diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx index f4109fcffe8..b44e76eea67 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx @@ -21,6 +21,8 @@ import type { } from '../DataModel/types'; import { RssExportFeedEditor } from '../ExportFeed'; import { exportFeedSpec } from '../ExportFeed/spec'; +import { FieldFormattersEditor } from '../FieldFormatters/Editor'; +import { fieldFormattersSpec } from '../FieldFormatters/spec'; import { DataObjectFormatter } from '../Formatters'; import { formattersSpec } from '../Formatters/spec'; import { FormEditor } from '../FormEditor'; @@ -168,7 +170,10 @@ export const visualAppResourceEditors = f.store< visual: WebLinkEditor, xml: generateXmlEditor(webLinksSpec), }, - uiFormatters: undefined, + uiFormatters: { + visual: FieldFormattersEditor, + xml: generateXmlEditor(fieldFormattersSpec), + }, dataObjectFormatters: { visual: DataObjectFormatter, xml: generateXmlEditor(formattersSpec), diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx index 8b1f46c0b83..000eda195d2 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -157,7 +157,7 @@ export const appResourceSubTypes = ensure>()({ documentationUrl: 'https://github.com/specify/specify6/blob/master/config/backstop/uiformatters.xml', icon: icons.hashtag, - label: resourcesText.uiFormatters(), + label: resourcesText.fieldFormatters(), }, dataObjectFormatters: { mimeType: 'text/xml', diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx index cf0f6294094..1bd5edbfb99 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Form.tsx @@ -257,6 +257,7 @@ export const Input = { props.onChange?.(event); }, readOnly: isReadOnly, + ...withPreventWheel(props.onWheel), } ), Float: wrap< diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts index 6dbb39a6931..48584ad4fea 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/__tests__/utils.test.ts @@ -1,11 +1,13 @@ +import type { LocalizedString } from 'typesafe-i18n'; + import { requireContext } from '../../../tests/helpers'; -import { formatterToParser } from '../../../utils/parser/definitions'; +import { fieldFormatterToParser } from '../../../utils/parser/definitions'; import type { IR, RA } from '../../../utils/types'; import { localized } from '../../../utils/types'; import { tables } from '../../DataModel/tables'; import { CatalogNumberNumeric, - formatterTypeMapper, + fieldFormatterTypeMapper, UiFormatter, } from '../../FieldFormatters'; import { syncFieldFormat } from '../../Formatters/fieldFormat'; @@ -49,7 +51,7 @@ const fileNameTestSpec: TestDefinition = { false, localized('testNumeric'), [ - new formatterTypeMapper.numeric({ + new fieldFormatterTypeMapper.numeric({ size: 3, autoIncrement: true, byYear: false, @@ -74,10 +76,10 @@ const fileNameTestSpec: TestDefinition = { false, localized('testRegex'), [ - new formatterTypeMapper.regex({ + new fieldFormatterTypeMapper.regex({ size: 3, autoIncrement: true, - value: localized('^\\d{1,6}(?:[a-zA-Z]{1,2})?$'), + placeholder: localized('^\\d{1,6}(?:[a-zA-Z]{1,2})?$'), byYear: false, }), ], @@ -103,13 +105,14 @@ describe('file names resolution test', () => { jest.spyOn(console, 'error').mockImplementation(); const field = tables.CollectionObject.getLiteralField('text1')!; const getResultFormatter = - (formatter: UiFormatter) => (value: number | string | undefined) => + (formatter: UiFormatter) => + (value: number | string | undefined): LocalizedString | undefined => value === undefined || value === null ? undefined : syncFieldFormat( field, value.toString(), - formatterToParser(field, formatter), + fieldFormatterToParser(field, formatter), undefined, true ); diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts index ae5e170cebb..7030ecf7e33 100644 --- a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts @@ -28,10 +28,8 @@ import { serializeResource, } from '../DataModel/serializers'; import { strictGetTable, tables } from '../DataModel/tables'; -import type { SpQuery, Tables } from '../DataModel/types'; -import type { CollectionObject } from '../DataModel/types'; +import type { CollectionObject, SpQuery, Tables } from '../DataModel/types'; import type { UiFormatter } from '../FieldFormatters'; -import { formatterTypeMapper } from '../FieldFormatters'; import { queryFieldFilterSpecs } from '../QueryBuilder/FieldFilterSpec'; import { makeQueryField } from '../QueryBuilder/fromTree'; import type { QueryFieldWithPath } from '../Statistics/types'; @@ -105,7 +103,7 @@ function generateInQueryResource( }; const { path, ...field } = rawField; return serializeResource( - makeQueryField(baseTable, rawField.path, { ...field, position: index }) + makeQueryField(baseTable, path, { ...field, position: index }) ); }); @@ -232,24 +230,15 @@ export function resolveFileNames( // BUG: Won't catch if formatters begin or end with a space const splitName = stripFileExtension(fileName).trim(); let nameToParse = splitName; - if ( - formatter !== undefined && - formatter.fields.every( - (field) => !(field instanceof formatterTypeMapper.regex) - ) - ) { - const formattedLength = formatter.fields.reduce( - (length, field) => length + field.size, - 0 - ); - nameToParse = fileName.trim().slice(0, formattedLength); + if (formatter?.parts.every((field) => field.type !== 'regex') === true) { + nameToParse = fileName.trim().slice(0, formatter.size); } let formatted = nameToParse === '' ? undefined : getFormatted(nameToParse); - const numericFields = formatter?.fields.filter( - (field) => field instanceof formatterTypeMapper.numeric + const numericFields = formatter?.parts.filter( + (field) => field.type === 'numeric' ); if ( - formatter?.fields?.length === 1 && + formatter?.parts?.length === 1 && numericFields?.length === 1 && formatted === undefined && splitName !== '' diff --git a/specifyweb/frontend/js_src/lib/components/DataEntryTables/fetch.ts b/specifyweb/frontend/js_src/lib/components/DataEntryTables/fetch.ts new file mode 100644 index 00000000000..260a3d4a85a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataEntryTables/fetch.ts @@ -0,0 +1,59 @@ +import { ajax } from '../../utils/ajax'; +import { getAppResourceUrl } from '../../utils/ajax/helpers'; +import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import { parseJavaClassName } from '../DataModel/resource'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import { fetchContext as fetchSchema, getTable } from '../DataModel/tables'; +import type { Tables } from '../DataModel/types'; +import { fetchView } from '../FormParse'; +import { cacheableUrl } from '../InitialContext'; +import { xmlToSpec } from '../Syncer/xmlUtils'; +import { dataEntryItems } from './spec'; + +const url = cacheableUrl(getAppResourceUrl('DataEntryTaskInit')); + +export const fetchLegacyForms = f.store( + async (): Promise> => + Promise.all([ + ajax(url, { + headers: { Accept: 'text/xml' }, + }), + fetchSchema, + ]) + .then(async ([{ data }]) => + Promise.all( + xmlToSpec(data, dataEntryItems()).items.map(async ({ viewName }) => + f.maybe(viewName, resolveTable) + ) + ) + ) + .then(filterArray) +); + +async function resolveTable( + viewName: string +): Promise { + const table = getTable(viewName); + if (typeof table === 'object') return table; + const form = await fetchView(viewName); + return typeof form === 'object' + ? getTable(parseJavaClassName(form.class)) + : undefined; +} + +export const defaultFormTablesConfig: RA = [ + 'CollectionObject', + 'CollectingEvent', + 'Locality', + 'Taxon', + 'Agent', + 'Geography', + 'DNASequence', + 'ReferenceWork', +]; + +export const exportsForTests = { + fetchLegacyForms, +}; diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/specifyTable.ts b/specifyweb/frontend/js_src/lib/components/DataModel/specifyTable.ts index 4b957207d1c..5a818256f6b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/specifyTable.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/specifyTable.ts @@ -458,7 +458,7 @@ export class SpecifyTable { * * I.e, table can be scoped to collection using a "collectionMemberId" field * (which is not a relationship - sad). Back-end looks at that relationship - * for scoping inconsistenly. Front-end does not look at all. + * for scoping inconsistently. Front-end does not look at all. */ public getScopingRelationship(): Relationship | undefined { this.scopingRelationship ??= diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Editor.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Editor.tsx new file mode 100644 index 00000000000..dbea71331fa --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Editor.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import type { AppResourceTabProps } from '../AppResources/TabDefinitions'; +import { createXmlContext, XmlEditor } from '../Formatters'; +import { fieldFormattersRoutes } from './Routes'; +import { fieldFormattersSpec } from './spec'; + +export function FieldFormattersEditor(props: AppResourceTabProps): JSX.Element { + return ( + + ); +} + +export const FieldFormattersContext = createXmlContext(fieldFormattersSpec()); diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Element.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Element.tsx new file mode 100644 index 00000000000..8ea50a884bc --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Element.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { resourcesText } from '../../localization/resources'; +import { ReadOnlyContext } from '../Core/Contexts'; +import { makeXmlEditorShellSlot, XmlEditorShell } from '../Formatters/Element'; +import { FieldFormatterElement } from './FieldFormatter'; +import type { FieldFormattersOutlet } from './List'; +import type { FieldFormatter } from './spec'; + +export function FieldFormatterWrapper(): JSX.Element { + const { index } = useParams(); + const isReadOnly = React.useContext(ReadOnlyContext); + return ( + + header={resourcesText.fieldFormatters()} + > + {makeXmlEditorShellSlot( + (getSet) => ( + + ), + index, + isReadOnly + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx new file mode 100644 index 00000000000..87627115e0f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -0,0 +1,189 @@ +import React from 'react'; + +import { resourcesText } from '../../localization/resources'; +import { schemaText } from '../../localization/schema'; +import { + fieldFormatterToParser, + getValidationAttributes, +} from '../../utils/parser/definitions'; +import type { GetSet, RA } from '../../utils/types'; +import { ErrorMessage } from '../Atoms'; +import { className } from '../Atoms/className'; +import { Input, Label } from '../Atoms/Form'; +import type { AnySchema } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { LiteralField, Relationship } from '../DataModel/specifyField'; +import { genericTables } from '../DataModel/tables'; +import { softError } from '../Errors/assert'; +import { ResourceMapping } from '../Formatters/Components'; +import { ResourcePreview } from '../Formatters/Preview'; +import { hasTablePermission } from '../Permissions/helpers'; +import type { MappingLineData } from '../WbPlanView/navigator'; +import type { UiFormatter } from '.'; +import { resolveFieldFormatter } from '.'; +import { FieldFormatterParts } from './Parts'; +import type { FieldFormatter } from './spec'; + +export function FieldFormatterElement({ + item, +}: { + readonly item: GetSet; +}): JSX.Element { + const [fieldFormatter] = item; + return ( + <> + + {fieldFormatter.external === undefined && + typeof fieldFormatter.table === 'object' ? ( + + ) : ( + {resourcesText.editorNotAvailable()} + )} + + + ); +} + +function FieldPicker({ + fieldFormatter: [fieldFormatter, setFieldFormatter], +}: { + readonly fieldFormatter: GetSet; +}): JSX.Element | null { + const openIndex = React.useState(undefined); + const mapping = React.useMemo( + () => (fieldFormatter.field === undefined ? [] : [fieldFormatter.field]), + [fieldFormatter.field] + ); + return fieldFormatter.table === undefined ? null : ( +
+ {schemaText.field()} + { + if (mapping !== undefined && mapping?.length > 1) + softError('Expected mapping length to be no more than 1'); + const field = mapping?.[0]; + if (field?.isRelationship === true) { + softError( + 'Did not expect relationship field in field formatter mapping' + ); + } else { + setFieldFormatter({ ...fieldFormatter, field }); + } + }, + ]} + openIndex={openIndex} + table={fieldFormatter.table} + /> +
+ ); +} + +const excludeNonLiteral = (mappingData: MappingLineData): MappingLineData => ({ + ...mappingData, + fieldsData: Object.fromEntries( + Object.entries(mappingData.fieldsData).filter(([name, fieldData]) => { + if (fieldData.tableName !== undefined) return false; + const field: LiteralField | Relationship | undefined = + mappingData.tableName === undefined + ? undefined + : genericTables[mappingData.tableName].field[name]; + if (field === undefined) return false; + return ( + !field.isReadOnly && !field.isVirtual && !field.overrides.isReadOnly + ); + }) + ), +}); + +function FieldFormatterPreview({ + fieldFormatter, +}: { + readonly fieldFormatter: FieldFormatter; +}): JSX.Element | null { + const resolvedFormatter = React.useMemo( + () => resolveFieldFormatter(fieldFormatter, 0), + [fieldFormatter] + ); + const doFormatting = React.useCallback( + (resources: RA>) => + resources.map((resource) => + formatterToPreview(resource, fieldFormatter, resolvedFormatter) + ), + [fieldFormatter, resolvedFormatter] + ); + return typeof fieldFormatter.table === 'object' && + hasTablePermission(fieldFormatter.table.name, 'read') ? ( + <> + + + + ) : null; +} + +function formatterToPreview( + resource: SpecifyResource, + fieldFormatter: FieldFormatter, + resolvedFormatter: UiFormatter | undefined +): string { + if (resolvedFormatter === undefined) + return resourcesText.formatterPreviewUnavailable(); + + const field = fieldFormatter.field; + if (field === undefined) return ''; + + const value = String(resource.get(field.name) ?? ''); + if (value.length === 0) return resolvedFormatter.defaultValue; + + const formatted = resolvedFormatter.format(value); + + return formatted === undefined + ? `${value} ${resourcesText.nonConformingInline()}` + : formatted; +} + +function FieldFormatterPreviewField({ + field, + resolvedFormatter, +}: { + readonly field: LiteralField | undefined; + readonly resolvedFormatter: UiFormatter | undefined; +}): JSX.Element | null { + const [value, setValue] = React.useState(''); + const isConforming = React.useMemo( + () => resolvedFormatter?.parse(value) !== undefined, + [value, resolvedFormatter] + ); + const parser = React.useMemo( + () => + field === undefined || resolvedFormatter === undefined + ? { type: 'text' as const } + : fieldFormatterToParser(field, resolvedFormatter), + [field, resolvedFormatter] + ); + + const validationAttributes = getValidationAttributes(parser); + return resolvedFormatter === undefined ? null : ( + + {`${resourcesText.exampleField()} ${ + isConforming ? '' : resourcesText.nonConformingInline() + }`} + + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx new file mode 100644 index 00000000000..5c497d18cfd --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; + +import { resourcesText } from '../../localization/resources'; +import type { GetSet, RA } from '../../utils/types'; +import { getUniqueName } from '../../utils/uniquifyName'; +import { XmlEntryList } from '../Formatters/List'; +import { SafeOutlet } from '../Router/RouterUtils'; +import { updateXml } from '../Syncer/xmlToString'; +import { FieldFormattersContext } from './Editor'; +import type { FieldFormatter } from './spec'; + +export type FieldFormattersOutlet = { + readonly items: GetSet>; +}; + +export function FieldFormatterEditorWrapper(): JSX.Element { + const { + parsed: [parsed, setParsed], + syncer: { deserializer }, + onChange: handleChange, + } = React.useContext(FieldFormattersContext)!; + + return ( + + items={[ + parsed.fieldFormatters, + (fieldFormatters): void => { + const newParsed = { ...parsed, fieldFormatters }; + setParsed(newParsed); + handleChange(() => updateXml(deserializer(newParsed))); + }, + ]} + /> + ); +} + +export function FieldFormattersList(): JSX.Element { + const { tableName } = useParams(); + const { items } = useOutletContext(); + const navigate = useNavigate(); + + return ( + { + const newName = getUniqueName( + table.name, + currentItems.map((item) => item.name), + undefined, + 'name' + ); + const newTitle = getUniqueName( + table.label, + currentItems.map((item) => item.title ?? '') + ); + return { + isSystem: false, + name: newName, + title: newTitle, + table, + field: undefined, + isDefault: currentItems.length === 0, + legacyType: undefined, + legacyPartialDate: undefined, + external: undefined, + parts: [], + raw: { + javaClass: undefined, + legacyAutoNumber: undefined, + }, + }; + }} + header={resourcesText.availableFieldFormatters()} + items={items} + tableName={tableName} + onGoBack={(): void => navigate('../')} + /> + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx new file mode 100644 index 00000000000..634d135b81d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -0,0 +1,268 @@ +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { useTriggerState } from '../../hooks/useTriggerState'; +import { commonText } from '../../localization/common'; +import { formsText } from '../../localization/forms'; +import { resourcesText } from '../../localization/resources'; +import type { GetSet } from '../../utils/types'; +import { localized } from '../../utils/types'; +import { removeItem, replaceItem } from '../../utils/utils'; +import { Button } from '../Atoms/Button'; +import { className } from '../Atoms/className'; +import { Input, Label, Select } from '../Atoms/Form'; +import { icons } from '../Atoms/Icons'; +import { ReadOnlyContext } from '../Core/Contexts'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import { fieldFormatterLocalization } from '.'; +import type { FieldFormatter, FieldFormatterPart } from './spec'; +import { + fieldFormatterTypesWithForcedSize, + normalizeFieldFormatterPart, +} from './spec'; + +export function FieldFormatterParts({ + fieldFormatter: [fieldFormatter, setFieldFormatter], +}: { + readonly fieldFormatter: GetSet; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + + const { parts } = fieldFormatter; + + const setParts = (newParts: typeof parts): void => + setFieldFormatter({ + ...fieldFormatter, + parts: newParts, + }); + + return ( + <> + {parts.length === 0 ? undefined : ( + + + + + + + + + + {parts.map((part, index) => ( + setParts(replaceItem(parts, index, part)), + ]} + table={fieldFormatter.table} + onRemove={(): void => setParts(removeItem(parts, index))} + /> + ))} + +
{resourcesText.type()}{commonText.size()}{resourcesText.value()} + +
+ )} + {isReadOnly ? undefined : ( +
+ + setParts([ + ...parts, + { + type: 'constant', + size: 1, + placeholder: localized(''), + regexPlaceholder: undefined, + byYear: false, + autoIncrement: false, + }, + ]) + } + > + {resourcesText.addField()} + +
+ )} + + ); +} + +function Part({ + part: [part, handleChange], + onRemove: handleRemove, + table, +}: { + readonly part: GetSet; + readonly onRemove: () => void; + readonly table: SpecifyTable | undefined; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + + const enforcePlaceholderSize = fieldFormatterTypesWithForcedSize.has( + part.type as 'constant' + ); + + return ( + + + + + + + handleChange( + normalizeFieldFormatterPart({ + ...part, + size, + }) + ) + } + /> + + + + handleChange( + normalizeFieldFormatterPart({ + ...part, + [part.type === 'regex' ? 'regexPlaceholder' : 'placeholder']: + placeholder, + }) + ) + } + /> + + + {part.type === 'numeric' ? ( + + + handleChange({ + ...part, + autoIncrement, + }) + } + /> + {formsText.autoNumber()} + + ) : part.type === 'year' ? ( + + + handleChange({ + ...part, + byYear, + }) + } + /> + {formsText.autoNumberByYear()} + + ) : part.type === 'regex' ? ( + + handleChange({ + ...part, + placeholder, + }) + } + /> + ) : undefined} + + + {isReadOnly ? undefined : ( + + {icons.trash} + + )} + + + ); +} + +const maxSize = 99; + +function RegexField({ + value, + onChange: handleChange, +}: { + readonly value: LocalizedString; + readonly onChange: (newValue: LocalizedString) => void; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + const [pendingValue, setPendingValue] = useTriggerState(value); + return ( + { + try { + // Regex may be coming from the user, thus disable strict mode + // eslint-disable-next-line require-unicode-regexp + void new RegExp(target.value); + handleChange(target.value as LocalizedString); + target.setCustomValidity(''); + } catch (error: unknown) { + target.setCustomValidity(String(error)); + target.reportValidity(); + } + }} + onValueChange={setPendingValue} + /> + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx new file mode 100644 index 00000000000..ae888a3d7d5 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { Redirect } from '../Router/Redirect'; +import { toReactRoutes } from '../Router/RouterUtils'; + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +export const fieldFormattersRoutes = toReactRoutes([ + { + index: true, + element: , + }, + { + path: 'field-formatters', + children: [ + { + index: true, + element: async () => + import('./Table').then( + ({ FieldFormatterTablesList }) => FieldFormatterTablesList + ), + }, + { + path: ':tableName', + element: async () => + import('./List').then( + ({ FieldFormatterEditorWrapper }) => FieldFormatterEditorWrapper + ), + children: [ + { + element: async () => + import('./List').then( + ({ FieldFormattersList }) => FieldFormattersList + ), + children: [ + { index: true }, + { + path: ':index', + element: async () => + import('./Element').then( + ({ FieldFormatterWrapper }) => FieldFormatterWrapper + ), + }, + ], + }, + ], + }, + ], + }, +]); +/* eslint-enable @typescript-eslint/explicit-function-return-type */ diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Table.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Table.tsx new file mode 100644 index 00000000000..8b636a08b59 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Table.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { resourcesText } from '../../localization/resources'; +import { filterArray } from '../../utils/types'; +import { group } from '../../utils/utils'; +import { formatNumber } from '../Atoms/Internationalization'; +import { resolveRelative } from '../Router/queryString'; +import { TableList } from '../SchemaConfig/Tables'; +import { FieldFormattersContext } from './Editor'; + +export function FieldFormatterTablesList(): JSX.Element { + const { + parsed: [{ fieldFormatters }], + } = React.useContext(FieldFormattersContext)!; + + const grouped = Object.fromEntries( + group( + filterArray( + fieldFormatters.map((item) => + item.table === undefined ? undefined : [item.table.name, item] + ) + ) + ) + ); + + return ( + <> +

{resourcesText.fieldFormattersDescription()}

+ resolveRelative(`./${name}`)} + > + {({ name }): string | undefined => + grouped[name] === undefined + ? undefined + : `(${formatNumber(grouped[name].length)})` + } + + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap index f9cc2770fc3..83890545b8f 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/__snapshots__/index.test.ts.snap @@ -4,540 +4,564 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` { "AccessionNumber": UiFormatter { "field": "[literalField Accession.accessionNumber]", - "fields": [ - YearField { + "isSystem": true, + "name": "AccessionNumber", + "originalIndex": 0, + "parts": [ + YearPart { "autoIncrement": false, "byYear": true, - "pattern": undefined, + "placeholder": "YEAR", + "regexPlaceholder": undefined, "size": 4, "type": "year", - "value": "YEAR", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - AlphaNumberField { + AlphaNumberPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "AA", + "regexPlaceholder": undefined, "size": 2, - "type": "alphanumeric", - "value": "AA", + "type": "alpha", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - NumericField { + NumericPart { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "###", + "regexPlaceholder": undefined, "size": 3, "type": "numeric", - "value": "###", }, ], - "isSystem": true, - "name": "AccessionNumber", "table": "[table Accession]", "title": "AccessionNumber", }, "AccessionNumberByYear": UiFormatter { "field": "[literalField Accession.accessionNumber]", - "fields": [ - YearField { + "isSystem": true, + "name": "AccessionNumberByYear", + "originalIndex": 1, + "parts": [ + YearPart { "autoIncrement": false, "byYear": true, - "pattern": undefined, + "placeholder": "YEAR", + "regexPlaceholder": undefined, "size": 4, "type": "year", - "value": "YEAR", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - AlphaNumberField { + AlphaNumberPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "AA", + "regexPlaceholder": undefined, "size": 2, - "type": "alphanumeric", - "value": "AA", + "type": "alpha", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - AlphaNumberField { + AlphaNumberPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "AAA", + "regexPlaceholder": undefined, "size": 3, - "type": "alphanumeric", - "value": "AAA", + "type": "alpha", }, ], - "isSystem": true, - "name": "AccessionNumberByYear", "table": "[table Accession]", "title": "AccessionNumberByYear", }, "AccessionStringFormatter": UiFormatter { "field": "[literalField Accession.accessionNumber]", - "fields": [ - AlphaNumberField { + "isSystem": true, + "name": "AccessionStringFormatter", + "originalIndex": 2, + "parts": [ + AlphaNumberPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "AAAAAAAAAA", + "regexPlaceholder": undefined, "size": 10, - "type": "alphanumeric", - "value": "AAAAAAAAAA", + "type": "alpha", }, ], - "isSystem": true, - "name": "AccessionStringFormatter", "table": "[table Accession]", "title": "AccessionStringFormatter", }, "CatalogNumber": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ - YearField { + "isSystem": false, + "name": "CatalogNumber", + "originalIndex": 3, + "parts": [ + YearPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "YEAR", + "regexPlaceholder": undefined, "size": 4, "type": "year", - "value": "YEAR", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - NumericField { + NumericPart { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "######", + "regexPlaceholder": undefined, "size": 6, "type": "numeric", - "value": "######", }, ], - "isSystem": false, - "name": "CatalogNumber", "table": "[table CollectionObject]", "title": "CatalogNumber", }, "CatalogNumberAlphaNumByYear": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ - YearField { + "isSystem": false, + "name": "CatalogNumberAlphaNumByYear", + "originalIndex": 5, + "parts": [ + YearPart { "autoIncrement": false, "byYear": true, - "pattern": undefined, + "placeholder": "YEAR", + "regexPlaceholder": undefined, "size": 4, "type": "year", - "value": "YEAR", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - NumericField { + NumericPart { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "######", + "regexPlaceholder": undefined, "size": 6, "type": "numeric", - "value": "######", }, ], - "isSystem": false, - "name": "CatalogNumberAlphaNumByYear", "table": "[table CollectionObject]", "title": "CatalogNumberAlphaNumByYear", }, "CatalogNumberNumeric": CatalogNumberNumeric { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ + "isSystem": true, + "name": "CatalogNumberNumeric", + "originalIndex": 0, + "parts": [ CatalogNumberNumericField { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "#########", + "regexPlaceholder": undefined, "size": 9, "type": "numeric", - "value": "#########", }, ], - "isSystem": true, - "name": "CatalogNumberNumeric", "table": "[table CollectionObject]", "title": "Catalog Number Numeric", }, "CatalogNumberNumericRegex": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ - RegexField { + "isSystem": false, + "name": "CatalogNumberNumericRegex", + "originalIndex": 4, + "parts": [ + RegexPart { "autoIncrement": false, "byYear": false, - "pattern": "####[-A]", + "placeholder": "[0-9]{4}(-[A-Z])?", + "regexPlaceholder": "####[-A]", "size": 1, "type": "regex", - "value": "[0-9]{4}(-[A-Z])?", }, ], - "isSystem": false, - "name": "CatalogNumberNumericRegex", "table": "[table CollectionObject]", "title": "CatalogNumberNumericRegex", }, "Date": UiFormatter { "field": undefined, - "fields": [], "isSystem": true, "name": "Date", + "originalIndex": 8, + "parts": [], "table": undefined, "title": "Date", }, "DeaccessionNumber": UiFormatter { "field": "[literalField Deaccession.deaccessionNumber]", - "fields": [ - YearField { + "isSystem": true, + "name": "DeaccessionNumber", + "originalIndex": 9, + "parts": [ + YearPart { "autoIncrement": false, "byYear": true, - "pattern": undefined, + "placeholder": "YEAR", + "regexPlaceholder": undefined, "size": 4, "type": "year", - "value": "YEAR", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - AlphaNumberField { + AlphaNumberPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "AA", + "regexPlaceholder": undefined, "size": 2, - "type": "alphanumeric", - "value": "AA", + "type": "alpha", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - NumericField { + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "###", + "regexPlaceholder": undefined, "size": 3, "type": "numeric", - "value": "###", }, ], - "isSystem": true, - "name": "DeaccessionNumber", "table": "[table Deaccession]", "title": "DeaccessionNumber", }, "GiftNumber": UiFormatter { "field": "[literalField Gift.giftNumber]", - "fields": [ - YearField { + "isSystem": true, + "name": "GiftNumber", + "originalIndex": 10, + "parts": [ + YearPart { "autoIncrement": false, "byYear": true, - "pattern": undefined, + "placeholder": "YEAR", + "regexPlaceholder": undefined, "size": 4, "type": "year", - "value": "YEAR", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - NumericField { + NumericPart { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "###", + "regexPlaceholder": undefined, "size": 3, "type": "numeric", - "value": "###", }, ], - "isSystem": true, - "name": "GiftNumber", "table": "[table Gift]", "title": "GiftNumber", }, "InfoRequestNumber": UiFormatter { "field": "[literalField InfoRequest.infoReqNumber]", - "fields": [ - YearField { + "isSystem": true, + "name": "InfoRequestNumber", + "originalIndex": 11, + "parts": [ + YearPart { "autoIncrement": false, "byYear": true, - "pattern": undefined, + "placeholder": "YEAR", + "regexPlaceholder": undefined, "size": 4, "type": "year", - "value": "YEAR", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - NumericField { + NumericPart { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "###", + "regexPlaceholder": undefined, "size": 3, "type": "numeric", - "value": "###", }, ], - "isSystem": true, - "name": "InfoRequestNumber", "table": "[table InfoRequest]", "title": "InfoRequestNumber", }, "KUITeach": CatalogNumberNumeric { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ + "isSystem": true, + "name": "CatalogNumberNumeric", + "originalIndex": 0, + "parts": [ CatalogNumberNumericField { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "#########", + "regexPlaceholder": undefined, "size": 9, "type": "numeric", - "value": "#########", }, ], - "isSystem": true, - "name": "CatalogNumberNumeric", "table": "[table CollectionObject]", "title": "Catalog Number Numeric", }, "LoanNumber": UiFormatter { "field": "[literalField Loan.loanNumber]", - "fields": [ - YearField { + "isSystem": true, + "name": "LoanNumber", + "originalIndex": 13, + "parts": [ + YearPart { "autoIncrement": false, "byYear": true, - "pattern": undefined, + "placeholder": "YEAR", + "regexPlaceholder": undefined, "size": 4, "type": "year", - "value": "YEAR", }, - SeparatorField { + SeparatorPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "-", + "regexPlaceholder": undefined, "size": 1, - "type": "separator", - "value": "-", + "type": "constant", }, - NumericField { + NumericPart { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "###", + "regexPlaceholder": undefined, "size": 3, "type": "numeric", - "value": "###", }, ], - "isSystem": true, - "name": "LoanNumber", "table": "[table Loan]", "title": "LoanNumber", }, "NumericBigDecimal": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "name": "NumericBigDecimal", + "originalIndex": 14, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "###############", + "regexPlaceholder": undefined, "size": 15, "type": "numeric", - "value": "###############", }, ], - "isSystem": true, - "name": "NumericBigDecimal", "table": undefined, "title": "NumericBigDecimal", }, "NumericByte": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "name": "NumericByte", + "originalIndex": 15, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "###", + "regexPlaceholder": undefined, "size": 3, "type": "numeric", - "value": "###", }, ], - "isSystem": true, - "name": "NumericByte", "table": undefined, "title": "NumericByte", }, "NumericDouble": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "name": "NumericDouble", + "originalIndex": 16, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "##########", + "regexPlaceholder": undefined, "size": 10, "type": "numeric", - "value": "##########", }, ], - "isSystem": true, - "name": "NumericDouble", "table": undefined, "title": "NumericDouble", }, "NumericFloat": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "name": "NumericFloat", + "originalIndex": 17, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "##########", + "regexPlaceholder": undefined, "size": 10, "type": "numeric", - "value": "##########", }, ], - "isSystem": true, - "name": "NumericFloat", "table": undefined, "title": "NumericFloat", }, "NumericInteger": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "name": "NumericInteger", + "originalIndex": 18, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "##########", + "regexPlaceholder": undefined, "size": 10, "type": "numeric", - "value": "##########", }, ], - "isSystem": true, - "name": "NumericInteger", "table": undefined, "title": "NumericInteger", }, "NumericLong": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "name": "NumericLong", + "originalIndex": 19, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "##########", + "regexPlaceholder": undefined, "size": 10, "type": "numeric", - "value": "##########", }, ], - "isSystem": true, - "name": "NumericLong", "table": undefined, "title": "NumericLong", }, "NumericShort": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "name": "NumericShort", + "originalIndex": 20, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "#####", + "regexPlaceholder": undefined, "size": 5, "type": "numeric", - "value": "#####", }, ], - "isSystem": true, - "name": "NumericShort", "table": undefined, "title": "NumericShort", }, "PartialDate": UiFormatter { "field": undefined, - "fields": [], "isSystem": false, "name": "PartialDate", + "originalIndex": 21, + "parts": [], "table": undefined, "title": "PartialDate", }, "PartialDateMonth": UiFormatter { "field": undefined, - "fields": [], "isSystem": false, "name": "PartialDateMonth", + "originalIndex": 22, + "parts": [], "table": undefined, "title": "PartialDateMonth", }, "PartialDateYear": UiFormatter { "field": undefined, - "fields": [], "isSystem": false, "name": "PartialDateYear", + "originalIndex": 23, + "parts": [], "table": undefined, "title": "PartialDateYear", }, "SearchDate": UiFormatter { "field": undefined, - "fields": [], "isSystem": true, "name": "SearchDate", + "originalIndex": 24, + "parts": [], "table": undefined, "title": "SearchDate", }, diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts index 16d9d1353f3..f14a5d9fcab 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts @@ -1,8 +1,8 @@ import { mockTime, requireContext } from '../../../tests/helpers'; import { getField } from '../../DataModel/helpers'; import { tables } from '../../DataModel/tables'; -import type { UiFormatter } from '../index'; -import { fetchContext, getUiFormatters } from '../index'; +import type { UiFormatter } from '..'; +import { fetchContext, getUiFormatters } from '..'; mockTime(); requireContext(); @@ -16,27 +16,29 @@ const getFormatter = (): UiFormatter | undefined => const getSecondFormatter = (): UiFormatter | undefined => getUiFormatters().AccessionNumber; -describe('valueOrWild', () => { +describe('defaultValue', () => { test('catalog number', () => - expect(getFormatter()?.valueOrWild()).toBe('#########')); + expect(getFormatter()?.defaultValue).toBe('#########')); test('accession number', () => - expect(getSecondFormatter()?.valueOrWild()).toBe('2022-AA-###')); + expect(getSecondFormatter()?.defaultValue).toBe('2022-AA-###')); }); -describe('parseRegExp', () => { +describe('placeholder', () => { test('catalog number', () => - expect(getFormatter()?.parseRegExp()).toBe('^(#########|\\d{0,9})$')); - test('accession number', () => - expect(getSecondFormatter()?.parseRegExp()).toBe( - '^(YEAR|\\d{4})(-)([a-zA-Z0-9]{2})(-)(###|\\d{3})$' + expect(getUiFormatters().CatalogNumberNumericRegex?.placeholder).toBe( + '####[-A]' )); + test('accession number', () => + expect(getSecondFormatter()?.placeholder).toBe('2022-AA-###')); }); -describe('pattern', () => { +describe('regex', () => { test('catalog number', () => - expect(getUiFormatters().CatalogNumberNumericRegex?.pattern()).toBe( - '####[-A]' + expect(getFormatter()?.regex.source).toBe('^(#########|\\d{0,9})$')); + test('accession number', () => + expect(getSecondFormatter()?.regex.source).toBe( + '^(YEAR|\\d{4})(-)([a-zA-Z0-9]{2})(-)(###|\\d{3})$' )); }); diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/spec.test.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/spec.test.ts new file mode 100644 index 00000000000..0157472a33d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/spec.test.ts @@ -0,0 +1,23 @@ +import { theories } from '../../../tests/utils'; +import { exportsForTests } from '../spec'; + +const { trimRegexString, normalizeRegexString } = exportsForTests; + +theories(trimRegexString, [ + [[''], ''], + [['[a-z]{3,}.*'], '[a-z]{3,}.*'], + [['/^[a-z]{3,}.*$/'], '[a-z]{3,}.*'], + [['^\\d{3}$'], '\\d{3}'], + [['/^(KUI|KUBI|NHM)$/'], 'KUI|KUBI|NHM'], +]); + +theories(normalizeRegexString, [ + [[''], '/^$/'], + [['[a-z]{3,}.*'], '/^[a-z]{3,}.*$/'], + [['/^[a-z]{3,}.*$/'], '/^[a-z]{3,}.*$/'], + [['^\\d{3}$'], '/^\\d{3}$/'], + [['\\d{3}'], '/^\\d{3}$/'], + [['/\\d{3}/'], '/^\\d{3}$/'], + [['KUI|KUBI|NHM'], '/^(KUI|KUBI|NHM)$/'], + [['(KUI|KUBI)|NHM'], '/^((KUI|KUBI)|NHM)$/'], +]); diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index cb222937acc..2726092a534 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -1,10 +1,12 @@ /** - * Parse and use Specify 6 UI Formatters + * Parse and use field formatters */ import type { LocalizedString } from 'typesafe-i18n'; import { formsText } from '../../localization/forms'; +import { queryText } from '../../localization/query'; +import { resourcesText } from '../../localization/resources'; import { getAppResourceUrl } from '../../utils/ajax/helpers'; import type { IR, RA } from '../../utils/types'; import { filterArray, localized } from '../../utils/types'; @@ -16,7 +18,8 @@ import { tables } from '../DataModel/tables'; import { error } from '../Errors/assert'; import { load } from '../InitialContext'; import { xmlToSpec } from '../Syncer/xmlUtils'; -import { fieldFormattersSpec } from './spec'; +import type { FieldFormatter, FieldFormatterPart } from './spec'; +import { fieldFormattersSpec, trimRegexString } from './spec'; let uiFormatters: IR; export const fetchContext = Promise.all([ @@ -25,35 +28,12 @@ export const fetchContext = Promise.all([ ]).then(([formatters]) => { uiFormatters = Object.fromEntries( filterArray( - xmlToSpec(formatters, fieldFormattersSpec()).formatters.map( - (formatter) => { - let resolvedFormatter; - if (typeof formatter.external === 'string') { - if ( - parseJavaClassName(formatter.external) === - 'CatalogNumberUIFieldFormatter' - ) - resolvedFormatter = new CatalogNumberNumeric(); - else return undefined; - } else { - const fields = filterArray( - formatter.fields.map((field) => - typeof field.type === 'string' - ? new formatterTypeMapper[field.type](field) - : undefined - ) - ); - resolvedFormatter = new UiFormatter( - formatter.isSystem, - formatter.title ?? formatter.name, - fields, - formatter.table, - formatter.field, - formatter.name - ); - } - - return [formatter.name, resolvedFormatter]; + xmlToSpec(formatters, fieldFormattersSpec()).fieldFormatters.map( + (formatter, index) => { + const resolvedFormatter = resolveFieldFormatter(formatter, index); + return resolvedFormatter === undefined + ? undefined + : [formatter.name, resolvedFormatter]; } ) ) @@ -63,44 +43,91 @@ export const fetchContext = Promise.all([ export const getUiFormatters = (): typeof uiFormatters => uiFormatters ?? error('Tried to access UI formatters before fetching them'); +export function resolveFieldFormatter( + formatter: FieldFormatter, + index: number +): UiFormatter | undefined { + if (typeof formatter.external === 'string') { + return parseJavaClassName(formatter.external) === + 'CatalogNumberUIFieldFormatter' + ? new CatalogNumberNumeric() + : undefined; + } else { + const parts = filterArray( + formatter.parts.map((part) => + typeof part.type === 'string' + ? new fieldFormatterTypeMapper[part.type](part) + : undefined + ) + ); + return new UiFormatter( + formatter.isSystem, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + formatter.title || formatter.name, + parts, + formatter.table, + formatter.field, + formatter.name, + index + ); + } +} + /* eslint-disable functional/no-class */ export class UiFormatter { public constructor( public readonly isSystem: boolean, public readonly title: LocalizedString, - public readonly fields: RA, + public readonly parts: RA, public readonly table: SpecifyTable | undefined, // The field which this formatter is formatting public readonly field: LiteralField | undefined, - public readonly name: string + public readonly name: string, + public readonly originalIndex = 0 ) {} - /** - * Value or wildcard (placeholders) - */ - public valueOrWild(): string { - return this.fields.map((field) => field.getDefaultValue()).join(''); + public get defaultValue(): string { + return this.parts.map((part) => part.defaultValue).join(''); } - public parseRegExp(): string { - return `^${this.fields - .map((field) => `(${field.wildOrValueRegexp()})`) - .join('')}$`; + public get placeholder(): string { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return this.regexPlaceholder || this.defaultValue; } - public parse(value: string): RA | undefined { + public get regex(): RegExp { // Regex may be coming from the user, thus disable strict mode // eslint-disable-next-line require-unicode-regexp - const match = new RegExp(this.parseRegExp()).exec(value); + return new RegExp( + `^${this.parts + .map((part) => `(${part.placeholderOrValueAsRegex})`) + .join('')}$` + ); + } + + public get size(): number { + return this.parts.reduce((size, field) => size + field.size, 0); + } + + private get regexPlaceholder(): LocalizedString | undefined { + const placeholders = this.parts + .map((part) => part.regexPlaceholder) + .filter(Boolean) + .join('\n'); + return placeholders.length > 0 ? localized(placeholders) : undefined; + } + + public parse(value: string): RA | undefined { + const match = this.regex.exec(value); return match?.slice(1); } public canAutonumber(): boolean { - return this.fields.some((field) => field.canAutonumber()); + return this.parts.some((part) => part.canAutonumber()); } public canAutoIncrement(): boolean { - return this.fields.some((field) => field.autoIncrement); + return this.parts.some((part) => part.autoIncrement); } public format(value: string): LocalizedString | undefined { @@ -110,173 +137,149 @@ export class UiFormatter { public canonicalize(values: RA): LocalizedString { return localized( - this.fields - .map((field, index) => field.canonicalize(values[index])) - .join('') + this.parts.map((part, index) => part.canonicalize(values[index])).join('') ); } - - public pattern(): LocalizedString | undefined { - return this.fields.some((field) => field.pattern) - ? localized(this.fields.map((field) => field.pattern ?? '').join('')) - : undefined; - } } -abstract class Field { +type PartOptions = Omit & + Partial>; + +abstract class Part { public readonly size: number; - public readonly value: LocalizedString; + public readonly placeholder: LocalizedString; + + public readonly regexPlaceholder: LocalizedString | undefined; public readonly autoIncrement: boolean; private readonly byYear: boolean; - public readonly pattern: LocalizedString | undefined; - - // eslint-disable-next-line functional/prefer-readonly-type - public type: keyof typeof formatterTypeMapper = undefined!; + public abstract readonly type: FieldFormatterPart['type']; public constructor({ - size, - value, + size = 1, + placeholder, autoIncrement, byYear, - pattern, - }: { - readonly size: number; - readonly value: LocalizedString; - readonly autoIncrement: boolean; - readonly byYear: boolean; - readonly pattern?: LocalizedString; - }) { + regexPlaceholder, + }: PartOptions) { this.size = size; - this.value = value; + this.placeholder = placeholder; this.autoIncrement = autoIncrement; this.byYear = byYear; - this.pattern = pattern; + this.regexPlaceholder = regexPlaceholder; } - public canAutonumber(): boolean { - return this.autoIncrement || this.byYear; + public get placeholderAsRegex(): LocalizedString { + return localized(escapeRegExp(this.placeholder)); } - public wildRegexp(): LocalizedString { - return localized(escapeRegExp(this.value)); - } + public get placeholderOrValueAsRegex(): LocalizedString { + const regex = this.regex; + if (!this.canAutonumber()) return this.regex; - public wildOrValueRegexp(): LocalizedString { - return this.canAutonumber() - ? localized(`${this.wildRegexp()}|${this.valueRegexp()}`) - : this.valueRegexp(); + const placeholderAsRegex = this.placeholderAsRegex; + return placeholderAsRegex === regex + ? regex + : localized(`${placeholderAsRegex}|${regex}`); } - public getDefaultValue(): LocalizedString { - return this.value === 'YEAR' + public get defaultValue(): LocalizedString { + return this.placeholder === YearPart.placeholder ? localized(new Date().getFullYear().toString()) - : this.value; + : this.placeholder; } - public canonicalize(value: string): LocalizedString { - return localized(value); + public abstract get regex(): LocalizedString; + + public canAutonumber(): boolean { + return this.autoIncrement || this.byYear; } - public valueRegexp(): LocalizedString { - throw new Error('not implemented'); + public canonicalize(value: string): LocalizedString { + return localized(value); } } -class ConstantField extends Field { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'constant'; - } +class ConstantPart extends Part { + public readonly type = 'constant'; - public valueRegexp(): LocalizedString { - return this.wildRegexp(); + public get regex(): LocalizedString { + return this.placeholderAsRegex; } } -class AlphaField extends Field { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'alpha'; - } +class AlphaPart extends Part { + public readonly type = 'alpha'; - public valueRegexp(): LocalizedString { + public get regex(): LocalizedString { return localized(`[a-zA-Z]{${this.size}}`); } } -class NumericField extends Field { - public constructor( - options: Omit[0], 'value'> - ) { +class NumericPart extends Part { + public readonly type = 'numeric'; + + public constructor(options: Omit) { super({ ...options, - value: localized(''.padStart(options.size, '#')), + placeholder: NumericPart.buildPlaceholder(options.size ?? 1), }); - this.type = 'numeric'; } - public valueRegexp(): LocalizedString { + public get regex(): LocalizedString { return localized(`\\d{${this.size}}`); } -} -class YearField extends Field { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'year'; + public static buildPlaceholder(size = 1): LocalizedString { + return localized(''.padStart(size, '#')); } +} - public valueRegexp(): LocalizedString { +class YearPart extends Part { + public static readonly placeholder = localized('YEAR'); + + public readonly type = 'year'; + + public get regex(): LocalizedString { return localized(`\\d{${this.size}}`); } } -class AlphaNumberField extends Field { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'alphanumeric'; - } +class AlphaNumberPart extends Part { + public readonly type = 'alpha'; - public valueRegexp(): LocalizedString { + public get regex(): LocalizedString { return localized(`[a-zA-Z0-9]{${this.size}}`); } } -class AnyCharField extends Field { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'anychar'; - } +class AnyCharPart extends Part { + public readonly type = 'anychar'; - public valueRegexp(): LocalizedString { + public get regex(): LocalizedString { return localized(`.{${this.size}}`); } } -class RegexField extends Field { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'regex'; - } +class RegexPart extends Part { + public readonly type = 'regex'; - public valueRegexp(): LocalizedString { - return this.value; + public get regex(): LocalizedString { + /* + * In UiFormatter.getRegex() we are adding ^ and $ as necessary, so trim + * them if they were present here + */ + return trimRegexString(this.placeholder) as LocalizedString; } } -class SeparatorField extends ConstantField { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'separator'; - } -} +class SeparatorPart extends ConstantPart {} -class CatalogNumberNumericField extends NumericField { - public valueRegexp(): LocalizedString { +class CatalogNumberNumericField extends NumericPart { + public get regex(): LocalizedString { return localized(`\\d{0,${this.size}}`); } @@ -296,6 +299,7 @@ export class CatalogNumberNumeric extends UiFormatter { size: 9, autoIncrement: true, byYear: false, + regexPlaceholder: undefined, }), ], tables.CollectionObject, @@ -306,13 +310,24 @@ export class CatalogNumberNumeric extends UiFormatter { } /* eslint-enable functional/no-class */ -export const formatterTypeMapper = { - constant: ConstantField, - year: YearField, - alpha: AlphaField, - numeric: NumericField, - alphanumeric: AlphaNumberField, - anychar: AnyCharField, - regex: RegexField, - separator: SeparatorField, +export const fieldFormatterTypeMapper = { + constant: ConstantPart, + year: YearPart, + alpha: AlphaPart, + numeric: NumericPart, + alphanumeric: AlphaNumberPart, + anychar: AnyCharPart, + regex: RegexPart, + separator: SeparatorPart, } as const; + +export const fieldFormatterLocalization = { + constant: resourcesText.constant(), + year: queryText.year(), + alpha: resourcesText.alpha(), + numeric: resourcesText.numeric(), + alphanumeric: resourcesText.alphanumeric(), + anychar: resourcesText.anychar(), + regex: resourcesText.regex(), + separator: resourcesText.separator(), +}; diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index 351a6335a0d..028455f853a 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -1,4 +1,5 @@ import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; import { localized } from '../../utils/types'; import type { LiteralField } from '../DataModel/specifyField'; import type { SpecifyTable } from '../DataModel/specifyTable'; @@ -7,24 +8,29 @@ import type { SpecToJson } from '../Syncer'; import { pipe, syncer } from '../Syncer'; import { syncers } from '../Syncer/syncers'; import { createXmlSpec } from '../Syncer/xmlUtils'; -import { formatterTypeMapper } from './index'; +import { fieldFormatterTypeMapper } from '.'; export const fieldFormattersSpec = f.store(() => createXmlSpec({ - formatters: pipe( + fieldFormatters: pipe( syncers.xmlChildren('format'), syncers.map( pipe( syncers.object(formatterSpec()), syncer( - ({ javaClass, ...formatter }) => ({ + ({ javaClass, rawAutoNumber, ...formatter }) => ({ ...formatter, table: getTable(javaClass ?? ''), raw: { javaClass, + legacyAutoNumber: rawAutoNumber, }, }), - ({ table, raw: { javaClass }, ...formatter }) => ({ + ({ + table, + raw: { javaClass, legacyAutoNumber }, + ...formatter + }) => ({ ...formatter, // "javaClass" is not always a database table javaClass: @@ -32,6 +38,10 @@ export const fieldFormattersSpec = f.store(() => (getTable(javaClass ?? '') === undefined ? javaClass : undefined), + rawAutoNumber: formatter.parts.some(isAutoNumbering) + ? legacyAutoNumber ?? + inferLegacyAutoNumber(table, formatter.parts) + : undefined, }) ), syncer( @@ -50,9 +60,38 @@ export const fieldFormattersSpec = f.store(() => }) ); +/** + * Specify 6 hardcoded special autonumbering behavior for a few tables. + * Accession table has special auto numbering, and collection object has + * two. Doing a best effort match of intended semantics for backwards + * compatibility. + */ +function inferLegacyAutoNumber( + table: SpecifyTable | undefined, + fields: RA<{ + readonly type: keyof typeof fieldFormatterTypeMapper | undefined; + }> +): string { + if (table?.name === 'Accession') + return 'edu.ku.brc.specify.dbsupport.AccessionAutoNumberAlphaNum'; + else if (table?.name === 'CollectionObject') { + const isNumericOnly = fields.every((field) => field.type === 'numeric'); + return isNumericOnly + ? 'edu.ku.brc.specify.dbsupport.CollectionAutoNumber' + : 'edu.ku.brc.specify.dbsupport.CollectionAutoNumberAlphaNum'; + } else return 'edu.ku.brc.af.core.db.AutoNumberGeneric'; +} + +const isAutoNumbering = (part: { + readonly autoIncrement: boolean; + readonly byYear: boolean; +}): boolean => part.autoIncrement || part.byYear; + export type FieldFormatter = SpecToJson< ReturnType ->['formatters'][number]; +>['fieldFormatters'][number]; + +export type FieldFormatterPart = FieldFormatter['parts'][number]; const formatterSpec = f.store(() => createXmlSpec({ @@ -68,14 +107,16 @@ const formatterSpec = f.store(() => title: syncers.xmlAttribute('title', 'empty'), // Some special formatters don't have a class name javaClass: syncers.xmlAttribute('class', 'skip'), - // BUG: enforce no relationship fields rawField: syncers.xmlAttribute('fieldName', 'skip'), isDefault: pipe( syncers.xmlAttribute('default', 'skip'), syncers.maybe(syncers.toBoolean), syncers.default(false) ), - autoNumber: pipe( + // Used only in special meta-formatters - we don't display these in the UI + legacyType: syncers.xmlAttribute('type', 'skip'), + legacyPartialDate: syncers.xmlAttribute('partialDate', 'skip'), + rawAutoNumber: pipe( syncers.xmlChild('autonumber', 'optional'), syncers.maybe(syncers.xmlContent) ), @@ -83,30 +124,96 @@ const formatterSpec = f.store(() => syncers.xmlChild('external', 'optional'), syncers.maybe(syncers.xmlContent) ), - fields: pipe( + parts: pipe( syncers.xmlChildren('field'), - syncers.map(syncers.object(fieldSpec())) + syncers.map( + pipe( + syncers.object(partSpec()), + syncer(normalizeFieldFormatterPart, (part) => ({ + ...part, + placeholder: + part.type === 'regex' + ? localized(normalizeRegexString(part.placeholder)) + : part.placeholder, + })) + ) + ) ), }) ); -const fieldSpec = f.store(() => +export function normalizeFieldFormatterPart( + part: SpecToJson> +): SpecToJson> { + const placeholder = + part.type === 'regex' + ? localized(trimRegexString(part.placeholder)) + : part.type === 'year' + ? fieldFormatterTypeMapper.year.placeholder + : part.type === 'numeric' + ? fieldFormatterTypeMapper.numeric.buildPlaceholder(part.size) + : part.placeholder; + const size = fieldFormatterTypesWithForcedSize.has(part.type as 'constant') + ? placeholder.length + : part.size; + return { ...part, placeholder, size }; +} + +export const fieldFormatterTypesWithForcedSize = new Set([ + 'constant', + 'separator', + 'year', +] as const); + +/** + * Specify 6 expects the regex pattern to start with "/^" and end with "$/" + * because it parses each field part individually. + * In Specify 7, we construct a combined regex that parts all field parts at + * once. + * Thus we do not want the "^" and "$" to be part of the pattern as far as + * Specify 7 front-end is concerned, but we want it to be part of the pattern + * in the .xml to work with Specify 6. + */ +export function trimRegexString(regexString: string): string { + let pattern = regexString; + if (pattern.startsWith('/')) pattern = pattern.slice(1); + if (pattern.startsWith('^')) pattern = pattern.slice(1); + if (pattern.endsWith('/')) pattern = pattern.slice(0, -1); + if (pattern.endsWith('$')) pattern = pattern.slice(0, -1); + if (pattern.startsWith('(') && pattern.endsWith(')')) + pattern = pattern.slice(1, -1); + return pattern; +} +function normalizeRegexString(regexString: string): string { + let pattern: string = trimRegexString(regexString); + if (pattern.includes('|')) pattern = `(${pattern})`; + return `/^${pattern}$/`; +} + +const partSpec = f.store(() => createXmlSpec({ type: pipe( syncers.xmlAttribute('type', 'required'), syncers.fallback(localized('alphanumeric')), - // TEST: check if sp6 defines any other types not present in this list - syncers.enum(Object.keys(formatterTypeMapper)) + syncers.enum(Object.keys(fieldFormatterTypeMapper)) ), size: pipe( syncers.xmlAttribute('size', 'skip'), - syncers.maybe(syncers.toDecimal), - syncers.default(1) + syncers.maybe(syncers.toDecimal) ), - value: pipe( + /* + * For most parts, this is a human-friendly placeholder like ### or ABC. + * For regex parts, this contains the actual regular expression + */ + placeholder: pipe( syncers.xmlAttribute('value', 'skip', false), - syncers.default(localized(' ')) + syncers.default(localized('')) ), + /* + * Since regular expressions are less readable, this part is specifically + * for providing human-readable description of a regular expression + */ + regexPlaceholder: syncers.xmlAttribute('pattern', 'skip', false), byYear: pipe( syncers.xmlAttribute('byYear', 'skip'), syncers.maybe(syncers.toBoolean), @@ -117,7 +224,6 @@ const fieldSpec = f.store(() => syncers.maybe(syncers.toBoolean), syncers.default(false) ), - pattern: syncers.xmlAttribute('pattern', 'skip', false), }) ); @@ -135,3 +241,5 @@ function parseField( return undefined; } else return field; } + +export const exportsForTests = { trimRegexString, normalizeRegexString }; diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx index c837284b37a..1d1f69191a2 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx @@ -119,12 +119,7 @@ function Field({ (field?.isReadOnly === true && !isInSearchDialog); const validationAttributes = getValidationAttributes(parser); - - const [rightAlignNumberFields] = userPreferences.use( - 'form', - 'ui', - 'rightAlignNumberFields' - ); + const rightAlignClassName = useRightAlignClassName(parser.type, isReadOnly); const isNew = resource?.isNew(); const isCO = resource?.specifyTable.name === 'CollectionObject'; @@ -225,17 +220,7 @@ function Field({ : undefined } {...validationAttributes} - className={ - /* - * Disable "text-align: right" in non webkit browsers - * as they don't support spinner's arrow customization - */ - parser.type === 'number' && - rightAlignNumberFields && - globalThis.navigator.userAgent.toLowerCase().includes('webkit') - ? `text-right ${isReadOnly ? '' : 'pr-6'}` - : '' - } + className={rightAlignClassName} id={id} isReadOnly={isReadOnly} required={'required' in validationAttributes && !isInSearchDialog} @@ -261,3 +246,24 @@ function Field({ /> ); } + +export function useRightAlignClassName( + type: Parser['type'], + isReadOnly: boolean +): string | undefined { + const [rightAlignNumberFields] = userPreferences.use( + 'form', + 'ui', + 'rightAlignNumberFields' + ); + + /* + * Disable "text-align: right" in non webkit browsers + * as they don't support spinner's arrow customization + */ + return type === 'number' && + rightAlignNumberFields && + globalThis.navigator.userAgent.toLowerCase().includes('webkit') + ? `text-right ${isReadOnly ? '' : 'pr-6'}` + : ''; +} diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/AutoNumbering.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/AutoNumbering.tsx index a905789f30c..b6763704469 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/AutoNumbering.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/AutoNumbering.tsx @@ -65,7 +65,7 @@ function AutoNumberingDialog({ if (stringValue.length === 0 && resource.isNew()) { const field = resource.specifyTable.strictGetLiteralField(fieldName); const formatter = field.getUiFormatter()!; - const wildCard = formatter.valueOrWild(); + const wildCard = formatter.defaultValue; resource.set(fieldName, wildCard as never); } handleChange([...config, fieldName]); @@ -74,7 +74,7 @@ function AutoNumberingDialog({ function handleDisableAutoNumbering(fieldName: string): void { const field = resource.specifyTable.strictGetLiteralField(fieldName); const formatter = field.getUiFormatter()!; - const wildCard = formatter.valueOrWild(); + const wildCard = formatter.defaultValue; if (resource.get(fieldName) === wildCard) resource.set(fieldName, null as never); handleChange(config.filter((name) => name !== fieldName)); diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx index a27d67afed9..8721484d316 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx @@ -259,11 +259,12 @@ export const tableValidForBulkClone = ( !( tables.CollectionObject.strictGetLiteralField('catalogNumber') .getUiFormatter(resource ?? undefined) - ?.fields.some( - (field) => - field.type === 'regex' || - field.type === 'alphanumeric' || - (field.type === 'numeric' && !field.canAutonumber()) + ?.parts.some( + (parts) => + parts.type === 'regex' || + parts.type === 'alphanumeric' || + parts.type === 'alpha' || + (parts.type === 'numeric' && !parts.canAutonumber()) ) ?? false ); diff --git a/specifyweb/frontend/js_src/lib/components/FormParse/index.ts b/specifyweb/frontend/js_src/lib/components/FormParse/index.ts index 424b9d2e131..2ae4158a1fc 100644 --- a/specifyweb/frontend/js_src/lib/components/FormParse/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FormParse/index.ts @@ -33,7 +33,7 @@ import { pushContext, setLogContext, } from '../Errors/logContext'; -import { cachableUrl } from '../InitialContext'; +import { cacheableUrl } from '../InitialContext'; import { getPref } from '../InitialContext/remotePrefs'; import { formatUrl } from '../Router/queryString'; import type { SimpleXmlNode } from '../Syncer/xmlToJson'; @@ -117,7 +117,7 @@ export const fetchView = async ( * NOTE: If getView hasn't yet been invoked, the view URL won't be * marked as cachable */ - cachableUrl(getViewSetApiUrl(name)), + cacheableUrl(getViewSetApiUrl(name)), { headers: { Accept: 'text/plain' }, expectedErrors: [Http.NOT_FOUND], diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx index 4bfb3654822..28103f98088 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx @@ -36,6 +36,7 @@ import { relationshipIsToMany, valueIsPartialField, } from '../WbPlanView/mappingHelpers'; +import type { MappingLineData } from '../WbPlanView/navigator'; import { getMappingLineData } from '../WbPlanView/navigator'; import { navigatorSpecs } from '../WbPlanView/navigatorSpecs'; import type { Aggregator, Formatter } from './spec'; @@ -148,11 +149,13 @@ export function ResourceMapping({ mapping: [mapping, setMapping], openIndex: [openIndex, setOpenIndex], isRequired = false, + fieldFilter, }: { readonly table: SpecifyTable; readonly mapping: GetSet | undefined>; readonly openIndex: GetSet; readonly isRequired?: boolean; + readonly fieldFilter?: (mappingData: MappingLineData) => MappingLineData; }): JSX.Element { const sourcePath = React.useMemo(() => { const rawPath = @@ -195,23 +198,26 @@ export function ResourceMapping({ }, [mappingPath, sourcePath]); const isReadOnly = React.useContext(ReadOnlyContext); + const lineData = React.useMemo(() => { + let data = getMappingLineData({ + baseTableName: table.name, + mappingPath, + showHiddenFields: true, + generateFieldData: 'all', + spec: navigatorSpecs.formatterEditor, + }).map((line) => ({ + ...line, + fieldsData: Object.fromEntries( + Object.entries(line.fieldsData).filter(([key]) => key !== 'age') + ), + })); - const lineData = React.useMemo( - () => - getMappingLineData({ - baseTableName: table.name, - mappingPath, - showHiddenFields: true, - generateFieldData: 'all', - spec: navigatorSpecs.formatterEditor, - }).map((line) => ({ - ...line, - fieldsData: Object.fromEntries( - Object.entries(line.fieldsData).filter(([key]) => key !== 'age') - ), - })), - [table.name, mappingPath] - ); + if (typeof fieldFilter === 'function') { + data = data.map(fieldFilter); + } + + return data; + }, [table.name, mappingPath, fieldFilter]); const validation = React.useMemo( () => diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx index b0e1ba77c64..ca931387c9d 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx @@ -186,7 +186,7 @@ function ConditionalFormatter({ )} - {expandedNoCondition || isReadOnly ? null : fields.length === 0 ? ( + {expandedNoCondition || isReadOnly ? undefined : fields.length === 0 ? (
>; - readonly item: GetSet; - }) => JSX.Element; + readonly children: (props: XmlEditorShellSlotProps) => JSX.Element; }): JSX.Element { const { index: rawIndex } = useParams(); const { items: allItems } = useOutletContext(); @@ -73,7 +72,7 @@ export function XmlEditorShell< - {isReadOnly ? null : ( + {isReadOnly ? undefined : ( { setItems(removeItem(items, index)); @@ -118,6 +117,11 @@ export function XmlEditorShell< ); } +type XmlEditorShellSlotProps = { + readonly items: GetSet>; + readonly item: GetSet; +}; + export function FormatterWrapper(): JSX.Element { const { type, index } = useParams(); const isReadOnly = React.useContext(ReadOnlyContext); @@ -129,44 +133,66 @@ export function FormatterWrapper(): JSX.Element { : resourcesText.aggregator() } > - {({ item: getSet, items: [items, setItems] }): JSX.Element => ( - <> - - {resourcesText.title()} - - getSet[1]({ ...getSet[0], title }) - } - /> - - - - setItems( - // Ensure there is only one default - items.map((otherItem, itemIndex) => - otherItem.table === getSet[0].table - ? itemIndex.toString() === index - ? { ...getSet[0], isDefault: !getSet[0].isDefault } - : { ...otherItem, isDefault: false } - : otherItem - ) - ) - } - /> - {resourcesText.default()} - - {type === 'formatter' ? ( + {makeXmlEditorShellSlot( + (getSet) => + type === 'formatter' ? ( } /> ) : ( } /> - )} - + ), + index, + isReadOnly )} ); } + +export const makeXmlEditorShellSlot = < + ITEM extends { + readonly name: string; + readonly title: string | undefined; + readonly isDefault: boolean; + readonly table: SpecifyTable | undefined; + } +>( + children: (getSet: GetSet) => JSX.Element, + index: string | undefined, + isReadOnly: boolean +) => + function XmlEditorShellSlot({ + item: getSet, + items: [items, setItems], + }: XmlEditorShellSlotProps): JSX.Element { + return ( + <> + + {resourcesText.title()} + getSet[1]({ ...getSet[0], title })} + /> + + + + setItems( + // Ensure there is only one default + items.map((otherItem, itemIndex) => + otherItem.table === getSet[0].table + ? itemIndex.toString() === index + ? { ...getSet[0], isDefault: !getSet[0].isDefault } + : { ...otherItem, isDefault: false } + : otherItem + ) + ) + } + /> + {resourcesText.default()} + + {children(getSet)} + + ); + }; diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx index c9979f1e4cd..acb6b41f016 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx @@ -19,7 +19,7 @@ import type { CustomSelectElementPropsOpen, } from '../WbPlanView/CustomSelectElement'; import { CustomSelectElement } from '../WbPlanView/CustomSelectElement'; -import type { HtmlGeneratorFieldData } from '../WbPlanView/LineComponents'; +import type { MapperComponentData } from '../WbPlanView/LineComponents'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; import { FormattersPickList, @@ -53,7 +53,7 @@ export function Fields({ return ( <> - {fields.length === 0 ? null : ( + {fields.length === 0 ? undefined : (
)} - {isReadOnly ? null : ( + {isReadOnly ? undefined : (
@@ -209,7 +209,7 @@ function Field({ )} - {isReadOnly ? null : ( + {isReadOnly ? undefined : ( <> void; -}): IR { +}): IR { return { trimZeros: { optionLabel: ( diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/List.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/List.tsx index 5c4423aff0f..ea6b6b3f489 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/List.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/List.tsx @@ -9,6 +9,7 @@ import { ensure, localized } from '../../utils/types'; import { getUniqueName } from '../../utils/uniquifyName'; import { ErrorMessage, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; import { Link } from '../Atoms/Link'; import { ReadOnlyContext } from '../Core/Contexts'; import type { SpecifyTable } from '../DataModel/specifyTable'; @@ -22,6 +23,7 @@ import type { FormatterTypesOutlet } from './Types'; export function FormatterList(): JSX.Element { const { type, tableName } = useParams(); const { items } = useOutletContext(); + const navigate = useNavigate(); return ( navigate('../')} /> ); } @@ -82,11 +85,13 @@ export function XmlEntryList< tableName, header, getNewItem, + onGoBack: handleGoBack, }: { readonly items: GetSet>; readonly tableName: string | undefined; readonly header: string; readonly getNewItem: (currentItems: RA, table: SpecifyTable) => ITEM; + readonly onGoBack?: () => void; }): JSX.Element { const isReadOnly = React.useContext(ReadOnlyContext); const navigate = useNavigate(); @@ -104,6 +109,12 @@ export function XmlEntryList< ); return (
+ {typeof handleGoBack === 'function' && ( + + {icons.chevronLeft} + {commonText.back()} + + )}

{table.label}

{commonText.colonHeader({ header })}
    diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx index b7b13640535..0c0484276be 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx @@ -9,6 +9,7 @@ import type { GetOrSet, RA } from '../../utils/types'; import { removeItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { Link } from '../Atoms/Link'; +import { LoadingContext } from '../Core/Contexts'; import { ReadOnlyContext } from '../Core/Contexts'; import { fetchCollection } from '../DataModel/collection'; import type { AnySchema } from '../DataModel/helperTypes'; @@ -40,7 +41,8 @@ export function useResourcePreview( table.name, { limit: defaultPreviewSize, - domainFilter: false, // REFACTOR: set to true after scoping reimplementation + // REFACTOR: set to true after scoping re-implementation + domainFilter: false, }, { orderBy: [ @@ -58,6 +60,7 @@ export function useResourcePreview( const [resources, setResources] = getSetResources; const [isOpen, handleOpen, handleClose] = useBooleanState(); + const loading = React.useContext(LoadingContext); return { resources: getSetResources, @@ -65,7 +68,7 @@ export function useResourcePreview(
    {resourcesText.preview()} @@ -112,7 +115,13 @@ export function useResourcePreview( onlyUseQueryBuilder table={table} onClose={handleClose} - onSelected={setResources} + onSelected={(selected): void => + void loading( + Promise.all( + selected.map(async (resource) => resource.fetch()) + ).then(setResources) + ) + } /> )}
    @@ -128,7 +137,7 @@ export function ResourcePreview({ readonly table: SpecifyTable; readonly doFormatting: ( resources: RA> - ) => Promise>; + ) => Promise> | RA; readonly isAggregator?: boolean; }): JSX.Element | null { const { diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts index 69e3f0bcc13..2b4c801c51b 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts @@ -23,7 +23,7 @@ import { } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; import { - cachableUrl, + cacheableUrl, contextUnlockedPromise, foreverFetch, } from '../InitialContext'; @@ -41,7 +41,7 @@ export const fetchFormatters: Promise<{ }> = contextUnlockedPromise.then(async (entrypoint) => entrypoint === 'main' ? Promise.all([ - ajax(cachableUrl(getAppResourceUrl('DataObjFormatters')), { + ajax(cacheableUrl(getAppResourceUrl('DataObjFormatters')), { headers: { Accept: 'text/xml' }, }).then(({ data }) => data), fetchSchema, diff --git a/specifyweb/frontend/js_src/lib/components/Forms/BulkCarryForward.tsx b/specifyweb/frontend/js_src/lib/components/Forms/BulkCarryForward.tsx index 43372eda02c..c9eb46590bb 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/BulkCarryForward.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/BulkCarryForward.tsx @@ -4,7 +4,7 @@ import { commonText } from '../../localization/common'; import { formsText } from '../../localization/forms'; import { ajax } from '../../utils/ajax'; import { - formatterToParser, + fieldFormatterToParser, getValidationAttributes, } from '../../utils/parser/definitions'; import type { RA } from '../../utils/types'; @@ -79,7 +79,9 @@ function useBulkCarryForwardRange( const formatter = field.getUiFormatter(resource); const canAutoNumberFormatter = formatter?.canAutoIncrement() ?? false; const parser = - formatter === undefined ? undefined : formatterToParser(field, formatter); + formatter === undefined + ? undefined + : fieldFormatterToParser(field, formatter); const [carryForwardRangeEnd, setCarryForwardRangeEnd] = React.useState(''); @@ -174,7 +176,7 @@ function useBulkCarryForwardRange( aria-label={formsText.bulkCarryForwardRangeStart()} className="!w-fit" isReadOnly - placeholder={formatter.valueOrWild()} + placeholder={formatter.defaultValue} value={resource.get('catalogNumber') ?? ''} width={field.datamodelDefinition.length} /> @@ -182,7 +184,7 @@ function useBulkCarryForwardRange( aria-label={formsText.bulkCarryForwardRangeEnd()} className="!w-fit" {...getValidationAttributes(parser)} - placeholder={formatter.valueOrWild()} + placeholder={formatter.defaultValue} value={carryForwardRangeEnd} onValueChange={(value): void => setCarryForwardRangeEnd(value)} /> @@ -226,7 +228,7 @@ function useBulkCarryForwardCount( const clones = await Promise.all( Array.from({ length: carryForwardAmount }, async () => { const clonedResource = await resource.clone(false, true); - clonedResource.set(field.name, formatter.valueOrWild() as never); + clonedResource.set(field.name, formatter.defaultValue as never); return clonedResource; }) ); diff --git a/specifyweb/frontend/js_src/lib/components/Forms/dataObjFormatters.ts b/specifyweb/frontend/js_src/lib/components/Forms/dataObjFormatters.ts index cce72be87a1..24672bcdc15 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/dataObjFormatters.ts +++ b/specifyweb/frontend/js_src/lib/components/Forms/dataObjFormatters.ts @@ -24,7 +24,7 @@ import type { Tables } from '../DataModel/types'; import { softFail } from '../Errors/Crash'; import { fieldFormat } from '../Formatters/fieldFormat'; import { - cachableUrl, + cacheableUrl, contextUnlockedPromise, foreverFetch, } from '../InitialContext'; @@ -73,7 +73,7 @@ export const fetchFormatters: Promise<{ }> = contextUnlockedPromise.then(async (entrypoint) => entrypoint === 'main' ? ajax( - cachableUrl( + cacheableUrl( formatUrl('/context/app.resource', { name: 'DataObjFormatters' }) ), { diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts index c4fb91e22d5..5b2f5a36efb 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts @@ -15,7 +15,7 @@ export const cachableUrls = new Set(); * Mark URL as cachable -> should have its cache cleared when cache buster is * invoked */ -export function cachableUrl(url: string): string { +export function cacheableUrl(url: string): string { cachableUrls.add(url); return url; } @@ -57,7 +57,7 @@ export const load = async (path: string, mimeType: MimeType): Promise => // Doing async import to avoid a circular dependency const { ajax } = await import('../../utils/ajax'); - const { data } = await ajax(cachableUrl(path), { + const { data } = await ajax(cacheableUrl(path), { errorMode: 'visible', headers: { Accept: mimeType }, }); diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts index 83bf2d6c614..2574f60f53d 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts @@ -11,7 +11,7 @@ import { parseValue } from '../../utils/parser/parse'; import type { IR, R, RA } from '../../utils/types'; import { defined } from '../../utils/types'; import type { JavaType } from '../DataModel/specifyField'; -import { cachableUrl, contextUnlockedPromise } from './index'; +import { cacheableUrl, contextUnlockedPromise } from './index'; const preferences: R = {}; @@ -22,7 +22,7 @@ const preferences: R = {}; */ export const fetchContext = contextUnlockedPromise.then(async (entrypoint) => entrypoint === 'main' - ? ajax(cachableUrl('/context/remoteprefs.properties'), { + ? ajax(cacheableUrl('/context/remoteprefs.properties'), { headers: { Accept: 'text/plain' }, }) .then(({ data: text }) => diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx index 61a4cd67432..51f9bbaadc4 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx @@ -482,8 +482,7 @@ function useParser(searchField: LiteralField): { const parser = pluralizeParser(resolveParser(searchField)); // Determine which delimiters are allowed const formatter = searchField.getUiFormatter(); - const formatted = - formatter?.fields.map((field) => field.value).join('') ?? ''; + const formatted = formatter?.defaultValue ?? ''; const formatterHasNewLine = formatted.includes('\n'); const formatterHasSpaces = formatted.includes(' '); const formatterHasCommas = formatted.includes(','); diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/fetch.ts b/specifyweb/frontend/js_src/lib/components/Interactions/fetch.ts new file mode 100644 index 00000000000..2d8d57dac39 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Interactions/fetch.ts @@ -0,0 +1,44 @@ +import { ajax } from '../../utils/ajax'; +import { getAppResourceUrl } from '../../utils/ajax/helpers'; +import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import { tables } from '../DataModel/tables'; +import type { Tables } from '../DataModel/types'; +import { cacheableUrl } from '../InitialContext'; +import { xmlToSpec } from '../Syncer/xmlUtils'; +import { interactionEntries } from './spec'; + +const url = cacheableUrl(getAppResourceUrl('InteractionsTaskInit')); +export const fetchLegacyInteractions = f.store(async () => + ajax(url, { + headers: { Accept: 'text/xml' }, + }).then(({ data }) => + filterArray( + xmlToSpec(data, interactionEntries()).entry.map( + ({ isFavorite, action, table }) => + isFavorite + ? action === 'RET_LOAN' + ? tables.LoanReturnPreparation + : table + : undefined + ) + ) + ) +); + +export const defaultInteractionTables: RA = [ + 'Accession', + 'Disposal', + 'Permit', + 'Loan', + 'LoanReturnPreparation', + 'Gift', + 'ExchangeIn', + 'ExchangeOut', + 'Borrow', + 'InfoRequest', + 'RepositoryAgreement', + 'Appraisal', +]; diff --git a/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts b/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts index a2ca8a730f8..7b6fe4c3f70 100644 --- a/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts +++ b/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts @@ -6,7 +6,7 @@ import { getAppResourceUrl } from '../../utils/ajax/helpers'; import type { IR, RA, RR } from '../../utils/types'; import { softFail } from '../Errors/Crash'; import { - cachableUrl, + cacheableUrl, contextUnlockedPromise, foreverFetch, } from '../InitialContext'; @@ -190,14 +190,14 @@ export const fetchLeafletLayers = async (): Promise> => const layersPromise: Promise> = contextUnlockedPromise.then(async (entrypoint) => entrypoint === 'main' - ? ajax(cachableUrl(getAppResourceUrl('leaflet-layers', 'quiet')), { + ? ajax(cacheableUrl(getAppResourceUrl('leaflet-layers', 'quiet')), { headers: { Accept: 'text/plain' }, errorMode: 'silent', }) .then(async ({ data, status }) => status === Http.NO_CONTENT ? ajax>( - cachableUrl(leafletLayersEndpoint), + cacheableUrl(leafletLayersEndpoint), { headers: { Accept: 'application/json' }, errorMode: 'silent', diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx index bc308aaabd7..f8621cfce84 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx @@ -13,7 +13,7 @@ import { keysToLowerCase, replaceKey } from '../../utils/utils'; import { SECOND } from '../Atoms/timeUnits'; import { softFail } from '../Errors/Crash'; import { - cachableUrl, + cacheableUrl, contextUnlockedPromise, foreverFetch, } from '../InitialContext'; @@ -429,7 +429,7 @@ export const fetchResourceId = async ( fetchUrl: string, resourceName: string ): Promise => - ajax>(cachableUrl(fetchUrl), { + ajax>(cacheableUrl(fetchUrl), { headers: { Accept: mimeType }, }).then( ({ data }) => @@ -445,7 +445,7 @@ const fetchResourceData = async ( fetchUrl: string, appResourceId: number ): Promise => - ajax(cachableUrl(`${fetchUrl}${appResourceId}/`), { + ajax(cacheableUrl(`${fetchUrl}${appResourceId}/`), { headers: { Accept: mimeType }, }).then(({ data }) => data); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx index 687676a880d..792da37d946 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx @@ -142,7 +142,7 @@ export function QueryLine({ required: false, }; // Remove autoNumbering wildCard from default values - if (dataModelField.getUiFormatter()?.valueOrWild() === parser.value) + if (dataModelField.getUiFormatter()?.defaultValue === parser.value) parser = { ...parser, value: undefined }; fieldType = diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineFilters.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineFilters.tsx index 1ac01cd3197..a5a3475ff24 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineFilters.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineFilters.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { commonText } from '../../localization/common'; -import { formatterToParser } from '../../utils/parser/definitions'; +import { fieldFormatterToParser } from '../../utils/parser/definitions'; import type { RA } from '../../utils/types'; import { Select } from '../Atoms/Form'; import { genericTables } from '../DataModel/tables'; @@ -145,7 +145,7 @@ function QueryLineFilterWrapper({ const parser = (terminatingField === undefined || fieldFormatter === undefined ? undefined - : formatterToParser(terminatingField, fieldFormatter)) ?? + : fieldFormatterToParser(terminatingField, fieldFormatter)) ?? fieldMeta.parser; return ( diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/RelativeDate.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/RelativeDate.tsx index 3c3a4873ced..addc0c00244 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/RelativeDate.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/RelativeDate.tsx @@ -166,10 +166,10 @@ function DateSplit({ handleChanging?.(); }} > - - - - + + + + ), direction: (direction) => ( diff --git a/specifyweb/frontend/js_src/lib/components/Reports/available.ts b/specifyweb/frontend/js_src/lib/components/Reports/available.ts index cc1b2b4b7ae..06170c40af6 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/available.ts +++ b/specifyweb/frontend/js_src/lib/components/Reports/available.ts @@ -1,11 +1,11 @@ import { ajax } from '../../utils/ajax'; -import { cachableUrl, contextUnlockedPromise } from '../InitialContext'; +import { cacheableUrl, contextUnlockedPromise } from '../InitialContext'; export const reportsAvailable = contextUnlockedPromise.then( async (entrypoint) => entrypoint === 'main' ? ajax<{ readonly available: boolean }>( - cachableUrl('/context/report_runner_status.json'), + cacheableUrl('/context/report_runner_status.json'), { headers: { Accept: 'application/json' }, } diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx index 71303bded2a..573bacf00cd 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx @@ -17,6 +17,7 @@ import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { getField } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { LiteralField, Relationship } from '../DataModel/specifyField'; +import type { SpecifyTable } from '../DataModel/specifyTable'; import { tables } from '../DataModel/tables'; import type { SpLocaleContainerItem } from '../DataModel/types'; import { ResourceLink } from '../Molecules/ResourceLink'; @@ -73,6 +74,13 @@ export function SchemaConfigFormat({ /> + } label={schemaText.formatted()} name="formatted" value={item.format} @@ -80,7 +88,7 @@ export function SchemaConfigFormat({ [`${ field === undefined ? '' - : schemaText.uiFormattersForField({ fieldLabel: field.label }) + : schemaText.fieldFormattersForField({ fieldLabel: field.label }) }`]: formattersForField .map( ({ name, isSystem, value }) => @@ -97,7 +105,7 @@ export function SchemaConfigFormat({ ] as const ) .sort(sortFunction((value) => value[1])), - [resourcesText.uiFormatters()]: otherFormatters + [resourcesText.fieldFormatters()]: otherFormatters .map( ({ name, isSystem, value }) => [ @@ -306,6 +314,9 @@ function PickListEditing({ ); } +const overlayPrefix = '/specify/overlay/resources/app-resource/'; +const fullScreenPrefix = '/specify/resources/app-resource/'; + function WebLinkEditing({ value, schemaData, @@ -316,34 +327,76 @@ function WebLinkEditing({ const index = schemaData.webLinks.find(({ name }) => name === value)?.index; const resourceId = appResourceIds.WebLinks; const navigate = useNavigate(); + return typeof resourceId === 'number' ? ( <> {typeof index === 'number' && ( { event.preventDefault(); - navigate( - `/specify/overlay/resources/app-resource/${resourceId}/web-link/${index}/` - ); + navigate(`${overlayPrefix}${resourceId}/web-link/${index}`); }} /> )} { event.preventDefault(); - navigate( - `/specify/overlay/resources/app-resource/${resourceId}/web-link/` - ); + navigate(`${overlayPrefix}${resourceId}/web-link`); }} /> ) : null; } + +function FieldFormatterEditing({ + table, + value, + schemaData, +}: { + readonly table: SpecifyTable; + readonly value: string | null; + readonly schemaData: SchemaData; +}): JSX.Element | null { + const index = schemaData.uiFormatters.find( + ({ name }) => name === value + )?.index; + const resourceId = appResourceIds.UIFormatters; + const navigate = useNavigate(); + if (resourceId === undefined) return null; + + const commonUrl = `${resourceId}/field-formatters/${table.name}`; + return ( + <> + {typeof index === 'number' && ( + { + event.preventDefault(); + navigate(`${overlayPrefix}${commonUrl}/${index}`); + }} + /> + )} + { + event.preventDefault(); + navigate(`${overlayPrefix}${commonUrl}`); + }} + /> + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleScope.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleScope.tsx index e37f7e78178..2eadb80dd17 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleScope.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/UniquenessRuleScope.tsx @@ -13,7 +13,7 @@ import type { SpecifyTable } from '../DataModel/specifyTable'; import { strictGetTable } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; import type { UniquenessRule } from '../DataModel/uniquenessRules'; -import type { HtmlGeneratorFieldData } from '../WbPlanView/LineComponents'; +import type { MapperComponentData } from '../WbPlanView/LineComponents'; import { getMappingLineProps } from '../WbPlanView/LineComponents'; import { MappingView } from '../WbPlanView/MapperComponents'; import type { MappingLineData } from '../WbPlanView/navigator'; @@ -35,7 +35,7 @@ export function UniquenessRuleScope({ : rule.scopes[0].split(djangoLookupSeparator) ); - const databaseScopeData: Readonly> = { + const databaseScopeData: Readonly> = { database: { isDefault: true, isEnabled: true, @@ -46,7 +46,7 @@ export function UniquenessRuleScope({ const getValidScopeRelationships = ( table: SpecifyTable - ): Readonly> => + ): Readonly> => Object.fromEntries( table.relationships .filter( diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts index 6b0d8b8ee0e..42120bd4698 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts @@ -46,6 +46,8 @@ type SimpleFieldFormatter = { readonly isSystem: boolean; readonly value: string; readonly field: LiteralField | undefined; + readonly tableName: keyof Tables | undefined; + readonly index: number; }; export const fetchSchemaData = async (): Promise => @@ -67,8 +69,10 @@ export const fetchSchemaData = async (): Promise => .map(([name, formatter]) => ({ name, isSystem: formatter.isSystem, - value: formatter.valueOrWild(), + value: formatter.defaultValue, field: formatter.field, + tableName: formatter.table?.name, + index: formatter.originalIndex, })) .filter(({ value }) => value) ), diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Language.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Language.tsx index cfde42616e2..710fe2c4636 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Language.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Language.tsx @@ -29,7 +29,7 @@ import { Select } from '../Atoms/Form'; import { Link } from '../Atoms/Link'; import { ReadOnlyContext } from '../Core/Contexts'; import { raise } from '../Errors/Crash'; -import { cachableUrl } from '../InitialContext'; +import { cacheableUrl } from '../InitialContext'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import type { PreferenceItem, @@ -170,7 +170,7 @@ export function LanguageSelection({ ); } -const url = cachableUrl( +const url = cacheableUrl( formatUrl('/context/language/', { languages: languages.join(','), }) diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/LineComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/LineComponents.tsx index 248a8f36e37..40075539a39 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/LineComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/LineComponents.tsx @@ -28,7 +28,7 @@ import { import type { AutoMapperSuggestion } from './Mapper'; import type { MappingLineData } from './navigator'; -export type HtmlGeneratorFieldData = { +export type MapperComponentData = { readonly optionLabel: JSX.Element | string; readonly title?: LocalizedString; readonly isEnabled?: boolean; @@ -49,7 +49,7 @@ type MappingLineBaseProps = { }; export type MappingElementProps = { - readonly fieldsData: IR; + readonly fieldsData: IR; } & ( | Omit | (Omit & { diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx index c7c5a7166ca..f8465cea9fa 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -21,7 +21,7 @@ import { TableIcon } from '../Molecules/TableIcon'; import { userPreferences } from '../Preferences/userPreferences'; import { ButtonWithConfirmation } from './Components'; import type { - HtmlGeneratorFieldData, + MapperComponentData, MappingElementProps, } from './LineComponents'; import { MappingPathComponent } from './LineComponents'; @@ -236,7 +236,7 @@ export function mappingOptionsMenu({ readonly onChangeMatchBehaviour: (matchBehavior: MatchBehaviors) => void; readonly onToggleAllowNulls: (allowNull: boolean) => void; readonly onChangeDefaultValue: (defaultValue: string | null) => void; -}): IR { +}): IR { return { matchBehavior: { optionLabel: ( diff --git a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts index 942b6079a8b..e83d0dc3f7e 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/navigator.ts @@ -20,7 +20,7 @@ import { getTreeDefinitions, isTreeTable } from '../InitialContext/treeRanks'; import { hasTablePermission, hasTreeAccess } from '../Permissions/helpers'; import type { CustomSelectSubtype } from './CustomSelectElement'; import type { - HtmlGeneratorFieldData, + MapperComponentData, MappingElementProps, } from './LineComponents'; import type { MappingPath } from './Mapper'; @@ -295,7 +295,7 @@ export function getMappingLineData({ const commitInstanceData = ( customSelectSubtype: CustomSelectSubtype, table: SpecifyTable, - fieldsData: RA + fieldsData: RA ): void => void internalState.mappingLineData.push({ customSelectSubtype, diff --git a/specifyweb/frontend/js_src/lib/components/WebLinks/List.tsx b/specifyweb/frontend/js_src/lib/components/WebLinks/List.tsx index 05d553dcd84..398e5593ed9 100644 --- a/specifyweb/frontend/js_src/lib/components/WebLinks/List.tsx +++ b/specifyweb/frontend/js_src/lib/components/WebLinks/List.tsx @@ -3,25 +3,22 @@ import { useNavigate } from 'react-router-dom'; import { commonText } from '../../localization/common'; import { resourcesText } from '../../localization/resources'; -import type { GetSet, RA } from '../../utils/types'; import { localized } from '../../utils/types'; import { Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Link } from '../Atoms/Link'; import { TableIcon } from '../Molecules/TableIcon'; import { resolveRelative } from '../Router/queryString'; -import type { WebLink } from './spec'; +import type { WebLinkOutlet } from './Editor'; export function WebLinkList({ items: [items, setItems], -}: { - readonly items: GetSet>; -}): JSX.Element { +}: WebLinkOutlet): JSX.Element { const navigate = useNavigate(); return (
    -

    {resourcesText.availableWebLink()}

    +

    {resourcesText.availableWebLinks()}

      {items.map((item, index) => (
    • diff --git a/specifyweb/frontend/js_src/lib/localization/README.md b/specifyweb/frontend/js_src/lib/localization/README.md index a9977a657da..064780d7ec8 100644 --- a/specifyweb/frontend/js_src/lib/localization/README.md +++ b/specifyweb/frontend/js_src/lib/localization/README.md @@ -35,7 +35,7 @@ `'es'` is the Weblate language code -8. Open [/specifyweb/settings/**init**.py](/specifyweb/settings/__init__.py) +8. Open [`/specifyweb/settings/__init__.py`](/specifyweb/settings/__init__.py) 9. Add newly created language to `LANGUAGES` array. 10. Push the changes to `main` branch (weblate is setup to only look at that branch). diff --git a/specifyweb/frontend/js_src/lib/localization/forms.ts b/specifyweb/frontend/js_src/lib/localization/forms.ts index 425a3c83510..2a0134fe21b 100644 --- a/specifyweb/frontend/js_src/lib/localization/forms.ts +++ b/specifyweb/frontend/js_src/lib/localization/forms.ts @@ -1041,6 +1041,22 @@ export const formsText = createDictionary({ "de-ch": "Automatische Nummerierung", "pt-br": "Numeração automática", }, + autoNumberByYear: { + 'en-us': 'Auto-number by year', + 'de-ch': 'Auto-Nummer nach Jahr', + 'es-es': 'Auto-número por año', + 'fr-fr': 'Auto-numéro par année', + 'ru-ru': 'Автонумерация по году', + 'uk-ua': 'Автонумерація за роком', + }, + autoNumber: { + 'en-us': 'Auto-number', + 'de-ch': 'Auto-Nummer', + 'es-es': 'Auto-número', + 'fr-fr': 'Auto-numéro', + 'ru-ru': 'Автонумерация', + 'uk-ua': 'Автонумерація', + }, editFormDefinition: { "en-us": "Edit Form Definition", "ru-ru": "Редактировать определение формы", diff --git a/specifyweb/frontend/js_src/lib/localization/query.ts b/specifyweb/frontend/js_src/lib/localization/query.ts index 1b245c2da79..1d265336323 100644 --- a/specifyweb/frontend/js_src/lib/localization/query.ts +++ b/specifyweb/frontend/js_src/lib/localization/query.ts @@ -809,6 +809,14 @@ export const queryText = createDictionary({ "uk-ua": "років", "pt-br": "Anos", }, + year: { + 'en-us': 'Year', + 'de-ch': 'Jahr', + 'es-es': 'Año', + 'fr-fr': 'Année', + 'ru-ru': 'Год', + 'uk-ua': 'рік', + }, relativeDate: { comment: ` Used in query builder lines, will be shown as a number followed by a diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index f476d27c012..c5b95d2b9ce 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -180,6 +180,13 @@ export const resourcesText = createDictionary({ "de-ch": "Feldformatierer", "pt-br": "Formatadores de campo", }, + fieldFormattersDescription: { + 'en-us': ` + The “Field Format” controls how data for a specific table field is + displayed in query results, exports, and forms. It manages autonumbering + and the composition of various parts that define the field. + `, + }, dataObjectFormatters: { "en-us": "Record Formatters", "ru-ru": "Форматеры записи", @@ -275,18 +282,16 @@ export const resourcesText = createDictionary({ "uk-ua": "Доступні веб-посилання", "pt-br": "Links da Web disponíveis", }, + availableFieldFormatters: { + 'en-us': 'Available Field Formatters', + 'de-ch': 'Verfügbare Feldformatierer', + 'es-es': 'Formateadores de campo disponibles', + 'fr-fr': 'Formateurs de champs disponibles', + 'ru-ru': 'Доступные форматеры полей', + 'uk-ua': 'Доступні форматувальники полів', + }, selectDefaultFormatter: { - "en-us": "Please select a default record formatter for this table", - "de-ch": - "Bitte wählen Sie einen Standard-Datensatzformatierer für diese Tabelle", - "es-es": - "Seleccione un formateador de registro predeterminado para esta tabla", - "fr-fr": - "Veuillez sélectionner un formateur d'enregistrement par défaut pour cette table", - "ru-ru": - "Пожалуйста, выберите форматирование записей по умолчанию для этой таблицы.", - "uk-ua": "Виберіть стандартний формат запису для цієї таблиці", - "pt-br": "Selecione um formatador de registro padrão para esta tabela", + 'en-us': 'Please designate one of the formatters as default', }, duplicateFormatters: { "en-us": "Record formatter names must be unique", @@ -1015,4 +1020,47 @@ export const resourcesText = createDictionary({ "ru-ru": "Удалить начальные нули из числовых значений.", "uk-ua": "Видаліть початкові нулі з числових значень.", }, + formatterPreviewUnavailable: { + 'en-us': 'Preview for formatter of this type is not available', + }, + nonConformingInline: { + 'en-us': '(non-conforming)', + }, + value: { + 'en-us': 'Value', + 'de-ch': 'Wert', + 'es-es': 'Valor', + 'fr-fr': 'Valeur', + 'ru-ru': 'Значение', + 'uk-ua': 'Значення', + }, + constant: { + 'en-us': 'Constant', + }, + alpha: { + 'en-us': 'Alpha', + }, + numeric: { + 'en-us': 'Numeric', + }, + alphanumeric: { + 'en-us': 'Alphanumeric', + }, + anychar: { + 'en-us': 'Any character', + }, + regex: { + 'en-us': 'Regular expression', + }, + exampleField: { + 'en-us': 'Example Field', + 'de-ch': 'Beispielfeld', + 'es-es': 'Campo de ejemplo', + 'fr-fr': "Champ d'exemple", + 'ru-ru': 'Пример поля', + 'uk-ua': 'Приклад поле', + }, + pattern: { + 'en-us': 'Pattern', + }, } as const); diff --git a/specifyweb/frontend/js_src/lib/localization/utils/scanUsages.ts b/specifyweb/frontend/js_src/lib/localization/utils/scanUsages.ts index fe37b54f562..7de4b6cacd0 100644 --- a/specifyweb/frontend/js_src/lib/localization/utils/scanUsages.ts +++ b/specifyweb/frontend/js_src/lib/localization/utils/scanUsages.ts @@ -413,6 +413,7 @@ export async function scanUsages( ), 'Value:\n', instances.at(-1)?.originalValue ?? valueString, + '\n', ].join('') ); }) diff --git a/specifyweb/frontend/js_src/lib/utils/parser/__tests__/definitions.test.ts b/specifyweb/frontend/js_src/lib/utils/parser/__tests__/definitions.test.ts index 457b8d70dbc..ce394fb7676 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/__tests__/definitions.test.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/__tests__/definitions.test.ts @@ -5,7 +5,7 @@ import type { } from '../../../components/DataModel/specifyField'; import { tables } from '../../../components/DataModel/tables'; import { - formatterTypeMapper, + fieldFormatterTypeMapper, UiFormatter, } from '../../../components/FieldFormatters'; import { userPreferences } from '../../../components/Preferences/userPreferences'; @@ -18,8 +18,8 @@ import { removeKey } from '../../utils'; import type { Parser } from '../definitions'; import { browserifyRegex, + fieldFormatterToParser, formatter, - formatterToParser, getValidationAttributes, lengthToRegex, mergeParsers, @@ -55,18 +55,16 @@ describe('parserFromType', () => { }); const formatterFields = [ - new formatterTypeMapper.constant({ + new fieldFormatterTypeMapper.constant({ size: 2, - value: localized('AB'), + placeholder: localized('AB'), autoIncrement: false, byYear: false, - pattern: localized('\\d{1,2}'), }), - new formatterTypeMapper.numeric({ + new fieldFormatterTypeMapper.numeric({ size: 2, autoIncrement: true, byYear: false, - pattern: localized('\\d{1,2}'), }), ]; const uiFormatter = new UiFormatter( @@ -77,7 +75,7 @@ const uiFormatter = new UiFormatter( undefined, 'test' ); -const title = formsText.requiredFormat({ format: uiFormatter.pattern()! }); +const title = formsText.requiredFormat({ format: uiFormatter.defaultValue }); describe('resolveParser', () => { test('simple string with parser merger', () => { @@ -127,7 +125,7 @@ describe('resolveParser', () => { ...parserFromType('java.lang.String'), required: false, ...removeKey( - formatterToParser(field, uiFormatter), + fieldFormatterToParser(field, uiFormatter), 'formatters', 'parser', 'validators' @@ -241,14 +239,12 @@ describe('formatterToParser', () => { validators, parser: parserFunction, ...parser - } = formatterToParser({}, uiFormatter); + } = fieldFormatterToParser({}, uiFormatter); expect(parser).toEqual({ - // Regex may be coming from the user, thus disable strict mode - // eslint-disable-next-line require-unicode-regexp - pattern: new RegExp(uiFormatter.parseRegExp()), + pattern: uiFormatter.regex, title, - placeholder: uiFormatter.pattern()!, - value: uiFormatter.valueOrWild(), + placeholder: uiFormatter.placeholder, + value: uiFormatter.defaultValue, }); expect(formatters).toBeInstanceOf(Array); @@ -275,7 +271,7 @@ describe('formatterToParser', () => { userPreferences.set('form', 'preferences', 'autoNumbering', { CollectionObject: [], }); - expect(formatterToParser(field, uiFormatter).value).toBeUndefined(); + expect(fieldFormatterToParser(field, uiFormatter).value).toBeUndefined(); }); }); diff --git a/specifyweb/frontend/js_src/lib/utils/parser/dateFormat.ts b/specifyweb/frontend/js_src/lib/utils/parser/dateFormat.ts index 53abf3fed53..929e58851af 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/dateFormat.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/dateFormat.ts @@ -9,7 +9,7 @@ export const fullDateFormat = (): string => export const monthFormat = (): string => getPref('ui.formatting.scrmonthformat'); -export function formatDateForBackEnd(date: Date) { +export function formatDateForBackEnd(date: Date): string { const year = date.getFullYear(); const month = (date.getMonth() + 1).toString(); const day = date.getDate().toString(); diff --git a/specifyweb/frontend/js_src/lib/utils/parser/dayJsFixes.ts b/specifyweb/frontend/js_src/lib/utils/parser/dayJsFixes.ts index c81288694ac..15490c337ba 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/dayJsFixes.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/dayJsFixes.ts @@ -58,7 +58,7 @@ function fixDayJsBugs( function unsafeParseMonthYear( value: string ): ReturnType | undefined { - const parsed = /(\d{2})\D(\d{4})/.exec(value)?.slice(1); + const parsed = /(\d{2})\D(\d{4})/u.exec(value)?.slice(1); if (parsed === undefined) return undefined; const [month, year] = parsed.map(f.unary(Number.parseInt)); return dayjs(new Date(year, month - 1)); @@ -72,7 +72,7 @@ function unsafeParseFullDate( value: string ): ReturnType | undefined { if (fullDateFormat().toUpperCase() !== 'DD/MM/YYYY') return; - const parsed = /(\d{2})\D(\d{2})\D(\d{4})/.exec(value)?.slice(1); + const parsed = /(\d{2})\D(\d{2})\D(\d{4})/u.exec(value)?.slice(1); if (parsed === undefined) return undefined; const [day, month, year] = parsed.map(f.unary(Number.parseInt)); return dayjs(new Date(year, month - 1, day)); diff --git a/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts index d2c4a7cacc6..f612701d5d3 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts @@ -29,7 +29,8 @@ import { fullDateFormat } from './dateFormat'; /** Makes sure a wrapped function would receive a string value */ export const stringGuard = - (formatter: (value: string) => unknown) => (value: unknown) => + (formatter: (value: string) => unknown) => + (value: unknown): unknown => typeof value === 'string' ? formatter(value) : error('Value is not a string'); @@ -102,7 +103,8 @@ type ExtendedJavaType = JavaType | 'day' | 'month' | 'year'; * This could be resolved by enabling time mocking globally, but that's not * great as it can alter behavior of the code */ -const getDate = () => (process.env.NODE_ENV === 'test' ? testTime : new Date()); +const getDate = (): Date => + process.env.NODE_ENV === 'test' ? testTime : new Date(); export const parsers = f.store( (): RR => ({ @@ -300,7 +302,7 @@ export function resolveParser( ? undefined : (fullField as LiteralField).whiteSpaceSensitive, ...(typeof formatter === 'object' - ? formatterToParser(field, formatter) + ? fieldFormatterToParser(field, formatter) : {}), }); } @@ -358,21 +360,19 @@ function resolveDate( if (values.length === 1) return values[0]; const leftDate = new Date(values[0]); const rightDate = new Date(values[1]); - return leftDate.getTime() < rightDate.getTime() === takeMin - ? values[0] - : values[1]; + const isLesser = leftDate < rightDate; + return isLesser === takeMin ? values[0] : values[1]; } const callback = takeMin ? f.min : f.max; return callback(...(values as RA)); } -export function formatterToParser( +export function fieldFormatterToParser( field: Partial, formatter: UiFormatter ): Parser { - const regExpString = formatter.parseRegExp(); const title = formsText.requiredFormat({ - format: formatter.pattern() ?? formatter.valueOrWild(), + format: formatter.placeholder, }); const autoNumberingConfig = userPreferences.get( @@ -385,29 +385,27 @@ export function formatterToParser( typeof tableName === 'string' ? (autoNumberingConfig[tableName] as RA) : undefined; - const canAutoNumber = - formatter.canAutonumber() && - (autoNumberingFields === undefined || - autoNumberingFields.includes(field.name ?? '')); + const autoNumberingEnabled = + autoNumberingFields === undefined || + autoNumberingFields.includes(field.name ?? ''); + const canAutoNumber = formatter.canAutonumber() && autoNumberingEnabled; return { - // Regex may be coming from the user, thus disable strict mode - // eslint-disable-next-line require-unicode-regexp - pattern: regExpString === null ? undefined : new RegExp(regExpString), + pattern: formatter.regex, title, formatters: [stringGuard(formatter.parse.bind(formatter))], validators: [ (value): string | undefined => value === undefined || value === null ? title : undefined, ], - placeholder: formatter.pattern() ?? undefined, + placeholder: formatter.placeholder, type: field.type === undefined ? undefined : parserFromType(field.type as ExtendedJavaType).type, parser: (value: unknown): string => formatter.canonicalize(value as RA), - value: canAutoNumber ? formatter.valueOrWild() : undefined, + value: canAutoNumber ? formatter.defaultValue : undefined, }; } @@ -465,7 +463,7 @@ export function pluralizeParser(rawParser: Parser): Parser { // FEATURE: allow customizing this const separator = ','; -/** Modify a regex pattern to allow a comma separate list of values */ +/** Modify a regex pattern to allow a comma separated list of values */ export function pluralizeRegex(regex: RegExp): RegExp { const pattern = browserifyRegex(regex); // Pattern with whitespace diff --git a/specifyweb/specify/utils/uiformatters.py b/specifyweb/specify/utils/uiformatters.py index 1d8ede523ac..4684e1661fa 100644 --- a/specifyweb/specify/utils/uiformatters.py +++ b/specifyweb/specify/utils/uiformatters.py @@ -257,7 +257,16 @@ def value_regexp(self) -> str: class RegexField(Field): def value_regexp(self) -> str: - return self.value + pattern = self.value + if pattern.startswith('/'): + pattern = pattern[1:] + if pattern.startswith('^'): + pattern = pattern[1:] + if pattern.endswith('/'): + pattern = pattern[:-1] + if pattern.endswith('$'): + pattern = pattern[:-1] + return pattern class AlphaField(Field): def value_regexp(self) -> str: