diff --git a/specifyweb/backend/stored_queries/format.py b/specifyweb/backend/stored_queries/format.py index 1a8970b6238..6837a5af4b7 100644 --- a/specifyweb/backend/stored_queries/format.py +++ b/specifyweb/backend/stored_queries/format.py @@ -194,6 +194,13 @@ def make_expr(self, do_blank_null = True ) -> tuple[ QueryConstruct, blank_nulls, QueryFieldSpec]: + if path is None: + # A field without a mapped field is static text. The editor uses + # ``sep`` for these entries so that the text is retained when + # neighbouring fields are null. + static_text = fieldNodeAttrib.get('format', fieldNodeAttrib.get('sep', '')) + return query, blank_nulls(literal(static_text)), None + path = path.split('.') path = [inspect(orm_table).class_.__name__, *path] formatter_field_spec = QueryFieldSpec.from_path(path) diff --git a/specifyweb/backend/stored_queries/tests/test_format/test_format_sprintf.py b/specifyweb/backend/stored_queries/tests/test_format/test_format_sprintf.py index 918a98c7419..009d07fc0f2 100644 --- a/specifyweb/backend/stored_queries/tests/test_format/test_format_sprintf.py +++ b/specifyweb/backend/stored_queries/tests/test_format/test_format_sprintf.py @@ -56,4 +56,38 @@ def test_accession(self, get_app_resource: Mock): (acc3.id, ', [89]'), (acc4.id, '')], results - ) \ No newline at end of file + ) + + @patch('specifyweb.backend.stored_queries.format.app_resource.get_app_resource') + def test_static_fields_and_postfix(self, get_app_resource: Mock): + get_app_resource.return_value = (""" + + + + + + remarks + + + + + + """, None, None) + Accession.objects.all().delete() + accession = Accession.objects.create( + division=self.division, + remarks="Remarks", + accessionnumber="1", + ) + empty = Accession.objects.create( + division=self.division, + accessionnumber="2", + ) + table, query_fields = make_query_fields_test("Accession", [[]]) + + results = self._get_results(table, query_fields) + self.assertCountEqual([ + (accession.id, "[Remarks]!"), + (empty.id, "[!"), + ], results) diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx index ca931387c9d..4ff493272b3 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx @@ -200,6 +200,7 @@ function ConditionalFormatter({ separator: localized(' '), aggregator: undefined, formatter: undefined, + format: undefined, fieldFormatter: undefined, field: undefined, trimZeros: false, diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx index acb6b41f016..02dbd81b841 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx @@ -64,8 +64,8 @@ export function Fields({ [&_td]:!items-stretch ${ displayFormatter - ? 'grid-cols-[min-content_1fr_auto_min-content]' - : 'grid-cols-[min-content_1fr_min-content]' + ? 'grid-cols-[min-content_1fr_auto_auto_min-content]' + : 'grid-cols-[min-content_1fr_auto_min-content]' } `} > @@ -73,7 +73,8 @@ export function Fields({ {resourcesText.separator()} {schemaText.field()} - {displayFormatter && {schemaText.customFieldFormat()}} + {resourcesText.displayFormat()} + {displayFormatter && {schemaText.fieldFormat()}} @@ -115,6 +116,7 @@ export function Fields({ separator: localized(' '), aggregator: undefined, formatter: undefined, + format: undefined, fieldFormatter: undefined, field: undefined, trimZeros: false, @@ -203,6 +205,25 @@ function Field({ + + {field.field === undefined ? ( + + handleChange({ + ...field, + format: format.length === 0 ? undefined : format, + }) + } + /> + ) : ( + + )} + {displayFormatter && ( @@ -240,6 +261,59 @@ function Field({ ); } +function FieldFormat({ + field: [field, handleChange], +}: { + readonly field: GetSet< + Formatter['definition']['fields'][number]['fields'][number] + >; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + const format = field.format ?? ''; + const substitution = field.format?.match(/%[sd]/)?.[0] ?? '%s'; + const [prefix, suffix] = format.includes(substitution) + ? format.split(substitution, 2) + : [format, '']; + const updateFormat = (newPrefix: string, newSuffix: string): void => { + const format = `${newPrefix}${substitution}${newSuffix}`; + handleChange({ + ...field, + format: format === '%s' ? undefined : localized(format), + }); + }; + + return ( +
+ updateFormat(value, suffix)} + /> + + … + + updateFormat(prefix, value)} + /> +
+ ); +} + function FieldFormatter({ field: [field, handleChange], }: { diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap index dea1513e2c1..81a5cf42986 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap @@ -1536,6 +1536,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "field": [ "[literalField LoanReturnPreparation.quantityResolved]", ], + "format": "%d", "separator": " - ", "trimZeros": false, }, diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/formatters.test.ts b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/formatters.test.ts index 1cfc1557635..4f3d28f0393 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/formatters.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/formatters.test.ts @@ -106,6 +106,7 @@ describe('formatField', () => { formatter: undefined, aggregator: undefined, fieldFormatter: undefined, + format: undefined, separator: localized(', '), trimZeros: false, }, @@ -132,6 +133,7 @@ describe('formatField', () => { formatter: undefined, aggregator: undefined, fieldFormatter: undefined, + format: undefined, formatFieldValue: false, separator: localized(', '), trimZeros: false, @@ -143,6 +145,61 @@ describe('formatField', () => { separator: ', ', }); }); + + test('applies a field format and preserves static formatter entries', async () => { + const parentResource = new tables.Collector.Resource({ id: collectorId }); + await expect( + formatField( + { + field: [ + tables.Collector.strictGetField('agent'), + tables.Agent.strictGetField('agentType'), + ], + formatter: undefined, + aggregator: undefined, + fieldFormatter: undefined, + format: localized('(%s)'), + separator: localized(''), + trimZeros: false, + }, + parentResource + ) + ).resolves.toEqual({ formatted: '(Person)', separator: '' }); + await expect( + formatField( + { + field: undefined, + formatter: undefined, + aggregator: undefined, + fieldFormatter: undefined, + format: undefined, + separator: localized(' / '), + trimZeros: false, + }, + parentResource + ) + ).resolves.toEqual({ formatted: ' / ', separator: '' }); + }); + + test('omits empty mapped values from substitution formats', async () => { + const parentResource = new tables.Agent.Resource({ agentType: undefined }); + const field = [tables.Agent.strictGetField('agentType')]; + const base = { + field, + formatter: undefined, + aggregator: undefined, + fieldFormatter: undefined, + separator: localized(''), + trimZeros: false, + } as const; + + await expect( + formatField({ ...base, format: localized('%s') }, parentResource) + ).resolves.toEqual({ formatted: '', separator: '' }); + await expect( + formatField({ ...base, format: localized('%d') }, parentResource) + ).resolves.toEqual({ formatted: '', separator: '' }); + }); }); const referenceWorkId = 1; @@ -195,6 +252,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(''), formatter: undefined, fieldFormatter: undefined, + format: undefined, trimZeros: false, }, { @@ -203,6 +261,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(''), formatter: undefined, fieldFormatter: undefined, + format: undefined, trimZeros: false, }, ], @@ -229,6 +288,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(' - '), formatter: undefined, fieldFormatter: undefined, + format: undefined, trimZeros: false, }, { @@ -237,6 +297,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(' -- '), formatter: undefined, fieldFormatter: undefined, + format: undefined, trimZeros: false, }, ], diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts index 6ab299b8406..45af4066194 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts @@ -169,6 +169,7 @@ async function formatField( fieldFormatter, formatFieldValue = true, trimZeros = false, + format: displayFormat, }: Formatter['definition']['fields'][number]['fields'][number] & { readonly formatFieldValue?: boolean; }, @@ -184,6 +185,14 @@ async function formatField( const cycleDetector = [...cycleDetection, parentResource]; let formatted: string | undefined = undefined; + if (fields === undefined) { + // An unmapped field is a static formatter entry. Its separator is the + // text to emit, rather than a separator for another value. + return { + formatted: displayFormat ?? separator ?? '', + separator: localized(''), + }; + } const hasPermission = hasPathPermission(fields ?? [], 'read'); if (hasPermission) { @@ -229,6 +238,20 @@ async function formatField( : num.toString(); } + if (displayFormat !== undefined) { + const substitution = displayFormat.includes('%s') + ? '%s' + : displayFormat.includes('%d') + ? '%d' + : undefined; + formatted = + substitution === undefined + ? displayFormat + : formatted === undefined || formatted === '' + ? undefined + : displayFormat.replace(substitution, formatted); + } + return { formatted: formatted?.toString() ?? '', separator: (formatted ?? '') === '' ? '' : separator, @@ -251,6 +274,7 @@ export async function fetchPathAsString( separator: localized(''), aggregator: undefined, fieldFormatter: undefined, + format: undefined, formatFieldValue, trimZeros: false, }, @@ -301,6 +325,7 @@ const autoGenerateFormatter = (table: SpecifyTable): Formatter => ({ formatter: undefined, aggregator: undefined, fieldFormatter: undefined, + format: undefined, trimZeros: false, })), }, diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts b/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts index e36777f8025..5de578d2543 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts @@ -26,9 +26,6 @@ export const formattersSpec = f.store(() => .filter((fieldGroup) => fieldGroup.fields.length > 0) .map((fieldGroup) => ({ ...fieldGroup, - fields: fieldGroup.fields.filter( - (field) => field.field !== undefined - ), })), isSingle: definition.fields.length <= 1, }, @@ -125,6 +122,7 @@ const fieldSpec = (table: SpecifyTable | undefined) => ), aggregator: syncers.xmlAttribute('aggregator', 'skip'), formatter: syncers.xmlAttribute('formatter', 'skip'), + format: syncers.xmlAttribute('format', 'skip', false), fieldFormatter: syncers.xmlAttribute('uiFieldFormatter', 'skip'), field: pipe(syncers.xmlContent, syncers.field(table?.name)), trimZeros: pipe( diff --git a/specifyweb/frontend/js_src/lib/components/Syncer/__tests__/index.test.ts b/specifyweb/frontend/js_src/lib/components/Syncer/__tests__/index.test.ts index 359a83b877c..54b8860ad26 100644 --- a/specifyweb/frontend/js_src/lib/components/Syncer/__tests__/index.test.ts +++ b/specifyweb/frontend/js_src/lib/components/Syncer/__tests__/index.test.ts @@ -55,6 +55,7 @@ test('Editing Data Object Formatter', () => { aggregator: undefined, formatter: undefined, fieldFormatter: undefined, + format: undefined, field: [getField(tables.Accession, 'accessionAgents')], trimZeros: false, }, diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index 17737c2827b..251a66c1c53 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -994,6 +994,17 @@ export const resourcesText = createDictionary({ 'hr-hr': 'Prilagodite formate polja', nb: 'Tilpass feltformater', }, + displayFormat: { + 'en-us': 'Display Format', + 'de-ch': 'Anzeigeformat', + 'es-es': 'Formato de visualización', + 'fr-fr': "Format d'affichage", + 'ru-ru': 'Формат отображения', + 'uk-ua': 'Формат відображення', + 'pt-br': 'Formato de exibição', + 'hr-hr': 'Format prikaza', + nb: 'Visningsformat', + }, expandConditionalField: { 'en-us': 'Expand Conditional Field', 'de-ch': 'Bedingtes Feld erweitern', diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index d80d3f6c6aa..befcb585ef9 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -735,15 +735,4 @@ export const schemaText = createDictionary({ 'hr-hr': 'Vidljivo', nb: 'Synlig', }, - customFieldFormat: { - 'en-us': 'Custom Field Format', - 'de-ch': 'Format für benutzerdefinierte Felder', - 'es-es': 'Formato de campo personalizado', - 'fr-fr': 'Format de champ personnalisé', - 'ru-ru': 'Формат пользовательского поля', - 'uk-ua': 'Формат користувацького поля', - 'pt-br': 'Formato de campo personalizado', - 'hr-hr': 'Prilagođeni format polja', - nb: 'Tilpasset feltformat', - }, } as const);