Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions specifyweb/backend/stored_queries/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,38 @@ def test_accession(self, get_app_resource: Mock):
(acc3.id, ', [89]'),
(acc4.id, '')],
results
)
)

@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 = ("""
<formatters>
<format name="Accession" title="Accession"
class="edu.ku.brc.specify.datamodel.Accession" default="true">
<switch single="true">
<fields>
<field sep="[" />
<field format="%s]">remarks</field>
<field sep="!" />
</fields>
</switch>
</format>
</formatters>
""", 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)
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ function ConditionalFormatter({
separator: localized(' '),
aggregator: undefined,
formatter: undefined,
format: undefined,
fieldFormatter: undefined,
field: undefined,
trimZeros: false,
Expand Down
80 changes: 77 additions & 3 deletions specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,17 @@ 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]'
}
`}
>
<thead>
<tr>
<th>{resourcesText.separator()}</th>
<th>{schemaText.field()}</th>
{displayFormatter && <th>{schemaText.customFieldFormat()}</th>}
<th>{resourcesText.displayFormat()}</th>
{displayFormatter && <th>{schemaText.fieldFormat()}</th>}
<th />
</tr>
</thead>
Expand Down Expand Up @@ -115,6 +116,7 @@ export function Fields({
separator: localized(' '),
aggregator: undefined,
formatter: undefined,
format: undefined,
fieldFormatter: undefined,
field: undefined,
trimZeros: false,
Expand Down Expand Up @@ -203,6 +205,25 @@ function Field({
<CustomSelectElement {...fieldOptionsSelectProps} />
</div>
</td>
<td>
{field.field === undefined ? (
<Input.Text
aria-label={resourcesText.displayFormat()}
className="w-full min-w-0"
isReadOnly={isReadOnly}
placeholder="Static text"
value={field.format ?? ''}
onValueChange={(format): void =>
handleChange({
...field,
format: format.length === 0 ? undefined : format,
})
}
/>
) : (
<FieldFormat field={[field, handleChange]} />
)}
</td>
{displayFormatter && (
<td>
<FieldFormatter field={[field, handleChange]} />
Expand Down Expand Up @@ -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 (
<div
aria-label={resourcesText.displayFormat()}
className="inline-flex w-56 max-w-full items-center gap-1"
>
<Input.Text
aria-label="Text Before"
className="w-0 min-w-0 flex-1"
isReadOnly={isReadOnly}
placeholder="Text Before"
value={prefix}
onValueChange={(value): void => updateFormat(value, suffix)}
/>
<span
aria-label="Selected field value"
className="cursor-not-allowed select-none px-1 font-mono text-sm text-gray-500"
title="Selected field value"
>
…
</span>
<Input.Text
aria-label="Text After"
className="w-0 min-w-0 flex-1"
isReadOnly={isReadOnly}
placeholder="Text After"
value={suffix}
onValueChange={(value): void => updateFormat(prefix, value)}
/>
</div>
);
}

function FieldFormatter({
field: [field, handleChange],
}: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = `
"field": [
"[literalField LoanReturnPreparation.quantityResolved]",
],
"format": "%d",
"separator": " - ",
"trimZeros": false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ describe('formatField', () => {
formatter: undefined,
aggregator: undefined,
fieldFormatter: undefined,
format: undefined,
separator: localized(', '),
trimZeros: false,
},
Expand All @@ -132,6 +133,7 @@ describe('formatField', () => {
formatter: undefined,
aggregator: undefined,
fieldFormatter: undefined,
format: undefined,
formatFieldValue: false,
separator: localized(', '),
trimZeros: false,
Expand All @@ -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;
Expand Down Expand Up @@ -195,6 +252,7 @@ test('Circular formatting is detected and prevented', async () => {
separator: localized(''),
formatter: undefined,
fieldFormatter: undefined,
format: undefined,
trimZeros: false,
},
{
Expand All @@ -203,6 +261,7 @@ test('Circular formatting is detected and prevented', async () => {
separator: localized(''),
formatter: undefined,
fieldFormatter: undefined,
format: undefined,
trimZeros: false,
},
],
Expand All @@ -229,6 +288,7 @@ test('Circular formatting is detected and prevented', async () => {
separator: localized(' - '),
formatter: undefined,
fieldFormatter: undefined,
format: undefined,
trimZeros: false,
},
{
Expand All @@ -237,6 +297,7 @@ test('Circular formatting is detected and prevented', async () => {
separator: localized(' -- '),
formatter: undefined,
fieldFormatter: undefined,
format: undefined,
trimZeros: false,
},
],
Expand Down
25 changes: 25 additions & 0 deletions specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ async function formatField(
fieldFormatter,
formatFieldValue = true,
trimZeros = false,
format: displayFormat,
}: Formatter['definition']['fields'][number]['fields'][number] & {
readonly formatFieldValue?: boolean;
},
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -251,6 +274,7 @@ export async function fetchPathAsString(
separator: localized(''),
aggregator: undefined,
fieldFormatter: undefined,
format: undefined,
formatFieldValue,
trimZeros: false,
},
Expand Down Expand Up @@ -301,6 +325,7 @@ const autoGenerateFormatter = (table: SpecifyTable): Formatter => ({
formatter: undefined,
aggregator: undefined,
fieldFormatter: undefined,
format: undefined,
trimZeros: false,
})),
},
Expand Down
4 changes: 1 addition & 3 deletions specifyweb/frontend/js_src/lib/components/Formatters/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ test('Editing Data Object Formatter', () => {
aggregator: undefined,
formatter: undefined,
fieldFormatter: undefined,
format: undefined,
field: [getField(tables.Accession, 'accessionAgents')],
trimZeros: false,
},
Expand Down
11 changes: 11 additions & 0 deletions specifyweb/frontend/js_src/lib/localization/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading