From 7c55c2e68efc3a51865ee35d68ce589ad78f3f90 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:54:57 +0200 Subject: [PATCH 1/5] feat(formatters): support static text and field formats --- specifyweb/backend/stored_queries/format.py | 7 ++++ .../tests/test_format/test_format_sprintf.py | 36 ++++++++++++++++++- .../lib/components/Formatters/formatters.ts | 22 ++++++++++++ .../js_src/lib/components/Formatters/spec.ts | 4 +-- 4 files changed, 65 insertions(+), 4 deletions(-) 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/formatters.ts b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts index 6ab299b8406..0c7da61173b 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,18 @@ async function formatField( : num.toString(); } + if (displayFormat !== undefined) { + const substitution = displayFormat.includes('%s') + ? '%s' + : displayFormat.includes('%d') + ? '%d' + : undefined; + formatted = + formatted === undefined || substitution === undefined + ? displayFormat + : displayFormat.replace(substitution, formatted ?? ''); + } + return { formatted: formatted?.toString() ?? '', separator: (formatted ?? '') === '' ? '' : separator, @@ -301,6 +322,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..81d5f3ed5f7 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'), fieldFormatter: syncers.xmlAttribute('uiFieldFormatter', 'skip'), field: pipe(syncers.xmlContent, syncers.field(table?.name)), trimZeros: pipe( From 67c64f93ade825d086dc5d3fe816c5e070f233cf Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:55:02 +0200 Subject: [PATCH 2/5] feat(formatters): improve field format editor --- .../lib/components/Formatters/Definitions.tsx | 1 + .../lib/components/Formatters/Fields.tsx | 77 ++++++++++++++++++- .../Formatters/__tests__/formatters.test.ts | 35 +++++++++ .../js_src/lib/localization/resources.ts | 22 ++++++ 4 files changed, 132 insertions(+), 3 deletions(-) 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..5eb114cc507 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 && {resourcesText.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,56 @@ 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 : format }); + }; + + return ( +
+ updateFormat(value, suffix)} + /> + + … + + updateFormat(prefix, value)} + /> +
+ ); +} + function FieldFormatter({ field: [field, handleChange], }: { 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..ad0c3e8b8ba 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 @@ -143,6 +143,41 @@ 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: '(%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: '' }); + }); }); const referenceWorkId = 1; diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index 58e2b7eac4e..cc15eb55cca 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -994,6 +994,28 @@ 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', + }, + fieldFormat: { + 'en-us': 'Field Format', + 'de-ch': 'Feldformat', + 'es-es': 'Formato de campo', + 'fr-fr': 'Format de champ', + 'ru-ru': 'Формат поля', + 'uk-ua': 'Формат поля', + 'pt-br': 'Formato do campo', + 'hr-hr': 'Format polja', + nb: 'Feltformat', + }, expandConditionalField: { 'en-us': 'Expand Conditional Field', 'de-ch': 'Bedingtes Feld erweitern', From 5d3e8b8d7463adc20d4c9e76daf711aead9ff67f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:11:36 +0200 Subject: [PATCH 3/5] fix: failing test --- .../frontend/js_src/lib/components/Formatters/Fields.tsx | 5 ++++- specifyweb/frontend/js_src/lib/components/Formatters/spec.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx index 5eb114cc507..14b9e2ab051 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx @@ -276,7 +276,10 @@ function FieldFormat({ : [format, '']; const updateFormat = (newPrefix: string, newSuffix: string): void => { const format = `${newPrefix}${substitution}${newSuffix}`; - handleChange({ ...field, format: format === '%s' ? undefined : format }); + handleChange({ + ...field, + format: format === '%s' ? undefined : localized(format), + }); }; return ( diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts b/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts index 81d5f3ed5f7..5de578d2543 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts @@ -122,7 +122,7 @@ const fieldSpec = (table: SpecifyTable | undefined) => ), aggregator: syncers.xmlAttribute('aggregator', 'skip'), formatter: syncers.xmlAttribute('formatter', 'skip'), - format: syncers.xmlAttribute('format', 'skip'), + format: syncers.xmlAttribute('format', 'skip', false), fieldFormatter: syncers.xmlAttribute('uiFieldFormatter', 'skip'), field: pipe(syncers.xmlContent, syncers.field(table?.name)), trimZeros: pipe( From d3d1c0a5b415473d7f74ac12b52a4f421770b90f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:54:57 +0200 Subject: [PATCH 4/5] fix(tests): one more --- .../__snapshots__/formatters.test.ts.snap | 1 + .../Formatters/__tests__/formatters.test.ts | 28 ++++++++++++++++++- .../lib/components/Formatters/formatters.ts | 7 +++-- .../components/Syncer/__tests__/index.test.ts | 1 + 4 files changed, 34 insertions(+), 3 deletions(-) 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 ad0c3e8b8ba..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, @@ -156,7 +158,7 @@ describe('formatField', () => { formatter: undefined, aggregator: undefined, fieldFormatter: undefined, - format: '(%s)', + format: localized('(%s)'), separator: localized(''), trimZeros: false, }, @@ -178,6 +180,26 @@ describe('formatField', () => { ) ).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; @@ -230,6 +252,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(''), formatter: undefined, fieldFormatter: undefined, + format: undefined, trimZeros: false, }, { @@ -238,6 +261,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(''), formatter: undefined, fieldFormatter: undefined, + format: undefined, trimZeros: false, }, ], @@ -264,6 +288,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(' - '), formatter: undefined, fieldFormatter: undefined, + format: undefined, trimZeros: false, }, { @@ -272,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 0c7da61173b..45af4066194 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts @@ -245,9 +245,11 @@ async function formatField( ? '%d' : undefined; formatted = - formatted === undefined || substitution === undefined + substitution === undefined ? displayFormat - : displayFormat.replace(substitution, formatted ?? ''); + : formatted === undefined || formatted === '' + ? undefined + : displayFormat.replace(substitution, formatted); } return { @@ -272,6 +274,7 @@ export async function fetchPathAsString( separator: localized(''), aggregator: undefined, fieldFormatter: undefined, + format: undefined, formatFieldValue, trimZeros: false, }, 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, }, From 9d4dd3ccb1effe34f82717bbcfdeac337719ae18 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:20:24 +0200 Subject: [PATCH 5/5] fix(formatters): use proper localization --- .../js_src/lib/components/Formatters/Fields.tsx | 2 +- .../frontend/js_src/lib/localization/resources.ts | 11 ----------- specifyweb/frontend/js_src/lib/localization/schema.ts | 11 ----------- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx index 14b9e2ab051..02dbd81b841 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx @@ -74,7 +74,7 @@ export function Fields({ {resourcesText.separator()} {schemaText.field()} {resourcesText.displayFormat()} - {displayFormatter && {resourcesText.fieldFormat()}} + {displayFormatter && {schemaText.fieldFormat()}} diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index cc15eb55cca..491ce86de2f 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -1005,17 +1005,6 @@ export const resourcesText = createDictionary({ 'hr-hr': 'Format prikaza', nb: 'Visningsformat', }, - fieldFormat: { - 'en-us': 'Field Format', - 'de-ch': 'Feldformat', - 'es-es': 'Formato de campo', - 'fr-fr': 'Format de champ', - 'ru-ru': 'Формат поля', - 'uk-ua': 'Формат поля', - 'pt-br': 'Formato do campo', - 'hr-hr': 'Format polja', - nb: 'Feltformat', - }, 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);