diff --git a/specifyweb/backend/stored_queries/batch_edit.py b/specifyweb/backend/stored_queries/batch_edit.py index f656f6b7d65..1dd6cc01dc9 100644 --- a/specifyweb/backend/stored_queries/batch_edit.py +++ b/specifyweb/backend/stored_queries/batch_edit.py @@ -36,6 +36,7 @@ from specifyweb.specify.func import Func from . import models import json +from .format import ObjectFormatterProps from specifyweb.backend.workbench.upload.upload_plan_schema import schema from jsonschema import validate @@ -1133,10 +1134,15 @@ def run_batch_edit_query(props: BatchEditProps): field_specs=query_with_hidden, limit=limit, offset=offset, - format_agent_type=True, recordsetid=recordsetid, formatauditobjs=False, - format_picklist=True, + formatter_props=ObjectFormatterProps( + format_agent_type=True, + format_picklist=True, + format_types=False, + numeric_catalog_number=False, + format_expr=False, + ) ) to_many_planner = indexed.to_many_planner() diff --git a/specifyweb/backend/stored_queries/execution.py b/specifyweb/backend/stored_queries/execution.py index 4b630f145cd..f923e8c31b4 100644 --- a/specifyweb/backend/stored_queries/execution.py +++ b/specifyweb/backend/stored_queries/execution.py @@ -23,7 +23,7 @@ from specifyweb.specify.tree_utils import get_search_filters from . import models -from .format import ObjectFormatter +from .format import ObjectFormatter, ObjectFormatterProps from .query_construct import QueryConstruct from .queryfield import QueryField from .relative_date_utils import apply_absolute_date @@ -54,7 +54,6 @@ class QuerySort: def by_id(sort_id: int): return QuerySort.SORT_TYPES[sort_id] - class BuildQueryProps(NamedTuple): recordsetid: int | None = None replace_nulls: bool = False @@ -62,8 +61,13 @@ class BuildQueryProps(NamedTuple): distinct: bool = False series: bool = False implicit_or: bool = True - format_agent_type: bool = False - format_picklist: bool = False + formatter_props: ObjectFormatterProps = ObjectFormatterProps( + format_agent_type = False, + format_picklist = False, + format_types = True, + numeric_catalog_number = True, + format_expr = True, + ) def set_group_concat_max_len(connection): @@ -786,10 +790,9 @@ def execute( field_specs, limit, offset, - format_agent_type=False, recordsetid=None, formatauditobjs=False, - format_picklist=False, + formatter_props=ObjectFormatterProps(), ): "Build and execute a query, returning the results as a data structure for json serialization" @@ -805,8 +808,7 @@ def execute( formatauditobjs=formatauditobjs, distinct=distinct, series=series, - format_agent_type=format_agent_type, - format_picklist=format_picklist, + formatter_props=formatter_props, ), ) @@ -921,8 +923,7 @@ def build_query( collection, user, props.replace_nulls, - format_agent_type=props.format_agent_type, - format_picklist=props.format_picklist, + props=props.formatter_props, ), query=query_construct_query ) diff --git a/specifyweb/backend/stored_queries/format.py b/specifyweb/backend/stored_queries/format.py index f8c5797f63b..5862ab834f5 100644 --- a/specifyweb/backend/stored_queries/format.py +++ b/specifyweb/backend/stored_queries/format.py @@ -30,6 +30,7 @@ from .blank_nulls import blank_nulls from .query_construct import QueryConstruct from .queryfieldspec import QueryFieldSpec +from typing import NamedTuple logger = logging.getLogger(__name__) @@ -37,9 +38,17 @@ Agent_model = datamodel.get_table('Agent') Spauditlog_model = datamodel.get_table('SpAuditLog') +class ObjectFormatterProps(NamedTuple): + format_agent_type: bool = False, + format_picklist: bool = False, + format_types: bool = True, + numeric_catalog_number: bool = True, + # format_expr determines if make_expr should call _fieldformat, like in versions before 7.10.2. + # Batch edit expects it to be false to correctly handle some edge cases. + format_expr: bool = False class ObjectFormatter: - def __init__(self, collection, user, replace_nulls, format_agent_type=False, format_picklist=False): + def __init__(self, collection, user, replace_nulls, props: ObjectFormatterProps = ObjectFormatterProps()): formattersXML, _, __ = app_resource.get_app_resource(collection, user, 'DataObjFormatters') self.formattersDom = ElementTree.fromstring(formattersXML) @@ -49,8 +58,11 @@ def __init__(self, collection, user, replace_nulls, format_agent_type=False, for self.collection = collection self.replace_nulls = replace_nulls self.aggregator_count = 0 - self.format_agent_type = format_agent_type - self.format_picklist = format_picklist + self.format_agent_type = props.format_agent_type + self.format_picklist = props.format_picklist + self.format_types = props.format_types + self.numeric_catalog_number = props.numeric_catalog_number + self.format_expr = props.format_expr def getFormatterDef(self, specify_model: Table, formatter_name) -> Element | None: @@ -190,6 +202,15 @@ def make_expr(self, new_query, table, model, specify_field = query.build_join( specify_model, orm_table, formatter_field_spec.join_path) new_expr = getattr(table, specify_field.name) + + if self.format_expr: + new_expr = self._fieldformat(formatter_field_spec.table, formatter_field_spec.get_field(), new_expr) + + if 'trimzeros' in fieldNodeAttrib: + new_expr = case( + [(new_expr.op('REGEXP')('^-?[0-9]+(\\.[0-9]+)?$'), cast(new_expr, types.Numeric(65)))], + else_=new_expr + ) if 'format' in fieldNodeAttrib: new_expr = self.pseudo_sprintf(fieldNodeAttrib['format'], new_expr) @@ -391,13 +412,13 @@ def _fieldformat(self, table: Table, specify_field: Field, return blank_nulls(_case) if self.replace_nulls else _case - if specify_field.type == "java.lang.Boolean": + if self.format_types and specify_field.type == "java.lang.Boolean": return field != 0 - if specify_field.type in ("java.lang.Integer", "java.lang.Short"): + if self.format_types and specify_field.type in ("java.lang.Integer", "java.lang.Short"): return field - if specify_field is CollectionObject_model.get_field('catalogNumber') \ + if self.numeric_catalog_number and specify_field is CollectionObject_model.get_field('catalogNumber') \ and all_numeric_catnum_formats(self.collection): # While the frontend can format the catalogNumber if needed, # processes like reports, labels, and query exports generally @@ -405,7 +426,7 @@ def _fieldformat(self, table: Table, specify_field: Field, # See https://github.com/specify/specify7/issues/6464 return cast(field, types.Numeric(65)) - if specify_field.type == 'json' and isinstance(field.comparator.type, types.JSON): + if self.format_types and specify_field.type == 'json' and isinstance(field.comparator.type, types.JSON): return cast(field, types.Text) return field diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/UploadAttachment.test.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/UploadAttachment.test.tsx index 88c7a933a0c..1c338f17c8d 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/UploadAttachment.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/UploadAttachment.test.tsx @@ -1,63 +1,68 @@ -import React from 'react'; -import { mount } from '../../../tests/reactUtils'; -import { UploadAttachment } from '../Plugin'; -import { clearIdStore } from '../../../hooks/useId'; -import { LoadingContext } from '../../Core/Contexts'; -import { f } from '../../../utils/functools'; -import { fireEvent, waitFor } from '@testing-library/react'; -import { overrideAttachmentSettings } from '../attachments'; +import { fireEvent, waitFor } from "@testing-library/react"; +import React from "react"; + +import { clearIdStore } from "../../../hooks/useId"; +import { overrideAjax } from "../../../tests/ajax"; import attachmentSettings from '../../../tests/ajax/static/context/attachment_settings.json'; -import { overrideAjax } from '../../../tests/ajax'; -import * as Attachments from '../attachments'; -import { requireContext } from '../../../tests/helpers'; -import { deserializeResource } from '../../DataModel/serializers'; -import { testAttachment } from './utils'; -import { SpecifyResource } from '../../DataModel/legacyTypes'; -import { Attachment } from '../../DataModel/types'; +import { requireContext } from "../../../tests/helpers"; +import { mount } from "../../../tests/reactUtils"; +import { f } from "../../../utils/functools"; +import { LoadingContext } from "../../Core/Contexts"; +import { SpecifyResource } from "../../DataModel/legacyTypes"; +import { deserializeResource } from "../../DataModel/serializers"; +import { Attachment } from "../../DataModel/types"; +import { overrideAttachmentSettings } from "../attachments"; +import * as Attachments from "../attachments"; +import { UploadAttachment } from "../Plugin"; +import { testAttachment } from "./utils"; requireContext(); + async function uploadFileMock() { - return deserializeResource(testAttachment) as SpecifyResource; + return deserializeResource(testAttachment) ; } beforeEach(() => { - clearIdStore(); + clearIdStore(); }); -describe('UploadAttachment', () => { - const testToken = 'testToken'; - const testAttachmentLocation = 'testLocation'; - - overrideAjax( - `/attachment_gw/get_upload_params/`, - [{ token: testToken, attachmentLocation: testAttachmentLocation }], - { method: 'POST' } - ); - - test('simple render', async () => { - jest.spyOn(Attachments, 'uploadFile').mockImplementation(uploadFileMock); - jest.spyOn(console, 'warn').mockImplementation(); - const handleUploaded = jest.fn(); - - overrideAttachmentSettings(attachmentSettings); - const { asFragment, container, user } = mount( - - - +describe("UploadAttachment", () => { + + const testToken = 'testToken'; + const testAttachmentLocation = 'testLocation'; + + overrideAjax( + `/attachment_gw/get_upload_params/`, + [{ token: testToken, attachmentLocation: testAttachmentLocation }], + { method: 'POST' } ); - expect(asFragment()).toMatchSnapshot(); - const input = Array.from(container.getElementsByTagName('input'))[0]; - const testFile = new File(['Some Text Contents'], 'testName', { - type: 'text/plain', - }); + test("simple render", async () => { + jest.spyOn(Attachments, 'uploadFile').mockImplementation(uploadFileMock); + jest.spyOn(console, 'warn').mockImplementation(); + const handleUploaded = jest.fn(); - await user.upload(input, testFile); - fireEvent.change(input, { target: { files: [testFile] } }); + overrideAttachmentSettings(attachmentSettings); + const { asFragment, container, user } = mount( + + + + ); + expect(asFragment()).toMatchSnapshot(); + + const input = Array.from(container.getElementsByTagName('input'))[0]; + const testFile = new File(['Some Text Contents'], 'testName', { + type: 'text/plain', + }); + + await user.upload(input, testFile); + fireEvent.change(input, { target: { files: [testFile] } }); + + await waitFor(() => { + expect(handleUploaded).toHaveBeenCalled(); + }); - await waitFor(() => { - expect(handleUploaded).toBeCalled(); }); - }); -}); + +}) \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx index 03c14ce2985..b0e1ba77c64 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx @@ -202,6 +202,7 @@ function ConditionalFormatter({ formatter: 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 33ddfeece26..c9979f1e4cd 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx @@ -4,7 +4,7 @@ import { commonText } from '../../localization/common'; import { queryText } from '../../localization/query'; import { resourcesText } from '../../localization/resources'; import { schemaText } from '../../localization/schema'; -import type { GetSet } from '../../utils/types'; +import type { GetSet, IR } from '../../utils/types'; import { localized } from '../../utils/types'; import { removeItem, replaceItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; @@ -14,6 +14,12 @@ import { icons } from '../Atoms/Icons'; import { ReadOnlyContext } from '../Core/Contexts'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { fetchContext as fetchFieldFormatters } from '../FieldFormatters'; +import type { + CustomSelectElementPropsClosed, + CustomSelectElementPropsOpen, +} from '../WbPlanView/CustomSelectElement'; +import { CustomSelectElement } from '../WbPlanView/CustomSelectElement'; +import type { HtmlGeneratorFieldData } from '../WbPlanView/LineComponents'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { FormattersPickList, @@ -111,6 +117,7 @@ export function Fields({ formatter: undefined, fieldFormatter: undefined, field: undefined, + trimZeros: false, }, ]) } @@ -156,6 +163,14 @@ function Field({ const [openIndex, setOpenIndex] = React.useState( undefined ); + + const [isConfigurationOpen, setConfigurationOpen] = + React.useState(false); + const fieldOptionsSelectProps = fieldOptionsButtonProps({ + field: [field, handleChange], + configurationOpen: [isConfigurationOpen, setConfigurationOpen], + }); + return ( @@ -172,18 +187,21 @@ function Field({ /> - - handleChange({ - ...field, - field: fieldMapping, - }), - ]} - openIndex={[openIndex, setOpenIndex]} - table={table} - /> +
+ + handleChange({ + ...field, + field: fieldMapping, + }), + ]} + openIndex={[openIndex, setOpenIndex]} + table={table} + /> + +
{displayFormatter && ( @@ -281,3 +299,88 @@ function FieldFormatter({ ); } + +function fieldOptionsButtonProps({ + field: [field, handleChange], + configurationOpen: [isConfigurationOpen, setConfigurationOpen], +}: { + readonly field: GetSet< + Formatter['definition']['fields'][number]['fields'][number] + >; + readonly configurationOpen: GetSet; +}): CustomSelectElementPropsClosed | CustomSelectElementPropsOpen { + // Per-field configuration button using CustomSelectElement + return { + customSelectType: 'OPTIONS_LIST', + customSelectSubtype: 'simple', + customSelectOptionGroups: { + optionalFields: { + selectGroupLabel: undefined, + selectOptionsData: fieldOptionsMenu({ + isReadOnly: false, + columnOptions: { + trimZeros: field.trimZeros, + }, + onToggleTrimZeros: (trimZeros) => { + handleChange({ + ...field, + trimZeros, + }); + }, + }), + }, + }, + previewOption: { + optionName: 'mappingOptions', + optionLabel: ( + + {resourcesText.configureField()} + {icons.cog} + + ), + }, + selectLabel: resourcesText.configureField(), + ...(isConfigurationOpen + ? { + isOpen: true, + onClose: (): void => { + setConfigurationOpen(false); + }, + } + : { + isOpen: false, + onOpen: (): void => { + setConfigurationOpen(true); + }, + }), + }; +} + +export type FormatterFieldOptions = { + readonly trimZeros: boolean; +}; + +function fieldOptionsMenu({ + columnOptions, + isReadOnly, + onToggleTrimZeros: handleToggleTrimZeros, +}: { + readonly isReadOnly: boolean; + readonly columnOptions: FormatterFieldOptions; + readonly onToggleTrimZeros: (trimZeros: boolean) => void; +}): IR { + return { + trimZeros: { + optionLabel: ( + + {' '} + {resourcesText.trimZeros()} + + ), + }, + }; +} 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 0f965abef88..36d4f409e67 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 @@ -406,6 +406,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Accession.accessionNumber]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -428,12 +429,14 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "formatter": "Agent", "separator": "", + "trimZeros": false, }, { "field": [ "[literalField AccessionAgent.role]", ], "separator": " - ", + "trimZeros": false, }, ], }, @@ -456,6 +459,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Accession.accessionNumber]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -477,30 +481,35 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Address.address]", ], "separator": "", + "trimZeros": false, }, { "field": [ "[literalField Address.address2]", ], "separator": " ", + "trimZeros": false, }, { "field": [ "[literalField Address.city]", ], "separator": ", ", + "trimZeros": false, }, { "field": [ "[literalField Address.state]", ], "separator": ", ", + "trimZeros": false, }, { "field": [ "[literalField Address.postalCode]", ], "separator": " ", + "trimZeros": false, }, ], }, @@ -525,6 +534,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Agent.lastName]", ], "separator": "", + "trimZeros": false, }, ], "value": "0", @@ -536,18 +546,21 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Agent.lastName]", ], "separator": "", + "trimZeros": false, }, { "field": [ "[literalField Agent.firstName]", ], "separator": ", ", + "trimZeros": false, }, { "field": [ "[literalField Agent.middleInitial]", ], "separator": " ", + "trimZeros": false, }, ], "value": "1", @@ -559,6 +572,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Agent.lastName]", ], "separator": "", + "trimZeros": false, }, ], "value": "2", @@ -570,6 +584,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Agent.lastName]", ], "separator": "", + "trimZeros": false, }, ], "value": "3", @@ -592,6 +607,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Attachment.attachmentLocation]", ], "separator": "http://biimages.biodiversity.ku.edu/static/Ichthyology/originals/", + "trimZeros": false, }, ], }, @@ -614,6 +630,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "formatter": "Agent", "separator": "", + "trimZeros": false, }, ], }, @@ -635,6 +652,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField AutoNumberingScheme.schemeName]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -656,6 +674,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Borrow.receivedDate]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -678,6 +697,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Attachment.attachmentLocation]", ], "separator": "http://biimages.biodiversity.ku.edu/static/Ichthyology/originals/", + "trimZeros": false, }, ], }, @@ -700,6 +720,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField ReferenceWork.url]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -721,12 +742,14 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField CollectingEvent.stationFieldNumber]", ], "separator": "", + "trimZeros": false, }, { "field": [ "[literalField CollectingEvent.startDate]", ], "separator": ": ", + "trimZeros": false, }, { "field": [ @@ -735,6 +758,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Geography.fullName]", ], "separator": ": ", + "trimZeros": false, }, { "field": [ @@ -742,6 +766,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Locality.localityName]", ], "separator": ": ", + "trimZeros": false, }, { "field": [ @@ -749,6 +774,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Locality.latitude1]", ], "separator": ": ", + "trimZeros": false, }, { "field": [ @@ -756,6 +782,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Locality.longitude1]", ], "separator": ": ", + "trimZeros": false, }, ], }, @@ -807,12 +834,14 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField CollectingEvent.stationFieldNumber]", ], "separator": "", + "trimZeros": false, }, { "field": [ "[literalField CollectingEvent.startDate]", ], "separator": ", ", + "trimZeros": false, }, ], }, @@ -864,18 +893,21 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField CollectingTrip.text1]", ], "separator": "", + "trimZeros": false, }, { "field": [ "[literalField CollectingTrip.text2]", ], "separator": ": ", + "trimZeros": false, }, { "field": [ "[literalField CollectingTrip.number1]", ], "separator": ": ", + "trimZeros": false, }, ], }, @@ -897,6 +929,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Collection.collectionName]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -919,6 +952,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "fieldFormatter": "CatalogNumber", "separator": "", + "trimZeros": false, }, ], }, @@ -940,6 +974,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField CollectionRelType.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -962,6 +997,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "formatter": "Agent", "separator": "", + "trimZeros": false, }, ], }, @@ -983,6 +1019,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Container.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1004,6 +1041,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField DNAPrimer.primerDesignator]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1025,6 +1063,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField DNASequence.text2]", ], "separator": "https://www.ncbi.nlm.nih.gov/nuccore/", + "trimZeros": false, }, ], }, @@ -1046,6 +1085,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField DataType.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1067,6 +1107,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Deaccession.deaccessionNumber]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1092,6 +1133,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Taxon.fullName]", ], "separator": "", + "trimZeros": false, }, ], "value": "true", @@ -1104,6 +1146,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Taxon.fullName]", ], "separator": "", + "trimZeros": false, }, ], "value": "false", @@ -1126,6 +1169,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Discipline.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1147,6 +1191,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Division.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1168,6 +1213,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField FieldNotebookPage.pageNumber]", ], "separator": "", + "trimZeros": false, }, { "field": [ @@ -1176,6 +1222,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField FieldNotebook.name]", ], "separator": " - ", + "trimZeros": false, }, ], }, @@ -1198,6 +1245,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "formatter": "Agent", "separator": "", + "trimZeros": false, }, ], }, @@ -1219,6 +1267,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Geography.fullName]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1240,6 +1289,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField GeographyTreeDef.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1261,6 +1311,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField GeographyTreeDefItem.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1282,6 +1333,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField GeologicTimePeriod.fullName]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1303,6 +1355,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField GeologicTimePeriodTreeDef.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1324,6 +1377,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Journal.journalName]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1345,6 +1399,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField LithoStrat.fullName]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1366,6 +1421,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField LithoStratTreeDef.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1387,6 +1443,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField LithoStratTreeDefItem.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1408,6 +1465,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Loan.loanNumber]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1430,12 +1488,14 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "formatter": "Agent", "separator": "", + "trimZeros": false, }, { "field": [ "[literalField LoanAgent.role]", ], "separator": " - ", + "trimZeros": false, }, ], }, @@ -1470,12 +1530,14 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "formatter": "PrepType", "separator": "", + "trimZeros": false, }, { "field": [ "[literalField LoanReturnPreparation.quantityResolved]", ], "separator": " - ", + "trimZeros": false, }, ], }, @@ -1497,6 +1559,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Locality.localityName]", ], "separator": "", + "trimZeros": false, }, { "field": [ @@ -1504,18 +1567,21 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Geography.fullName]", ], "separator": "; ", + "trimZeros": false, }, { "field": [ "[literalField Locality.latitude1]", ], "separator": "; ", + "trimZeros": false, }, { "field": [ "[literalField Locality.longitude1]", ], "separator": ", ", + "trimZeros": false, }, ], }, @@ -1651,6 +1717,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField PaleoContext.paleoContextName]", ], "separator": "", + "trimZeros": false, }, { "field": [ @@ -1658,6 +1725,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField GeologicTimePeriod.fullName]", ], "separator": ", ", + "trimZeros": false, }, { "field": [ @@ -1665,6 +1733,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField LithoStrat.fullName]", ], "separator": ", ", + "trimZeros": false, }, ], }, @@ -1686,6 +1755,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Permit.permitNumber]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1707,6 +1777,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField PrepType.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1729,12 +1800,14 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "formatter": "PrepType", "separator": "", + "trimZeros": false, }, { "field": [ "[literalField Preparation.countAmt]", ], "separator": " - ", + "trimZeros": false, }, ], }, @@ -1756,6 +1829,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField ReferenceWork.title]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1777,18 +1851,21 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Shipment.shipmentDate]", ], "separator": "", + "trimZeros": false, }, { "field": [ "[literalField Shipment.shipmentNumber]", ], "separator": " - ", + "trimZeros": false, }, { "field": [ "[literalField Shipment.shipmentMethod]", ], "separator": " - ", + "trimZeros": false, }, ], }, @@ -1810,6 +1887,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Storage.fullName]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1831,6 +1909,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField StorageTreeDef.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1852,6 +1931,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField StorageTreeDefItem.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1873,6 +1953,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Taxon.fullName]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1895,6 +1976,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` ], "formatter": "ReferenceWork", "separator": "", + "trimZeros": false, }, ], }, @@ -1916,6 +1998,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField TaxonTreeDef.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1937,6 +2020,7 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField TaxonTreeDefItem.name]", ], "separator": "", + "trimZeros": false, }, ], }, @@ -1958,24 +2042,28 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField Project.projectNumber]", ], "separator": "institutionCode=", + "trimZeros": false, }, { "field": [ "[literalField Project.grantAgency]", ], "separator": "&collectionCode=", + "trimZeros": false, }, { "field": [ "[literalField Project.projectName]", ], "separator": "&catalogNumber=", + "trimZeros": false, }, { "field": [ "[literalField Project.url]", ], "separator": "&accesspoint=", + "trimZeros": false, }, ], }, @@ -1997,24 +2085,28 @@ exports[`Formatters are fetched and parsed correctly 1`] = ` "[literalField VoucherRelationship.institutionCode]", ], "separator": "institutionCode=", + "trimZeros": false, }, { "field": [ "[literalField VoucherRelationship.collectionCode]", ], "separator": "&collectionCode=", + "trimZeros": false, }, { "field": [ "[literalField VoucherRelationship.voucherNumber]", ], "separator": "&catalogNumber=", + "trimZeros": false, }, { "field": [ "[literalField VoucherRelationship.urlLink]", ], "separator": "&accesspoint=", + "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 f1a68f07fd7..a2db15bce92 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 @@ -98,6 +98,7 @@ describe('formatField', () => { aggregator: undefined, fieldFormatter: undefined, separator: localized(', '), + trimZeros: false, }, parentResource ) @@ -124,6 +125,7 @@ describe('formatField', () => { fieldFormatter: undefined, formatFieldValue: false, separator: localized(', '), + trimZeros: false, }, parentResource ) @@ -184,6 +186,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(''), formatter: undefined, fieldFormatter: undefined, + trimZeros: false, }, { field: [getField(tables.ReferenceWork, 'taxonCitations')], @@ -191,6 +194,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(''), formatter: undefined, fieldFormatter: undefined, + trimZeros: false, }, ], }, @@ -216,6 +220,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(' - '), formatter: undefined, fieldFormatter: undefined, + trimZeros: false, }, { field: [getField(tables.TaxonCitation, 'referenceWork')], @@ -223,6 +228,7 @@ test('Circular formatting is detected and prevented', async () => { separator: localized(' -- '), formatter: undefined, fieldFormatter: 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 48549e48e65..69e3f0bcc13 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts @@ -145,6 +145,7 @@ async function formatField( aggregator, fieldFormatter, formatFieldValue = true, + trimZeros = false, }: Formatter['definition']['fields'][number]['fields'][number] & { readonly formatFieldValue?: boolean; }, @@ -195,6 +196,11 @@ async function formatField( ? naiveFormatter(parentResource.specifyTable.name, parentResource.id) : userText.noPermission(); + if (trimZeros) + formatted = Number.isNaN(Number(formatted)) + ? formatted + : Number(formatted).toString(); + return { formatted: formatted?.toString() ?? '', separator: (formatted ?? '') === '' ? '' : separator, @@ -218,6 +224,7 @@ export async function fetchPathAsString( aggregator: undefined, fieldFormatter: undefined, formatFieldValue, + trimZeros: false, }, baseResource ); @@ -266,6 +273,7 @@ const autoGenerateFormatter = (table: SpecifyTable): Formatter => ({ formatter: undefined, aggregator: undefined, fieldFormatter: 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 fc1651d82a3..e36777f8025 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/spec.ts @@ -127,6 +127,11 @@ const fieldSpec = (table: SpecifyTable | undefined) => formatter: syncers.xmlAttribute('formatter', 'skip'), fieldFormatter: syncers.xmlAttribute('uiFieldFormatter', 'skip'), field: pipe(syncers.xmlContent, syncers.field(table?.name)), + trimZeros: pipe( + syncers.xmlAttribute('trimZeros', 'skip'), + syncers.maybe(syncers.toBoolean), + syncers.default(false) + ), }); const aggregatorSpec = f.store(() => 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 bb7abf22a9c..359a83b877c 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 @@ -56,6 +56,7 @@ test('Editing Data Object Formatter', () => { formatter: undefined, fieldFormatter: undefined, field: [getField(tables.Accession, 'accessionAgents')], + trimZeros: false, }, ], }, diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx index de59957a8ac..3d2d6120900 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/CustomSelectElement.tsx @@ -239,7 +239,7 @@ export type CustomSelectElementPropsOpenBase = CustomSelectElementPropsBase & { readonly onClose?: () => void; }; -type CustomSelectElementPropsOpen = CustomSelectElementPropsOpenBase & { +export type CustomSelectElementPropsOpen = CustomSelectElementPropsOpenBase & { readonly customSelectOptionGroups: IR; readonly autoMapperSuggestions?: JSX.Element; }; diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index 25c892c778d..d3c9ebafd8e 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -947,4 +947,13 @@ export const resourcesText = createDictionary({ 'ru-ru': 'Количество приготовлений не может быть отрицательным', 'uk-ua': "Кількість підготовок не може бути від'ємним значенням", }, + configureField: { + 'en-us': 'Configure field', + }, + trimZeros: { + 'en-us': 'Trim Zeros', + }, + trimZerosDescription: { + 'en-us': 'Remove leading zeros from numeric values.', + }, } as const);