From 0fd3c9558c54c04c342be77da88a26a25cab60ca Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 29 Jun 2024 21:25:26 -0700 Subject: [PATCH 01/27] feat(FieldFormatter): begin implementing visual editor --- .../AppResources/TabDefinitions.tsx | 7 +- .../lib/components/AppResources/types.tsx | 2 +- .../lib/components/DataEntryTables/fetch.ts | 4 +- .../lib/components/FieldFormatters/Editor.tsx | 20 ++++ .../components/FieldFormatters/Element.tsx | 27 +++++ .../FieldFormatters/FieldFormatter.tsx | 87 ++++++++++++++ .../lib/components/FieldFormatters/List.tsx | 78 +++++++++++++ .../lib/components/FieldFormatters/Routes.tsx | 44 ++++++++ .../lib/components/FieldFormatters/Table.tsx | 41 +++++++ .../FieldFormatters/__tests__/index.test.ts | 4 +- .../lib/components/FieldFormatters/index.ts | 59 +++++----- .../lib/components/FieldFormatters/spec.ts | 51 +++++++-- .../js_src/lib/components/FormParse/index.ts | 4 +- .../lib/components/Formatters/Element.tsx | 106 +++++++++++------- .../lib/components/Formatters/Preview.tsx | 2 +- .../lib/components/Formatters/formatters.ts | 4 +- .../lib/components/Forms/dataObjFormatters.ts | 4 +- .../lib/components/InitialContext/index.ts | 4 +- .../components/InitialContext/remotePrefs.ts | 4 +- .../lib/components/Interactions/fetch.ts | 4 +- .../js_src/lib/components/Leaflet/layers.ts | 6 +- .../Preferences/BasePreferences.tsx | 6 +- .../lib/components/Reports/available.ts | 4 +- .../lib/components/Toolbar/Language.tsx | 4 +- .../js_src/lib/components/WebLinks/List.tsx | 9 +- .../js_src/lib/localization/resources.ts | 28 ++++- 26 files changed, 501 insertions(+), 112 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/FieldFormatters/Editor.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/FieldFormatters/Element.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/FieldFormatters/Table.tsx 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 b344f82c9f4..df9f20cfa46 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -156,7 +156,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/DataEntryTables/fetch.ts b/specifyweb/frontend/js_src/lib/components/DataEntryTables/fetch.ts index dff49c0413e..260a3d4a85a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataEntryTables/fetch.ts +++ b/specifyweb/frontend/js_src/lib/components/DataEntryTables/fetch.ts @@ -8,11 +8,11 @@ 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 { cachableUrl } from '../InitialContext'; +import { cacheableUrl } from '../InitialContext'; import { xmlToSpec } from '../Syncer/xmlUtils'; import { dataEntryItems } from './spec'; -const url = cachableUrl(getAppResourceUrl('DataEntryTaskInit')); +const url = cacheableUrl(getAppResourceUrl('DataEntryTaskInit')); export const fetchLegacyForms = f.store( async (): Promise> => 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..a522221c28c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +import { formsText } from '../../localization/forms'; +import { resourcesText } from '../../localization/resources'; +import type { GetSet, RA } from '../../utils/types'; +import { ErrorMessage } from '../Atoms'; +import { Input, Label } from '../Atoms/Form'; +import { ReadOnlyContext } from '../Core/Contexts'; +import type { AnySchema } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { ResourcePreview } from '../Formatters/Preview'; +import { hasTablePermission } from '../Permissions/helpers'; +import type { UiFormatter } from '.'; +import { resolveFieldFormatter } from '.'; +import { Definitions } from './Definitions'; +import type { FieldFormatter } from './spec'; + +export function FieldFormatterElement({ + item: [fieldFormatter, setFieldFormatter], +}: { + readonly item: GetSet; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + return ( + <> + + {formsText.autoNumbering()} + + setFieldFormatter({ ...fieldFormatter, autoNumber }) + } + /> + + {fieldFormatter.external === undefined ? ( + + ) : ( + {resourcesText.editorNotAvailable()} + )} + + + ); +} + +function FieldFormatterPreview({ + fieldFormatter, +}: { + readonly fieldFormatter: FieldFormatter; +}): JSX.Element | null { + const doFormatting = React.useCallback( + (resources: RA>) => { + const resolvedFormatter = resolveFieldFormatter(fieldFormatter); + return resources.map((resource) => + formatterToPreview(resource, fieldFormatter, resolvedFormatter) + ); + }, + [fieldFormatter] + ); + 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.pattern() ?? resolvedFormatter.valueOrWild(); + + const formatted = resolvedFormatter.format(value); + + return formatted === undefined + ? `${value} ${resourcesText.nonConformingInline()}` + : formatted; +} 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..e0dd5343ed2 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { 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(); + + 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, + autoNumber: false, + external: undefined, + fields: [], + raw: { + javaClass: undefined, + legacyAutoNumber: undefined, + }, + }; + }} + header={resourcesText.availableFieldFormatters()} + items={items} + tableName={tableName} + /> + ); +} 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..dbe1c68e99c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx @@ -0,0 +1,44 @@ +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( + ({ 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__/index.test.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/index.test.ts index 16d9d1353f3..c48bbe783eb 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(); diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 50357e95fb2..3ab2724f788 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -16,6 +16,7 @@ import { tables } from '../DataModel/tables'; import { error } from '../Errors/assert'; import { load } from '../InitialContext'; import { xmlToSpec } from '../Syncer/xmlUtils'; +import type { FieldFormatter } from './spec'; import { fieldFormattersSpec } from './spec'; let uiFormatters: IR; @@ -25,34 +26,12 @@ export const fetchContext = Promise.all([ ]).then(([formatters]) => { uiFormatters = Object.fromEntries( filterArray( - xmlToSpec(formatters, fieldFormattersSpec()).formatters.map( + xmlToSpec(formatters, fieldFormattersSpec()).fieldFormatters.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 - ); - } - - return [formatter.name, resolvedFormatter]; + const resolvedFormatter = resolveFieldFormatter(formatter); + return resolvedFormatter === undefined + ? undefined + : [formatter.name, resolvedFormatter]; } ) ) @@ -62,6 +41,32 @@ 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 +): UiFormatter | undefined { + if (typeof formatter.external === 'string') { + return parseJavaClassName(formatter.external) === + 'CatalogNumberUIFieldFormatter' + ? new CatalogNumberNumeric() + : undefined; + } else { + const fields = filterArray( + formatter.fields.map((field) => + typeof field.type === 'string' + ? new formatterTypeMapper[field.type](field) + : undefined + ) + ); + return new UiFormatter( + formatter.isSystem, + formatter.title ?? formatter.name, + fields, + formatter.table, + formatter.field + ); + } +} + /* eslint-disable functional/no-class */ export class UiFormatter { public constructor( diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index 351a6335a0d..552dfbae875 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,31 @@ 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 { formatterTypeMapper } 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 ?? ''), + autoNumber: rawAutoNumber !== undefined, raw: { javaClass, + legacyAutoNumber: rawAutoNumber, }, }), - ({ table, raw: { javaClass }, ...formatter }) => ({ + ({ + table, + autoNumber, + raw: { javaClass, legacyAutoNumber }, + ...formatter + }) => ({ ...formatter, // "javaClass" is not always a database table javaClass: @@ -32,6 +40,10 @@ export const fieldFormattersSpec = f.store(() => (getTable(javaClass ?? '') === undefined ? javaClass : undefined), + rawAutoNumber: autoNumber + ? legacyAutoNumber ?? + inferLegacyAutoNumber(table, formatter.fields) + : undefined, }) ), syncer( @@ -50,9 +62,31 @@ 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. Trying our best here to match the intended semantics for backwards + * compatibility. + */ +function inferLegacyAutoNumber( + table: SpecifyTable | undefined, + fields: RA<{ readonly type: keyof typeof formatterTypeMapper | 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'; +} + export type FieldFormatter = SpecToJson< ReturnType ->['formatters'][number]; +>['fieldFormatters'][number]; + +export type FieldFormatterField = FieldFormatter['fields'][number]; const formatterSpec = f.store(() => createXmlSpec({ @@ -68,14 +102,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) ), @@ -95,7 +131,6 @@ const fieldSpec = f.store(() => 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)) ), size: pipe( 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/Element.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Element.tsx index 4b5fbbc4044..9c60a174085 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Element.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Element.tsx @@ -16,6 +16,7 @@ import { Form, Input, Label } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; import { Submit } from '../Atoms/Submit'; import { ReadOnlyContext } from '../Core/Contexts'; +import type { SpecifyTable } from '../DataModel/specifyTable'; import { Dialog } from '../Molecules/Dialog'; import { NotFoundView } from '../Router/NotFoundView'; import { resolveRelative } from '../Router/queryString'; @@ -25,8 +26,9 @@ import type { Aggregator, Formatter } from './spec'; import type { FormatterTypesOutlet } from './Types'; /** - * Display a dialog for editing weblink/formatter/aggregator and calls a - * render prop to render the actual interface + * Display a dialog for editing + * weblink/field formatter/formatter/aggregator and calls a render prop to + * render the actual interface */ export function XmlEditorShell< ITEM extends { readonly name: string }, @@ -36,10 +38,7 @@ export function XmlEditorShell< children, }: { readonly header: LocalizedString; - readonly children: (props: { - readonly items: GetSet>; - readonly item: GetSet; - }) => JSX.Element; + readonly children: (props: XmlEditorShellSlotProps) => JSX.Element; }): JSX.Element { const { index: rawIndex } = useParams(); const { items: allItems } = useOutletContext(); @@ -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/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx index 4ed5cb01b7f..372f9369d28 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx @@ -125,7 +125,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 48549e48e65..9ad8f37e576 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/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/fetch.ts b/specifyweb/frontend/js_src/lib/components/Interactions/fetch.ts index 2b207f5bfdf..2d8d57dac39 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/fetch.ts +++ b/specifyweb/frontend/js_src/lib/components/Interactions/fetch.ts @@ -6,11 +6,11 @@ import { filterArray } from '../../utils/types'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { tables } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; -import { cachableUrl } from '../InitialContext'; +import { cacheableUrl } from '../InitialContext'; import { xmlToSpec } from '../Syncer/xmlUtils'; import { interactionEntries } from './spec'; -const url = cachableUrl(getAppResourceUrl('InteractionsTaskInit')); +const url = cacheableUrl(getAppResourceUrl('InteractionsTaskInit')); export const fetchLegacyInteractions = f.store(async () => ajax(url, { headers: { Accept: 'text/xml' }, diff --git a/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts b/specifyweb/frontend/js_src/lib/components/Leaflet/layers.ts index f5e6ff5fce2..3264bc65eda 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/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/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/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/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index 568dc4972fd..ff888be9aff 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -145,7 +145,7 @@ export const resourcesText = createDictionary({ 'uk-ua': 'Веб-посилання', 'de-ch': 'Weblinks', }, - uiFormatters: { + fieldFormatters: { 'en-us': 'Field Formatters', 'ru-ru': 'Форматировщики полей', 'es-es': 'Formateadores de campo', @@ -153,6 +153,13 @@ export const resourcesText = createDictionary({ 'uk-ua': 'Форматувальники полів', 'de-ch': 'Feldformatierer', }, + fieldFormattersDescription: { + 'en-us': ` + Field formatter controls how data for a specific table field is + shown in query results, exports, and on the form. It determines + autonumbering and individual parts that make up the field. + `, + }, dataObjectFormatters: { 'en-us': 'Record Formatters', 'ru-ru': 'Форматеры записи', @@ -284,7 +291,7 @@ export const resourcesText = createDictionary({ 'ru-ru': 'Доступные агрегаты таблиц', 'uk-ua': 'Доступні агрегації таблиць', }, - availableWebLink: { + availableWebLinks: { 'en-us': 'Available Web Links', 'de-ch': 'Verfügbare Weblinks', 'es-es': 'Enlaces web disponibles', @@ -292,6 +299,14 @@ export const resourcesText = createDictionary({ 'ru-ru': 'Доступные веб-ссылки', 'uk-ua': 'Доступні веб-посилання', }, + 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': @@ -846,4 +861,13 @@ export const resourcesText = createDictionary({ 'en-us': 'A Consolidated Collection Object Group must have a primary Collection Object child', }, + formatterPreviewUnavailable: { + 'en-us': 'Preview for formatter of this type is not available', + }, + nonConformingInline: { + 'en-us': '(non-conforming)', + }, + parentCogSameAsChild: { + 'en-us': 'A Collection Object Group cannot be a parent to itself', + }, } as const); From 03acac0256a41760a7c1417c326f4465b0a71ef7 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 6 Jul 2024 10:09:10 -0700 Subject: [PATCH 02/27] refactor(FieldFormatters): cleanup placeholder & regex semantics --- .../__tests__/utils.test.ts | 2 +- .../FieldFormatters/FieldFormatter.tsx | 10 +- .../lib/components/FieldFormatters/Fields.tsx | 253 ++++++++++++++++++ .../FieldFormatters/__tests__/index.test.ts | 22 +- .../lib/components/FieldFormatters/index.ts | 164 +++++------- .../lib/components/FieldFormatters/spec.ts | 16 +- .../lib/components/FormMeta/AutoNumbering.tsx | 4 +- .../lib/components/Formatters/Fields.tsx | 2 +- .../Interactions/InteractionDialog.tsx | 2 +- .../lib/components/QueryBuilder/Line.tsx | 2 +- .../lib/components/SchemaConfig/schemaData.ts | 2 +- .../parser/__tests__/definitions.test.ts | 16 +- .../js_src/lib/utils/parser/definitions.ts | 32 ++- 13 files changed, 377 insertions(+), 150 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/FieldFormatters/Fields.tsx 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 cd49c122ba5..2eee6078078 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 @@ -76,7 +76,7 @@ const fileNameTestSpec: TestDefinition = { new formatterTypeMapper.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, }), ], diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index a522221c28c..20672227c83 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -12,7 +12,7 @@ import { ResourcePreview } from '../Formatters/Preview'; import { hasTablePermission } from '../Permissions/helpers'; import type { UiFormatter } from '.'; import { resolveFieldFormatter } from '.'; -import { Definitions } from './Definitions'; +import { FieldFormatterFields } from './Fields'; import type { FieldFormatter } from './spec'; export function FieldFormatterElement({ @@ -21,6 +21,7 @@ export function FieldFormatterElement({ readonly item: GetSet; }): JSX.Element { const isReadOnly = React.useContext(ReadOnlyContext); + // FIXME: add field selector return ( <> @@ -35,7 +36,9 @@ export function FieldFormatterElement({ /> {fieldFormatter.external === undefined ? ( - + ) : ( {resourcesText.editorNotAvailable()} )} @@ -76,8 +79,7 @@ function formatterToPreview( if (field === undefined) return ''; const value = String(resource.get(field.name) ?? ''); - if (value.length === 0) - return resolvedFormatter.pattern() ?? resolvedFormatter.valueOrWild(); + if (value.length === 0) return resolvedFormatter.defaultValue; const formatted = resolvedFormatter.format(value); diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Fields.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Fields.tsx new file mode 100644 index 00000000000..f49f9a6609e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Fields.tsx @@ -0,0 +1,253 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { queryText } from '../../localization/query'; +import { resourcesText } from '../../localization/resources'; +import { schemaText } from '../../localization/schema'; +import type { GetSet, RA } 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 } from '../Atoms/Form'; +import { icons } from '../Atoms/Icons'; +import { ReadOnlyContext } from '../Core/Contexts'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import { fetchContext as fetchFieldFormatters } from '../FieldFormatters'; +import { + GenericFormatterPickList, + ResourceMapping, +} from '../Formatters/Components'; +import type { Formatter } from '../Formatters/spec'; +import { FormattersPickList } from '../PickLists/FormattersPickList'; +import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; +import type { FieldFormatter } from './spec'; + +export function FieldFormatterFields({ + table, + fieldFormatter: [fieldFormatter, setFieldFormatter], +}: { + readonly table: SpecifyTable; + readonly fieldFormatter: GetSet; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + + const [displayFormatter, setDisplayFormatter] = React.useState(false); + + /* + * FIXME: display type field + * FIXME: display size field (but hardcode to 4 for year) + * FIXME: display placeholder/regex field (unless type year) + * + * FIXME: display byYear checkbox if type year + * FIXME: display autoIncrement checkbox if type number + */ + + /* + * FIXME: make placeholder field required + * FIXME: infer placeholder in the UI for numeric + */ + return ( + <> + {fieldFormatter.fields.length === 0 ? undefined : ( + + + + + + {displayFormatter && } + + + + {fields.map((field, index) => ( + setFields(replaceItem(fields, index, field)), + ]} + key={index} + table={table} + onRemove={(): void => setFields(removeItem(fields, index))} + /> + ))} + +
    {resourcesText.separator()}{schemaText.field()}{schemaText.customFieldFormat()} +
    + )} + {isReadOnly ? null : ( +
    + + setFields([ + ...fields, + { + separator: localized(' '), + aggregator: undefined, + formatter: undefined, + fieldFormatter: undefined, + field: undefined, + }, + ]) + } + > + {resourcesText.addField()} + + + {fields.length > 0 && ( + + + setDisplayFormatter(!displayFormatter) + } + /> + {resourcesText.customizeFieldFormatters()} + + )} +
    + )} + + ); +} + +function Field({ + table, + field: [field, handleChange], + onRemove: handleRemove, + displayFormatter, +}: { + readonly table: SpecifyTable; + readonly field: GetSet< + Formatter['definition']['fields'][number]['fields'][number] + >; + readonly onRemove: () => void; + readonly displayFormatter: boolean; +}): JSX.Element { + const isReadOnly = React.useContext(ReadOnlyContext); + const [openIndex, setOpenIndex] = React.useState( + undefined + ); + return ( + + + + handleChange({ + ...field, + separator, + }) + } + /> + + + + handleChange({ + ...field, + field: fieldMapping, + }), + ]} + openIndex={[openIndex, setOpenIndex]} + table={table} + /> + + {displayFormatter && ( + + + + )} + + {isReadOnly ? null : ( + + {icons.trash} + + )} + + + ); +} + +function FieldFormatterPicker({ + field: [field, handleChange], +}: { + readonly field: GetSet< + Formatter['definition']['fields'][number]['fields'][number] + >; +}): JSX.Element | null { + const lastField = field.field?.at(-1); + if (lastField === undefined) return null; + else if (!lastField.isRelationship) + return ( + + + handleChange({ + ...field, + fieldFormatter, + }) + } + /> + + ); + else if (relationshipIsToMany(lastField)) + return ( + + + handleChange({ + ...field, + aggregator, + }) + } + /> + + ); + else + return ( + + {resourcesText.formatter()} + + handleChange({ + ...field, + formatter, + }) + } + /> + + ); +} 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 c48bbe783eb..d33a4c3c215 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 @@ -16,27 +16,31 @@ 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})$')); + expect(getUiFormatters().CatalogNumberNumericRegex?.placeholder).toBe( + '####[-A]' + )); test('accession number', () => - expect(getSecondFormatter()?.parseRegExp()).toBe( + expect(getSecondFormatter()?.placeholder).toBe( '^(YEAR|\\d{4})(-)([a-zA-Z0-9]{2})(-)(###|\\d{3})$' )); }); -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/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 3ab2724f788..b94fa65f6ac 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -16,7 +16,7 @@ import { tables } from '../DataModel/tables'; import { error } from '../Errors/assert'; import { load } from '../InitialContext'; import { xmlToSpec } from '../Syncer/xmlUtils'; -import type { FieldFormatter } from './spec'; +import type { FieldFormatter, FieldFormatterField } from './spec'; import { fieldFormattersSpec } from './spec'; let uiFormatters: IR; @@ -78,23 +78,35 @@ export class UiFormatter { public readonly field: LiteralField | undefined ) {} - /** - * Value or wildcard (placeholders) - */ - public valueOrWild(): string { - return this.fields.map((field) => field.getDefaultValue()).join(''); + public get defaultValue(): string { + return this.fields.map((field) => field.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.fields + .map((field) => `(${field.placeholderOrValueAsRegex})`) + .join('')}$` + ); + } + + public get regexPlaceholder(): LocalizedString | undefined { + const placeholders = this.fields + .map((field) => field.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); } @@ -114,168 +126,119 @@ export class UiFormatter { .join('') ); } - - public pattern(): LocalizedString | undefined { - return this.fields.some((field) => field.pattern) - ? localized(this.fields.map((field) => field.pattern ?? '').join('')) - : undefined; - } } abstract class Field { public readonly size: number; - public readonly value: LocalizedString; + public readonly placeholder: LocalizedString; + + public readonly regexPlaceholder: LocalizedString | undefined; private 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 constructor({ size, - value, + placeholder, autoIncrement, byYear, - pattern, - }: { - readonly size: number; - readonly value: LocalizedString; - readonly autoIncrement: boolean; - readonly byYear: boolean; - readonly pattern?: LocalizedString; - }) { + regexPlaceholder, + }: Omit) { 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 === 'YEAR' ? 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'; - } - - 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'; - } - - public valueRegexp(): LocalizedString { + public get regex(): LocalizedString { return localized(`[a-zA-Z]{${this.size}}`); } } class NumericField extends Field { public constructor( - options: Omit[0], 'value'> + options: Omit ) { super({ ...options, - value: localized(''.padStart(options.size, '#')), + placeholder: localized(''.padStart(options.size, '#')), }); - 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 valueRegexp(): LocalizedString { + public get regex(): LocalizedString { return localized(`\\d{${this.size}}`); } } class AlphaNumberField extends Field { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'alphanumeric'; - } - - 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'; - } - - 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'; - } - - public valueRegexp(): LocalizedString { - return this.value; + public get regex(): LocalizedString { + return this.placeholder; } } -class SeparatorField extends ConstantField { - public constructor(options: ConstructorParameters[0]) { - super(options); - this.type = 'separator'; - } -} +class SeparatorField extends ConstantField {} class CatalogNumberNumericField extends NumericField { - public valueRegexp(): LocalizedString { + public get regex(): LocalizedString { return localized(`\\d{0,${this.size}}`); } @@ -295,6 +258,7 @@ export class CatalogNumberNumeric extends UiFormatter { size: 9, autoIncrement: true, byYear: false, + regexPlaceholder: undefined, }), ], tables.CollectionObject, diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index 552dfbae875..556b3802bd9 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -134,14 +134,23 @@ const fieldSpec = f.store(() => syncers.enum(Object.keys(formatterTypeMapper)) ), size: pipe( - syncers.xmlAttribute('size', 'skip'), + syncers.xmlAttribute('size', 'required'), syncers.maybe(syncers.toDecimal), syncers.default(1) ), - value: pipe( + /* + * For most fields, this is a human-friendly placeholder like ### or ABC. + * For regex fields, 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 field 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), @@ -152,7 +161,6 @@ const fieldSpec = f.store(() => syncers.maybe(syncers.toBoolean), syncers.default(false) ), - pattern: syncers.xmlAttribute('pattern', 'skip', false), }) ); 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/Formatters/Fields.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx index 33ddfeece26..2f34a6ca3b2 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx @@ -47,7 +47,7 @@ export function Fields({ return ( <> - {fields.length === 0 ? null : ( + {fields.length === 0 ? undefined : ( field.value).join('') ?? ''; + formatter?.fields.map((field) => field.placeholder).join('') ?? ''; const formatterHasNewLine = formatted.includes('\n'); const formatterHasSpaces = formatted.includes(' '); const formatterHasCommas = formatted.includes(','); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx index a0d4f635b2a..b1eb555d6d2 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx @@ -160,7 +160,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/SchemaConfig/schemaData.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts index 6b0d8b8ee0e..c49584513b1 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts @@ -67,7 +67,7 @@ export const fetchSchemaData = async (): Promise => .map(([name, formatter]) => ({ name, isSystem: formatter.isSystem, - value: formatter.valueOrWild(), + value: formatter.defaultValue, field: formatter.field, })) .filter(({ value }) => value) 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 72e5974d38d..bad02cc244d 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 @@ -57,16 +57,16 @@ describe('parserFromType', () => { const formatterFields = [ new formatterTypeMapper.constant({ size: 2, - value: localized('AB'), + placeholder: localized('AB'), + regexPlaceholder: undefined, autoIncrement: false, byYear: false, - pattern: localized('\\d{1,2}'), }), new formatterTypeMapper.numeric({ size: 2, autoIncrement: true, byYear: false, - pattern: localized('\\d{1,2}'), + regexPlaceholder: undefined, }), ]; const uiFormatter = new UiFormatter( @@ -76,7 +76,7 @@ const uiFormatter = new UiFormatter( tables.CollectionObject, undefined ); -const title = formsText.requiredFormat({ format: uiFormatter.pattern()! }); +const title = formsText.requiredFormat({ format: uiFormatter.defaultValue }); describe('resolveParser', () => { test('simple string with parser merger', () => { @@ -242,12 +242,10 @@ describe('formatterToParser', () => { ...parser } = formatterToParser({}, 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); diff --git a/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts index d2c4a7cacc6..83e2c01b7a2 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 => ({ @@ -358,9 +360,8 @@ 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)); @@ -370,9 +371,8 @@ export function formatterToParser( 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 From 4e0cec6566908f9a504a3ec22970d732f61b474f Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 6 Jul 2024 10:27:37 -0700 Subject: [PATCH 03/27] refactor(FieldFormatter): rename "Field" to "Part" - Field is ambiguous name - Field suggests that you can have fields inside of a formatter - you can't - you can only have text parts inside a field formatter --- .../__tests__/utils.test.ts | 10 +- .../components/AttachmentsBulkImport/utils.ts | 25 ++--- .../FieldFormatters/FieldFormatter.tsx | 8 +- .../lib/components/FieldFormatters/List.tsx | 2 +- .../FieldFormatters/{Fields.tsx => Parts.tsx} | 31 +++--- .../lib/components/FieldFormatters/index.ts | 99 +++++++++++-------- .../lib/components/FieldFormatters/spec.ts | 24 ++--- .../Interactions/InteractionDialog.tsx | 3 +- .../parser/__tests__/definitions.test.ts | 8 +- 9 files changed, 108 insertions(+), 102 deletions(-) rename specifyweb/frontend/js_src/lib/components/FieldFormatters/{Fields.tsx => Parts.tsx} (91%) 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 2eee6078078..c3ab8b21a56 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,3 +1,4 @@ +import { LocalizedString } from 'typesafe-i18n'; import { requireContext } from '../../../tests/helpers'; import { formatterToParser } from '../../../utils/parser/definitions'; import type { IR, RA } from '../../../utils/types'; @@ -5,7 +6,7 @@ import { localized } from '../../../utils/types'; import { tables } from '../../DataModel/tables'; import { CatalogNumberNumeric, - formatterTypeMapper, + fieldFormatterTypeMapper, UiFormatter, } from '../../FieldFormatters'; import { syncFieldFormat } from '../../Formatters/fieldFormat'; @@ -49,7 +50,7 @@ const fileNameTestSpec: TestDefinition = { false, localized('testNumeric'), [ - new formatterTypeMapper.numeric({ + new fieldFormatterTypeMapper.numeric({ size: 3, autoIncrement: true, byYear: false, @@ -73,7 +74,7 @@ const fileNameTestSpec: TestDefinition = { false, localized('testRegex'), [ - new formatterTypeMapper.regex({ + new fieldFormatterTypeMapper.regex({ size: 3, autoIncrement: true, placeholder: localized('^\\d{1,6}(?:[a-zA-Z]{1,2})?$'), @@ -101,7 +102,8 @@ 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( diff --git a/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts b/specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/utils.ts index 8693c397fc3..fe44fb2fcb5 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 { queryFieldFilters } from '../QueryBuilder/FieldFilter'; 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/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index 20672227c83..6ced315dd53 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -12,7 +12,7 @@ import { ResourcePreview } from '../Formatters/Preview'; import { hasTablePermission } from '../Permissions/helpers'; import type { UiFormatter } from '.'; import { resolveFieldFormatter } from '.'; -import { FieldFormatterFields } from './Fields'; +import { FieldFormatterParts } from './Parts'; import type { FieldFormatter } from './spec'; export function FieldFormatterElement({ @@ -35,9 +35,11 @@ export function FieldFormatterElement({ } /> - {fieldFormatter.external === undefined ? ( - ) : ( {resourcesText.editorNotAvailable()} diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx index e0dd5343ed2..dcd86d260a7 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx @@ -63,7 +63,7 @@ export function FieldFormattersList(): JSX.Element { legacyPartialDate: undefined, autoNumber: false, external: undefined, - fields: [], + parts: [], raw: { javaClass: undefined, legacyAutoNumber: undefined, diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Fields.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx similarity index 91% rename from specifyweb/frontend/js_src/lib/components/FieldFormatters/Fields.tsx rename to specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index f49f9a6609e..0d7f98aae05 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -13,17 +13,16 @@ import { Input, Label } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; import { ReadOnlyContext } from '../Core/Contexts'; import type { SpecifyTable } from '../DataModel/specifyTable'; -import { fetchContext as fetchFieldFormatters } from '../FieldFormatters'; import { GenericFormatterPickList, ResourceMapping, } from '../Formatters/Components'; -import type { Formatter } from '../Formatters/spec'; import { FormattersPickList } from '../PickLists/FormattersPickList'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; -import type { FieldFormatter } from './spec'; +import { fetchContext as fetchFieldFormatters } from '.'; +import type { FieldFormatter, FieldFormatterPart } from './spec'; -export function FieldFormatterFields({ +export function FieldFormatterParts({ table, fieldFormatter: [fieldFormatter, setFieldFormatter], }: { @@ -75,7 +74,7 @@ export function FieldFormatterFields({ {fields.map((field, index) => ( - ; + readonly field: GetSet; readonly onRemove: () => void; readonly displayFormatter: boolean; }): JSX.Element { @@ -148,10 +145,10 @@ function Field({ handleChange({ - ...field, + ...part, separator, }) } @@ -160,10 +157,10 @@ function Field({ {displayFormatter && ( )}
    handleChange({ - ...field, + ...part, field: fieldMapping, }), ]} @@ -173,7 +170,7 @@ function Field({ - + @@ -195,9 +192,7 @@ function Field({ function FieldFormatterPicker({ field: [field, handleChange], }: { - readonly field: GetSet< - Formatter['definition']['fields'][number]['fields'][number] - >; + readonly field: GetSet; }): JSX.Element | null { const lastField = field.field?.at(-1); if (lastField === undefined) return null; diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index b94fa65f6ac..5257548783c 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -16,7 +16,7 @@ import { tables } from '../DataModel/tables'; import { error } from '../Errors/assert'; import { load } from '../InitialContext'; import { xmlToSpec } from '../Syncer/xmlUtils'; -import type { FieldFormatter, FieldFormatterField } from './spec'; +import type { FieldFormatter, FieldFormatterPart } from './spec'; import { fieldFormattersSpec } from './spec'; let uiFormatters: IR; @@ -50,17 +50,17 @@ export function resolveFieldFormatter( ? new CatalogNumberNumeric() : undefined; } else { - const fields = filterArray( - formatter.fields.map((field) => - typeof field.type === 'string' - ? new formatterTypeMapper[field.type](field) + const parts = filterArray( + formatter.parts.map((part) => + typeof part.type === 'string' + ? new fieldFormatterTypeMapper[part.type](part) : undefined ) ); return new UiFormatter( formatter.isSystem, formatter.title ?? formatter.name, - fields, + parts, formatter.table, formatter.field ); @@ -72,14 +72,14 @@ 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 get defaultValue(): string { - return this.fields.map((field) => field.defaultValue).join(''); + return this.parts.map((part) => part.defaultValue).join(''); } public get placeholder(): string { @@ -91,15 +91,19 @@ export class UiFormatter { // Regex may be coming from the user, thus disable strict mode // eslint-disable-next-line require-unicode-regexp return new RegExp( - `^${this.fields - .map((field) => `(${field.placeholderOrValueAsRegex})`) + `^${this.parts + .map((part) => `(${part.placeholderOrValueAsRegex})`) .join('')}$` ); } - public get regexPlaceholder(): LocalizedString | undefined { - const placeholders = this.fields - .map((field) => field.regexPlaceholder) + 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; @@ -111,7 +115,7 @@ export class UiFormatter { } public canAutonumber(): boolean { - return this.fields.some((field) => field.canAutonumber()); + return this.parts.some((part) => part.canAutonumber()); } public format(value: string): LocalizedString | undefined { @@ -121,14 +125,15 @@ 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('') ); } } -abstract class Field { +type PartOptions = Omit & + Partial>; + +abstract class Part { public readonly size: number; public readonly placeholder: LocalizedString; @@ -139,13 +144,15 @@ abstract class Field { private readonly byYear: boolean; + public abstract readonly type: FieldFormatterPart['type']; + public constructor({ size, placeholder, autoIncrement, byYear, regexPlaceholder, - }: Omit) { + }: PartOptions) { this.size = size; this.placeholder = placeholder; this.autoIncrement = autoIncrement; @@ -184,22 +191,26 @@ abstract class Field { } } -class ConstantField extends Field { +class ConstantPart extends Part { + public readonly type = 'constant'; + public get regex(): LocalizedString { return this.placeholderAsRegex; } } -class AlphaField extends Field { +class AlphaPart extends Part { + public readonly type = 'alpha'; + public get regex(): LocalizedString { return localized(`[a-zA-Z]{${this.size}}`); } } -class NumericField extends Field { - public constructor( - options: Omit - ) { +class NumericPart extends Part { + public readonly type = 'numeric'; + + public constructor(options: Omit) { super({ ...options, placeholder: localized(''.padStart(options.size, '#')), @@ -211,33 +222,41 @@ class NumericField extends Field { } } -class YearField extends Field { +class YearPart extends Part { + public readonly type = 'year'; + public get regex(): LocalizedString { return localized(`\\d{${this.size}}`); } } -class AlphaNumberField extends Field { +class AlphaNumberPart extends Part { + public readonly type = 'alpha'; + public get regex(): LocalizedString { return localized(`[a-zA-Z0-9]{${this.size}}`); } } -class AnyCharField extends Field { +class AnyCharPart extends Part { + public readonly type = 'anychar'; + public get regex(): LocalizedString { return localized(`.{${this.size}}`); } } -class RegexField extends Field { +class RegexPart extends Part { + public readonly type = 'regex'; + public get regex(): LocalizedString { return this.placeholder; } } -class SeparatorField extends ConstantField {} +class SeparatorPart extends ConstantPart {} -class CatalogNumberNumericField extends NumericField { +class CatalogNumberNumericField extends NumericPart { public get regex(): LocalizedString { return localized(`\\d{0,${this.size}}`); } @@ -268,13 +287,13 @@ 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; diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index 556b3802bd9..b13519089d7 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -8,7 +8,7 @@ import type { SpecToJson } from '../Syncer'; import { pipe, syncer } from '../Syncer'; import { syncers } from '../Syncer/syncers'; import { createXmlSpec } from '../Syncer/xmlUtils'; -import { formatterTypeMapper } from '.'; +import { fieldFormatterTypeMapper } from '.'; export const fieldFormattersSpec = f.store(() => createXmlSpec({ @@ -42,7 +42,7 @@ export const fieldFormattersSpec = f.store(() => : undefined), rawAutoNumber: autoNumber ? legacyAutoNumber ?? - inferLegacyAutoNumber(table, formatter.fields) + inferLegacyAutoNumber(table, formatter.parts) : undefined, }) ), @@ -70,7 +70,9 @@ export const fieldFormattersSpec = f.store(() => */ function inferLegacyAutoNumber( table: SpecifyTable | undefined, - fields: RA<{ readonly type: keyof typeof formatterTypeMapper | undefined }> + fields: RA<{ + readonly type: keyof typeof fieldFormatterTypeMapper | undefined; + }> ): string { if (table?.name === 'Accession') return 'edu.ku.brc.specify.dbsupport.AccessionAutoNumberAlphaNum'; @@ -86,7 +88,7 @@ export type FieldFormatter = SpecToJson< ReturnType >['fieldFormatters'][number]; -export type FieldFormatterField = FieldFormatter['fields'][number]; +export type FieldFormatterPart = FieldFormatter['parts'][number]; const formatterSpec = f.store(() => createXmlSpec({ @@ -119,19 +121,19 @@ 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(syncers.object(partSpec())) ), }) ); -const fieldSpec = f.store(() => +const partSpec = f.store(() => createXmlSpec({ type: pipe( syncers.xmlAttribute('type', 'required'), syncers.fallback(localized('alphanumeric')), - syncers.enum(Object.keys(formatterTypeMapper)) + syncers.enum(Object.keys(fieldFormatterTypeMapper)) ), size: pipe( syncers.xmlAttribute('size', 'required'), @@ -139,15 +141,15 @@ const fieldSpec = f.store(() => syncers.default(1) ), /* - * For most fields, this is a human-friendly placeholder like ### or ABC. - * For regex fields, this contains the actual regular expression + * 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('')) ), /* - * Since regular expressions are less readable, this field is specifically + * 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), diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx index bbe4d213ac8..f14e9667d9e 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx @@ -447,8 +447,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.placeholder).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/utils/parser/__tests__/definitions.test.ts b/specifyweb/frontend/js_src/lib/utils/parser/__tests__/definitions.test.ts index bad02cc244d..310a5d1fa8f 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'; @@ -55,18 +55,16 @@ describe('parserFromType', () => { }); const formatterFields = [ - new formatterTypeMapper.constant({ + new fieldFormatterTypeMapper.constant({ size: 2, placeholder: localized('AB'), - regexPlaceholder: undefined, autoIncrement: false, byYear: false, }), - new formatterTypeMapper.numeric({ + new fieldFormatterTypeMapper.numeric({ size: 2, autoIncrement: true, byYear: false, - regexPlaceholder: undefined, }), ]; const uiFormatter = new UiFormatter( From ac18dc47171ec652cf22338c7d4ac4178d831d2d Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 6 Jul 2024 16:42:25 -0700 Subject: [PATCH 04/27] feat(FieldFormatters): complete visual editor --- .../FieldFormatters/FieldFormatter.tsx | 124 ++++++- .../lib/components/FieldFormatters/List.tsx | 4 +- .../lib/components/FieldFormatters/Parts.tsx | 304 +++++++++--------- .../lib/components/FieldFormatters/Routes.tsx | 6 +- .../lib/components/FieldFormatters/index.ts | 23 +- .../lib/components/FormFields/Field.tsx | 40 ++- .../lib/components/Formatters/Components.tsx | 24 +- .../js_src/lib/components/Formatters/List.tsx | 11 + .../lib/components/Formatters/Preview.tsx | 15 +- .../components/QueryBuilder/RelativeDate.tsx | 8 +- .../frontend/js_src/lib/localization/forms.ts | 16 + .../frontend/js_src/lib/localization/query.ts | 34 +- .../js_src/lib/localization/resources.ts | 34 ++ .../js_src/lib/utils/parser/dateFormat.ts | 2 +- .../js_src/lib/utils/parser/dayJsFixes.ts | 4 +- 15 files changed, 426 insertions(+), 223 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index 6ced315dd53..3e41fc15856 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -1,31 +1,38 @@ import React from 'react'; +import { useParser } from '../../hooks/resource'; import { formsText } from '../../localization/forms'; import { resourcesText } from '../../localization/resources'; +import { schemaText } from '../../localization/schema'; +import { getValidationAttributes } from '../../utils/parser/definitions'; import type { GetSet, RA } from '../../utils/types'; import { ErrorMessage } from '../Atoms'; import { Input, Label } from '../Atoms/Form'; import { ReadOnlyContext } from '../Core/Contexts'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { LiteralField } from '../DataModel/specifyField'; +import { softError } from '../Errors/assert'; +import { ResourceMapping } from '../Formatters/Components'; import { ResourcePreview } from '../Formatters/Preview'; +import { useRightAlignClassName } from '../FormFields/Field'; 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: [fieldFormatter, setFieldFormatter], + item, }: { readonly item: GetSet; }): JSX.Element { + const [fieldFormatter, setFieldFormatter] = item; const isReadOnly = React.useContext(ReadOnlyContext); - // FIXME: add field selector return ( <> - {formsText.autoNumbering()} + {formsText.autoNumbering()} + {fieldFormatter.external === undefined && typeof fieldFormatter.table === 'object' ? ( - + ) : ( {resourcesText.editorNotAvailable()} )} @@ -49,23 +55,81 @@ export function FieldFormatterElement({ ); } +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]) => fieldData.tableName === undefined + ) + ), +}); + function FieldFormatterPreview({ fieldFormatter, }: { readonly fieldFormatter: FieldFormatter; }): JSX.Element | null { + const resolvedFormatter = React.useMemo( + () => resolveFieldFormatter(fieldFormatter), + [fieldFormatter] + ); const doFormatting = React.useCallback( - (resources: RA>) => { - const resolvedFormatter = resolveFieldFormatter(fieldFormatter); - return resources.map((resource) => + (resources: RA>) => + resources.map((resource) => formatterToPreview(resource, fieldFormatter, resolvedFormatter) - ); - }, - [fieldFormatter] + ), + [fieldFormatter, resolvedFormatter] ); return typeof fieldFormatter.table === 'object' && hasTablePermission(fieldFormatter.table.name, 'read') ? ( - + <> + + + ) : null; } @@ -89,3 +153,35 @@ function formatterToPreview( ? `${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 = useParser(field); + + const validationAttributes = getValidationAttributes(parser); + const rightAlignClassName = useRightAlignClassName(parser.type, false); + 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 index dcd86d260a7..a050d57b641 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useOutletContext, useParams } from 'react-router-dom'; +import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; import { resourcesText } from '../../localization/resources'; import type { GetSet, RA } from '../../utils/types'; @@ -38,6 +38,7 @@ export function FieldFormatterEditorWrapper(): JSX.Element { export function FieldFormattersList(): JSX.Element { const { tableName } = useParams(); const { items } = useOutletContext(); + const navigate = useNavigate(); return ( navigate('../')} /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index 0d7f98aae05..9e9de7e3458 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -1,88 +1,68 @@ import React from 'react'; +import { useValidation } from '../../hooks/useValidation'; import { commonText } from '../../localization/common'; +import { formsText } from '../../localization/forms'; import { queryText } from '../../localization/query'; import { resourcesText } from '../../localization/resources'; -import { schemaText } from '../../localization/schema'; -import type { GetSet, RA } from '../../utils/types'; +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 } from '../Atoms/Form'; +import { Input, Label, Select } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; import { ReadOnlyContext } from '../Core/Contexts'; -import type { SpecifyTable } from '../DataModel/specifyTable'; -import { - GenericFormatterPickList, - ResourceMapping, -} from '../Formatters/Components'; -import { FormattersPickList } from '../PickLists/FormattersPickList'; -import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; -import { fetchContext as fetchFieldFormatters } from '.'; +import { fieldFormatterLocalization, fieldFormatterTypeMapper } from '.'; import type { FieldFormatter, FieldFormatterPart } from './spec'; export function FieldFormatterParts({ - table, fieldFormatter: [fieldFormatter, setFieldFormatter], }: { - readonly table: SpecifyTable; readonly fieldFormatter: GetSet; }): JSX.Element { const isReadOnly = React.useContext(ReadOnlyContext); - const [displayFormatter, setDisplayFormatter] = React.useState(false); + const { parts } = fieldFormatter; - /* - * FIXME: display type field - * FIXME: display size field (but hardcode to 4 for year) - * FIXME: display placeholder/regex field (unless type year) - * - * FIXME: display byYear checkbox if type year - * FIXME: display autoIncrement checkbox if type number - */ + const setParts = (newParts: typeof parts): void => + setFieldFormatter({ + ...fieldFormatter, + parts: newParts, + }); - /* - * FIXME: make placeholder field required - * FIXME: infer placeholder in the UI for numeric - */ return ( <> - {fieldFormatter.fields.length === 0 ? undefined : ( + {parts.length === 0 ? undefined : ( - - - {displayFormatter && } + + + + - {fields.map((field, index) => ( + {parts.map((part, index) => ( setFields(replaceItem(fields, index, field)), - ]} key={index} - table={table} - onRemove={(): void => setFields(removeItem(fields, index))} + part={[ + part, + (part): void => setParts(replaceItem(parts, index, part)), + ]} + onRemove={(): void => setParts(removeItem(parts, index))} /> ))} @@ -92,32 +72,21 @@ export function FieldFormatterParts({
    - setFields([ - ...fields, + setParts([ + ...parts, { - separator: localized(' '), - aggregator: undefined, - formatter: undefined, - fieldFormatter: undefined, - field: undefined, + type: 'constant', + size: 1, + placeholder: localized(''), + regexPlaceholder: undefined, + byYear: false, + autoIncrement: false, }, ]) } > {resourcesText.addField()} - - {fields.length > 0 && ( - - - setDisplayFormatter(!displayFormatter) - } - /> - {resourcesText.customizeFieldFormatters()} - - )}
    )} @@ -125,54 +94,145 @@ export function FieldFormatterParts({ } function Part({ - table, - field: [part, handleChange], + part: [part, handleChange], onRemove: handleRemove, - displayFormatter, }: { - readonly table: SpecifyTable; - readonly field: GetSet; + readonly part: GetSet; readonly onRemove: () => void; - readonly displayFormatter: boolean; }): JSX.Element { const isReadOnly = React.useContext(ReadOnlyContext); - const [openIndex, setOpenIndex] = React.useState( - undefined + + React.useEffect(() => { + if (part.type === 'year') + handleChange({ + ...part, + size: 4, + placeholder: fieldFormatterTypeMapper.year.placeholder, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [part.type]); + + React.useEffect(() => { + if (part.type === 'numeric') + handleChange({ + ...part, + placeholder: fieldFormatterTypeMapper.numeric.buildPlaceholder( + part.size + ), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [part.size, part.type]); + + const enforcePlaceholderSize = + part.type === 'constant' || + part.type === 'separator' || + part.type === 'year'; + + const placeholderSize = enforcePlaceholderSize ? part.size : undefined; + + /** + * While native browser validation does length enforcement, it only does so + * when the field value is changed by the user - if field already had + * incorrect value, or if validation requirement changed (because size field + * was changed), browser won't automatically re-validate so we have to + */ + const requestedSize = placeholderSize ?? part.placeholder.length; + const actualSize = part.placeholder.length; + const { validationRef } = useValidation( + actualSize > requestedSize + ? queryText.tooLongErrorMessage({ maxLength: requestedSize }) + : actualSize < requestedSize + ? queryText.tooShortErrorMessage({ minLength: requestedSize }) + : undefined ); + return ( + + - {displayFormatter && ( - - )} - + @@ -117,7 +117,7 @@ function Part({ handleChange({ ...part, placeholder: fieldFormatterTypeMapper.numeric.buildPlaceholder( - part.size + part.size ?? 1 ), }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -128,23 +128,14 @@ function Part({ part.type === 'separator' || part.type === 'year'; - const placeholderSize = enforcePlaceholderSize ? part.size : undefined; - - /** - * While native browser validation does length enforcement, it only does so - * when the field value is changed by the user - if field already had - * incorrect value, or if validation requirement changed (because size field - * was changed), browser won't automatically re-validate so we have to - */ - const requestedSize = placeholderSize ?? part.placeholder.length; - const actualSize = part.placeholder.length; - const { validationRef } = useValidation( - actualSize > requestedSize - ? queryText.tooLongErrorMessage({ maxLength: requestedSize }) - : actualSize < requestedSize - ? queryText.tooShortErrorMessage({ minLength: requestedSize }) - : undefined - ); + React.useEffect(() => { + if (enforcePlaceholderSize) + handleChange({ + ...part, + size: part.placeholder.length, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [part.placeholder, enforcePlaceholderSize]); return ( @@ -152,7 +143,7 @@ function Part({ ); } + +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); + } catch (error: unknown) { + target.setCustomValidity(String(error)); + target.reportValidity(); + } + }} + onValueChange={setPendingValue} + /> + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 5b5c09bfd62..705550188c2 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -149,7 +149,7 @@ abstract class Part { public abstract readonly type: FieldFormatterPart['type']; public constructor({ - size, + size = 1, placeholder, autoIncrement, byYear, @@ -215,7 +215,7 @@ class NumericPart extends Part { public constructor(options: Omit) { super({ ...options, - placeholder: NumericPart.buildPlaceholder(options.size), + placeholder: NumericPart.buildPlaceholder(options.size ?? 1), }); } diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index b13519089d7..0b825e64384 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -136,9 +136,8 @@ const partSpec = f.store(() => syncers.enum(Object.keys(fieldFormatterTypeMapper)) ), size: pipe( - syncers.xmlAttribute('size', 'required'), - syncers.maybe(syncers.toDecimal), - syncers.default(1) + syncers.xmlAttribute('size', 'skip'), + syncers.maybe(syncers.toDecimal) ), /* * For most parts, this is a human-friendly placeholder like ### or ABC. diff --git a/specifyweb/frontend/js_src/lib/localization/query.ts b/specifyweb/frontend/js_src/lib/localization/query.ts index 561c438d1f1..3c493c46897 100644 --- a/specifyweb/frontend/js_src/lib/localization/query.ts +++ b/specifyweb/frontend/js_src/lib/localization/query.ts @@ -742,12 +742,6 @@ export const queryText = createDictionary({ {maxLength:number|formatted} `, }, - tooShortErrorMessage: { - 'en-us': ` - Field value is too short. Min allowed length is - {minLength:number|formatted} - `, - }, future: { 'en-us': 'in the future', 'de-ch': 'Exportdatei wird erstellt', diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index fb58b5f1459..b3d8ad73bd7 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -539,7 +539,7 @@ export const resourcesText = createDictionary({ 'uk-ua': 'Попередній перегляд', }, previewExplainer: { - 'en-us': 'Search your collection records to preview the record formatter', + 'en-us': 'Search your collection records to preview the formatter', 'de-ch': ` Durchsuchen Sie Ihre Sammlungsdatensätze, um eine Vorschau des Datensatzformatierers anzuzeigen @@ -553,12 +553,12 @@ export const resourcesText = createDictionary({ formateur d'enregistrements `, 'ru-ru': ` - Выполните поиск в записях своей коллекции, чтобы просмотреть средство - форматирования записей. + Выполните поиск в записях своей коллекции, чтобы просмотреть + форматирования. `, 'uk-ua': ` - Виконайте пошук у своїх записах колекції, щоб переглянути інструмент - форматування записів + Виконайте пошук у своїх записах колекції, щоб переглянути + форматування `, }, editorNotAvailable: { @@ -901,6 +901,9 @@ export const resourcesText = createDictionary({ 'ru-ru': 'Пример поля', 'uk-ua': 'Приклад поле', }, + pattern: { + 'en-us': 'Pattern', + }, parentCogSameAsChild: { 'en-us': 'A Collection Object Group cannot be a parent to itself', }, From 6850bab037f4bb7fc36f2986adb05faed94d19a3 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 6 Jul 2024 17:25:16 -0700 Subject: [PATCH 06/27] tests(FieldFormatters): update failing tests --- .../__snapshots__/index.test.ts.snap | 360 +++++++++--------- .../FieldFormatters/__tests__/index.test.ts | 4 +- 2 files changed, 181 insertions(+), 183 deletions(-) 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 95c01ecf9bb..ac849aacaca 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,516 +4,516 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` { "AccessionNumber": UiFormatter { "field": "[literalField Accession.accessionNumber]", - "fields": [ - YearField { + "isSystem": true, + "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, "table": "[table Accession]", "title": "AccessionNumber", }, "AccessionNumberByYear": UiFormatter { "field": "[literalField Accession.accessionNumber]", - "fields": [ - YearField { + "isSystem": true, + "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, "table": "[table Accession]", "title": "AccessionNumberByYear", }, "AccessionStringFormatter": UiFormatter { "field": "[literalField Accession.accessionNumber]", - "fields": [ - AlphaNumberField { + "isSystem": true, + "parts": [ + AlphaNumberPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "AAAAAAAAAA", + "regexPlaceholder": undefined, "size": 10, - "type": "alphanumeric", - "value": "AAAAAAAAAA", + "type": "alpha", }, ], - "isSystem": true, "table": "[table Accession]", "title": "AccessionStringFormatter", }, "CatalogNumber": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ - YearField { + "isSystem": false, + "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, "table": "[table CollectionObject]", "title": "CatalogNumber", }, "CatalogNumberAlphaNumByYear": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ - YearField { + "isSystem": false, + "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, "table": "[table CollectionObject]", "title": "CatalogNumberAlphaNumByYear", }, "CatalogNumberNumeric": CatalogNumberNumeric { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ + "isSystem": true, + "parts": [ CatalogNumberNumericField { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "#########", + "regexPlaceholder": undefined, "size": 9, "type": "numeric", - "value": "#########", }, ], - "isSystem": true, "table": "[table CollectionObject]", "title": "Catalog Number Numeric", }, "CatalogNumberNumericRegex": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ - RegexField { + "isSystem": false, + "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, "table": "[table CollectionObject]", "title": "CatalogNumberNumericRegex", }, "Date": UiFormatter { "field": undefined, - "fields": [], "isSystem": true, + "parts": [], "table": undefined, "title": "Date", }, "DeaccessionNumber": UiFormatter { "field": "[literalField Deaccession.deaccessionNumber]", - "fields": [ - YearField { + "isSystem": true, + "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, "table": "[table Deaccession]", "title": "DeaccessionNumber", }, "GiftNumber": UiFormatter { "field": "[literalField Gift.giftNumber]", - "fields": [ - YearField { + "isSystem": true, + "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, "table": "[table Gift]", "title": "GiftNumber", }, "InfoRequestNumber": UiFormatter { "field": "[literalField InfoRequest.infoReqNumber]", - "fields": [ - YearField { + "isSystem": true, + "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, "table": "[table InfoRequest]", "title": "InfoRequestNumber", }, "KUITeach": CatalogNumberNumeric { "field": "[literalField CollectionObject.catalogNumber]", - "fields": [ + "isSystem": true, + "parts": [ CatalogNumberNumericField { "autoIncrement": true, "byYear": false, - "pattern": undefined, + "placeholder": "#########", + "regexPlaceholder": undefined, "size": 9, "type": "numeric", - "value": "#########", }, ], - "isSystem": true, "table": "[table CollectionObject]", "title": "Catalog Number Numeric", }, "LoanNumber": UiFormatter { "field": "[literalField Loan.loanNumber]", - "fields": [ - YearField { + "isSystem": true, + "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, "table": "[table Loan]", "title": "LoanNumber", }, "NumericBigDecimal": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "###############", + "regexPlaceholder": undefined, "size": 15, "type": "numeric", - "value": "###############", }, ], - "isSystem": true, "table": undefined, "title": "NumericBigDecimal", }, "NumericByte": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "###", + "regexPlaceholder": undefined, "size": 3, "type": "numeric", - "value": "###", }, ], - "isSystem": true, "table": undefined, "title": "NumericByte", }, "NumericDouble": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "##########", + "regexPlaceholder": undefined, "size": 10, "type": "numeric", - "value": "##########", }, ], - "isSystem": true, "table": undefined, "title": "NumericDouble", }, "NumericFloat": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "##########", + "regexPlaceholder": undefined, "size": 10, "type": "numeric", - "value": "##########", }, ], - "isSystem": true, "table": undefined, "title": "NumericFloat", }, "NumericInteger": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "##########", + "regexPlaceholder": undefined, "size": 10, "type": "numeric", - "value": "##########", }, ], - "isSystem": true, "table": undefined, "title": "NumericInteger", }, "NumericLong": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "##########", + "regexPlaceholder": undefined, "size": 10, "type": "numeric", - "value": "##########", }, ], - "isSystem": true, "table": undefined, "title": "NumericLong", }, "NumericShort": UiFormatter { "field": undefined, - "fields": [ - NumericField { + "isSystem": true, + "parts": [ + NumericPart { "autoIncrement": false, "byYear": false, - "pattern": undefined, + "placeholder": "#####", + "regexPlaceholder": undefined, "size": 5, "type": "numeric", - "value": "#####", }, ], - "isSystem": true, "table": undefined, "title": "NumericShort", }, "PartialDate": UiFormatter { "field": undefined, - "fields": [], "isSystem": false, + "parts": [], "table": undefined, "title": "PartialDate", }, "PartialDateMonth": UiFormatter { "field": undefined, - "fields": [], "isSystem": false, + "parts": [], "table": undefined, "title": "PartialDateMonth", }, "PartialDateYear": UiFormatter { "field": undefined, - "fields": [], "isSystem": false, + "parts": [], "table": undefined, "title": "PartialDateYear", }, "SearchDate": UiFormatter { "field": undefined, - "fields": [], "isSystem": true, + "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 d33a4c3c215..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 @@ -30,9 +30,7 @@ describe('placeholder', () => { '####[-A]' )); test('accession number', () => - expect(getSecondFormatter()?.placeholder).toBe( - '^(YEAR|\\d{4})(-)([a-zA-Z0-9]{2})(-)(###|\\d{3})$' - )); + expect(getSecondFormatter()?.placeholder).toBe('2022-AA-###')); }); describe('regex', () => { From 4ae2f218db878506ce3057e825b52c8e64d94cf8 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sun, 7 Jul 2024 00:28:54 +0000 Subject: [PATCH 07/27] Lint code with ESLint and Prettier Triggered by 06b3c1a504edb68e1f46e82cd84eb07355d9b480 on branch refs/heads/field-editor --- .../components/AttachmentsBulkImport/__tests__/utils.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 c3ab8b21a56..5b111d6f55c 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,4 +1,5 @@ -import { LocalizedString } from 'typesafe-i18n'; +import type { LocalizedString } from 'typesafe-i18n'; + import { requireContext } from '../../../tests/helpers'; import { formatterToParser } from '../../../utils/parser/definitions'; import type { IR, RA } from '../../../utils/types'; From 0b5cba02c57124666cc57057fbfe909d379a95b2 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 6 Jul 2024 17:56:10 -0700 Subject: [PATCH 08/27] refactor(FieldFormatters): do a self code review --- .../lib/components/FieldFormatters/Parts.tsx | 10 +++------- .../lib/components/FieldFormatters/Routes.tsx | 18 ++++++++++-------- .../lib/components/FieldFormatters/index.ts | 3 ++- .../lib/components/FieldFormatters/spec.ts | 2 +- .../lib/components/Formatters/Definitions.tsx | 2 +- .../lib/components/Formatters/Element.tsx | 2 +- .../lib/components/Formatters/Fields.tsx | 4 ++-- 7 files changed, 20 insertions(+), 21 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index fbab9f37fa6..86c0558236e 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -35,12 +35,8 @@ export function FieldFormatterParts({ <> {parts.length === 0 ? undefined : (
    {resourcesText.separator()}{schemaText.field()}{schemaText.customFieldFormat()}{resourcesText.type()}{commonText.size()}{resourcesText.hint()}{formsText.autoNumber()}
    + + + + handleChange({ + ...part, + size, + }) + } + isReadOnly={isReadOnly} + // Size is hardcoded to 4 for year + disabled={part.type === 'year'} + /> + + maxLength={placeholderSize} + minLength={placeholderSize} + required + value={part.placeholder} + onValueChange={(placeholder): void => handleChange({ ...part, - separator, + placeholder, }) } /> - - handleChange({ - ...part, - field: fieldMapping, - }), - ]} - openIndex={[openIndex, setOpenIndex]} - table={table} - /> + {part.type === 'numeric' ? ( + + + handleChange({ + ...part, + autoIncrement, + }) + } + /> + {formsText.autoNumber()} + + ) : part.type === 'year' ? ( + + + handleChange({ + ...part, + byYear, + }) + } + /> + {formsText.autoNumberByYear()} + + ) : undefined} - - {isReadOnly ? null : ( ); } - -function FieldFormatterPicker({ - field: [field, handleChange], -}: { - readonly field: GetSet; -}): JSX.Element | null { - const lastField = field.field?.at(-1); - if (lastField === undefined) return null; - else if (!lastField.isRelationship) - return ( - - - handleChange({ - ...field, - fieldFormatter, - }) - } - /> - - ); - else if (relationshipIsToMany(lastField)) - return ( - - - handleChange({ - ...field, - aggregator, - }) - } - /> - - ); - else - return ( - - {resourcesText.formatter()} - - handleChange({ - ...field, - formatter, - }) - } - /> - - ); -} diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx index dbe1c68e99c..5bbab2fb537 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Routes.tsx @@ -23,11 +23,15 @@ export const fieldFormattersRoutes = toReactRoutes([ path: ':tableName', element: async () => import('./List').then( - ({ FieldFormattersList }) => FieldFormattersList + ({ FieldFormatterEditorWrapper }) => FieldFormatterEditorWrapper ), children: [ { index: true, + element: async () => + import('./List').then( + ({ FieldFormattersList }) => FieldFormattersList + ), }, { path: ':index', diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 5257548783c..5b5c09bfd62 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -5,6 +5,8 @@ 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'; @@ -175,7 +177,7 @@ abstract class Part { } public get defaultValue(): LocalizedString { - return this.placeholder === 'YEAR' + return this.placeholder === YearPart.placeholder ? localized(new Date().getFullYear().toString()) : this.placeholder; } @@ -213,16 +215,22 @@ class NumericPart extends Part { public constructor(options: Omit) { super({ ...options, - placeholder: localized(''.padStart(options.size, '#')), + placeholder: NumericPart.buildPlaceholder(options.size), }); } public get regex(): LocalizedString { return localized(`\\d{${this.size}}`); } + + public static buildPlaceholder(size: number): LocalizedString { + return localized(''.padStart(size, '#')); + } } class YearPart extends Part { + public static readonly placeholder = localized('YEAR'); + public readonly type = 'year'; public get regex(): LocalizedString { @@ -297,3 +305,14 @@ export const fieldFormatterTypeMapper = { 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/FormFields/Field.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx index 94a54096946..df0c7d8d10a 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx @@ -117,29 +117,14 @@ function Field({ (field?.isReadOnly === true && !isInSearchDialog); const validationAttributes = getValidationAttributes(parser); - - const [rightAlignNumberFields] = userPreferences.use( - 'form', - 'ui', - 'rightAlignNumberFields' - ); + const rightAlignClassName = useRightAlignClassName(parser.type, isReadOnly); return ( ); } + +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/Formatters/Components.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx index 483068829a6..49f0c30b457 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,17 +198,16 @@ export function ResourceMapping({ }, [mappingPath, sourcePath]); const isReadOnly = React.useContext(ReadOnlyContext); - const lineData = React.useMemo( - () => - getMappingLineData({ - baseTableName: table.name, - mappingPath, - showHiddenFields: true, - generateFieldData: 'all', - spec: navigatorSpecs.formatterEditor, - }), - [table.name, mappingPath] - ); + const lineData = React.useMemo(() => { + const data = getMappingLineData({ + baseTableName: table.name, + mappingPath, + showHiddenFields: true, + generateFieldData: 'all', + spec: navigatorSpecs.formatterEditor, + }); + return typeof fieldFilter === 'function' ? data.map(fieldFilter) : data; + }, [table.name, mappingPath, fieldFilter]); const validation = React.useMemo( () => 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 372f9369d28..7c5ceac76a6 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 { fetchCollection } from '../DataModel/collection'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; @@ -39,7 +40,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: [ @@ -57,6 +59,7 @@ export function useResourcePreview( const [resources, setResources] = getSetResources; const [isOpen, handleOpen, handleClose] = useBooleanState(); + const loading = React.useContext(LoadingContext); return { resources: getSetResources, @@ -64,7 +67,7 @@ export function useResourcePreview(
      {resourcesText.preview()} @@ -109,7 +112,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) + ) + } /> )}
      diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/RelativeDate.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/RelativeDate.tsx index 96754263e12..8223907a6e3 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/localization/forms.ts b/specifyweb/frontend/js_src/lib/localization/forms.ts index b9382efdf03..d128c5fc9d4 100644 --- a/specifyweb/frontend/js_src/lib/localization/forms.ts +++ b/specifyweb/frontend/js_src/lib/localization/forms.ts @@ -915,6 +915,22 @@ export const formsText = createDictionary({ 'uk-ua': 'Автоматична нумерація', 'de-ch': 'Automatische Nummerierung', }, + 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 6ddad842103..561c438d1f1 100644 --- a/specifyweb/frontend/js_src/lib/localization/query.ts +++ b/specifyweb/frontend/js_src/lib/localization/query.ts @@ -742,6 +742,12 @@ export const queryText = createDictionary({ {maxLength:number|formatted} `, }, + tooShortErrorMessage: { + 'en-us': ` + Field value is too short. Min allowed length is + {minLength:number|formatted} + `, + }, future: { 'en-us': 'in the future', 'de-ch': 'Exportdatei wird erstellt', @@ -758,7 +764,7 @@ export const queryText = createDictionary({ 'ru-ru': 'в прошлом', 'uk-ua': 'в минулому', }, - day: { + days: { 'en-us': 'Days', 'es-es': 'Días', 'fr-fr': 'Jours', @@ -766,7 +772,7 @@ export const queryText = createDictionary({ 'uk-ua': 'днів', 'de-ch': 'Tage', }, - week: { + weeks: { 'en-us': 'Weeks', 'de-ch': 'Wochen', 'es-es': 'Semanas', @@ -774,7 +780,7 @@ export const queryText = createDictionary({ 'ru-ru': 'Недели', 'uk-ua': 'тижнів', }, - month: { + months: { 'en-us': 'Months', 'de-ch': 'Monate', 'es-es': 'Meses', @@ -782,7 +788,7 @@ export const queryText = createDictionary({ 'ru-ru': 'Месяцы', 'uk-ua': 'Місяці', }, - year: { + years: { 'en-us': 'Years', 'de-ch': 'Jahre', 'es-es': 'Años', @@ -790,40 +796,36 @@ export const queryText = createDictionary({ 'ru-ru': 'Годы', 'uk-ua': 'років', }, + 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 period of time (ie: day, month or week) then a direction (past or future) `, 'en-us': ` - {size:number} {type:string} {direction:string} - `, 'de-ch': ` - {size:number} {type:string} {direction:string} - `, 'es-es': ` - {size:number} {type:string} {direction:string} - `, 'fr-fr': ` - {size:number} {type:string} {direction:string} - `, 'ru-ru': ` - {size:number} {type:string} {direction:string} - `, 'uk-ua': ` - {size:number} {type:string} {direction:string} - `, }, importHiddenFields: { diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index ff888be9aff..fb58b5f1459 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -867,6 +867,40 @@ export const resourcesText = createDictionary({ nonConformingInline: { 'en-us': '(non-conforming)', }, + hint: { + 'en-us': 'Hint', + 'de-ch': 'Hinweis', + 'es-es': 'Sugerencia', + 'fr-fr': 'Indice', + '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': 'Приклад поле', + }, parentCogSameAsChild: { 'en-us': 'A Collection Object Group cannot be a parent to itself', }, 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)); From 516a51946fd3489dd1122f94808bb08715a33288 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 6 Jul 2024 17:20:36 -0700 Subject: [PATCH 05/27] feat(FieldFormatters): improve visual editor UX --- .../FieldFormatters/FieldFormatter.tsx | 6 +- .../lib/components/FieldFormatters/Parts.tsx | 94 +++++++++++++------ .../lib/components/FieldFormatters/index.ts | 4 +- .../lib/components/FieldFormatters/spec.ts | 5 +- .../frontend/js_src/lib/localization/query.ts | 6 -- .../js_src/lib/localization/resources.ts | 13 ++- 6 files changed, 77 insertions(+), 51 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index 3e41fc15856..62d75cab76d 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -15,7 +15,6 @@ import type { LiteralField } from '../DataModel/specifyField'; import { softError } from '../Errors/assert'; import { ResourceMapping } from '../Formatters/Components'; import { ResourcePreview } from '../Formatters/Preview'; -import { useRightAlignClassName } from '../FormFields/Field'; import { hasTablePermission } from '../Permissions/helpers'; import type { MappingLineData } from '../WbPlanView/navigator'; import type { UiFormatter } from '.'; @@ -34,9 +33,8 @@ export function FieldFormatterElement({ <> setFieldFormatter({ ...fieldFormatter, autoNumber }) } @@ -169,14 +167,12 @@ function FieldFormatterPreviewField({ const parser = useParser(field); const validationAttributes = getValidationAttributes(parser); - const rightAlignClassName = useRightAlignClassName(parser.type, false); return resolvedFormatter === undefined ? null : ( {`${resourcesText.exampleField()} ${ isConforming ? '' : resourcesText.nonConformingInline() }`} {resourcesText.type()}
    {commonText.size()} {resourcesText.hint()}{formsText.autoNumber()}
    handleChange({ ...part, - placeholder, + [part.type === 'regex' ? 'regexPlaceholder' : 'placeholder']: + placeholder, }) } /> @@ -231,6 +223,16 @@ function Part({ /> {formsText.autoNumberByYear()} + ) : part.type === 'regex' ? ( + + handleChange({ + ...part, + placeholder, + }) + } + /> ) : undefined} @@ -248,3 +250,35 @@ function Part({
    )} - {isReadOnly ? null : ( + {isReadOnly ? undefined : (
    @@ -236,7 +232,7 @@ function Part({ ) : undefined}
    - {isReadOnly ? null : ( + {isReadOnly ? undefined : ( import('./List').then( ({ FieldFormattersList }) => FieldFormattersList ), - }, - { - path: ':index', - element: async () => - import('./Element').then( - ({ FieldFormatterWrapper }) => FieldFormatterWrapper - ), + children: [ + { index: true }, + { + path: ':index', + element: async () => + import('./Element').then( + ({ FieldFormatterWrapper }) => FieldFormatterWrapper + ), + }, + ], }, ], }, diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 705550188c2..b3f08fd9a67 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -61,7 +61,8 @@ export function resolveFieldFormatter( ); return new UiFormatter( formatter.isSystem, - formatter.title ?? formatter.name, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + formatter.title || formatter.name, parts, formatter.table, formatter.field diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index 0b825e64384..be312f8c6c2 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -65,7 +65,7 @@ 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. Trying our best here to match the intended semantics for backwards + * two. Doing a best effort match of intended semantics for backwards * compatibility. */ function inferLegacyAutoNumber( diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Definitions.tsx index 03c14ce2985..fe290b3fbf1 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 ? (
    - {isReadOnly ? null : ( + {isReadOnly ? undefined : ( { setItems(removeItem(items, index)); diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx index 2f34a6ca3b2..674a7d217dc 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Fields.tsx @@ -99,7 +99,7 @@ export function Fields({
    )} - {isReadOnly ? null : ( + {isReadOnly ? undefined : (
    @@ -191,7 +191,7 @@ function Field({ )} - {isReadOnly ? null : ( + {isReadOnly ? undefined : ( <> Date: Sat, 6 Jul 2024 18:11:30 -0700 Subject: [PATCH 09/27] feat(SchemaConfig): add link to FieldFormatter VisualEditor --- .../lib/components/SchemaConfig/Format.tsx | 53 +++++++++++++++++++ .../lib/components/SchemaConfig/schemaData.ts | 2 + 2 files changed, 55 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx index c86deed867b..903ff94c6a5 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} @@ -345,3 +353,48 @@ function WebLinkEditing({ ) : null; } + +function FieldFormatterEditing({ + table, + value, + schemaData, +}: { + readonly table: SpecifyTable; + readonly value: string | null; + readonly schemaData: SchemaData; +}): JSX.Element | null { + const index = schemaData.uiFormatters + .filter((formatter) => formatter.tableName === table.name) + .findIndex(({ name }) => name === value); + const resourceId = appResourceIds.UIFormatters; + const navigate = useNavigate(); + if (resourceId === undefined) return null; + + const baseUrl = `/specify/resources/app-resource/${resourceId}/field-formatters/${table.name}/`; + return ( + <> + {typeof index === 'number' && ( + { + event.preventDefault(); + navigate(`${baseUrl}${index}/`); + }} + /> + )} + { + event.preventDefault(); + navigate(baseUrl); + }} + /> + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts index c49584513b1..dc71b52b337 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts @@ -46,6 +46,7 @@ type SimpleFieldFormatter = { readonly isSystem: boolean; readonly value: string; readonly field: LiteralField | undefined; + readonly tableName: keyof Tables | undefined; }; export const fetchSchemaData = async (): Promise => @@ -69,6 +70,7 @@ export const fetchSchemaData = async (): Promise => isSystem: formatter.isSystem, value: formatter.defaultValue, field: formatter.field, + tableName: formatter.table?.name, })) .filter(({ value }) => value) ), From 0587e45b2d8f1562e4aa727082fc64d5f300d94e Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 13 Jul 2024 08:01:23 -0700 Subject: [PATCH 10/27] fix(FieldFormatters): normalize ^ and $ in regex Fixes https://github.com/specify/specify7/pull/5075#pullrequestreview-2163560361 --- .../FieldFormatters/__tests__/spec.test.ts | 23 +++++++++ .../lib/components/FieldFormatters/index.ts | 13 ++++- .../lib/components/FieldFormatters/spec.ts | 49 ++++++++++++++++++- .../lib/localization/utils/scanUsages.ts | 1 + specifyweb/specify/uiformatters.py | 11 ++++- 5 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/FieldFormatters/__tests__/spec.test.ts 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 b3f08fd9a67..1e8c2dbfbc5 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -1,5 +1,5 @@ /** - * Parse and use Specify 6 UI Formatters + * Parse and use field formatters */ import type { LocalizedString } from 'typesafe-i18n'; @@ -259,7 +259,16 @@ class RegexPart extends Part { public readonly type = 'regex'; public get regex(): LocalizedString { - return this.placeholder; + let pattern: string = this.placeholder; + /* + * In UiFormatter.getRegex() we are adding ^ and $ as necessary, so trim + * them if they were present here + */ + 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); + return pattern as LocalizedString; } } diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index be312f8c6c2..a03ac359d05 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -123,11 +123,56 @@ const formatterSpec = f.store(() => ), parts: pipe( syncers.xmlChildren('field'), - syncers.map(syncers.object(partSpec())) + syncers.map( + pipe( + syncers.object(partSpec()), + syncer( + (part) => ({ + ...part, + placeholder: + part.type === 'regex' + ? localized(trimRegexString(part.placeholder)) + : part.placeholder, + }), + (part) => ({ + ...part, + placeholder: + part.type === 'regex' + ? localized(normalizeRegexString(part.placeholder)) + : part.placeholder, + }) + ) + ) + ) ), }) ); +/** + * Specify 6 expects the regex pattern to start with "/^" and end with "$/" + * because it parts 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. + */ +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( @@ -179,3 +224,5 @@ function parseField( return undefined; } else return field; } + +export const exportsForTests = { trimRegexString, normalizeRegexString }; 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/specify/uiformatters.py b/specifyweb/specify/uiformatters.py index 6c73897e473..f24eba0d657 100644 --- a/specifyweb/specify/uiformatters.py +++ b/specifyweb/specify/uiformatters.py @@ -237,7 +237,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: From 61ef5fd03880873e58aa8334c9be53cdb765acf9 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 13 Jul 2024 08:28:15 -0700 Subject: [PATCH 11/27] fix(FieldFormatters): correctly resolve formatter in editor preview --- .../AttachmentsBulkImport/__tests__/utils.test.ts | 4 ++-- .../components/FieldFormatters/FieldFormatter.tsx | 15 ++++++++++++--- .../utils/parser/__tests__/definitions.test.ts | 8 ++++---- .../js_src/lib/utils/parser/definitions.ts | 4 ++-- 4 files changed, 20 insertions(+), 11 deletions(-) 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 5b111d6f55c..4f732a2d772 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,7 +1,7 @@ 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'; @@ -110,7 +110,7 @@ describe('file names resolution test', () => { : syncFieldFormat( field, value.toString(), - formatterToParser(field, formatter), + fieldFormatterToParser(field, formatter), undefined, true ); diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index 62d75cab76d..71eeb0edb78 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { useParser } from '../../hooks/resource'; import { formsText } from '../../localization/forms'; import { resourcesText } from '../../localization/resources'; import { schemaText } from '../../localization/schema'; -import { getValidationAttributes } from '../../utils/parser/definitions'; +import { + fieldFormatterToParser, + getValidationAttributes, +} from '../../utils/parser/definitions'; import type { GetSet, RA } from '../../utils/types'; import { ErrorMessage } from '../Atoms'; import { Input, Label } from '../Atoms/Form'; @@ -164,7 +166,13 @@ function FieldFormatterPreviewField({ () => resolvedFormatter?.parse(value) !== undefined, [value, resolvedFormatter] ); - const parser = useParser(field); + 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 : ( @@ -173,6 +181,7 @@ function FieldFormatterPreviewField({ isConforming ? '' : resourcesText.nonConformingInline() }`} { ...parserFromType('java.lang.String'), required: false, ...removeKey( - formatterToParser(field, uiFormatter), + fieldFormatterToParser(field, uiFormatter), 'formatters', 'parser', 'validators' @@ -238,7 +238,7 @@ describe('formatterToParser', () => { validators, parser: parserFunction, ...parser - } = formatterToParser({}, uiFormatter); + } = fieldFormatterToParser({}, uiFormatter); expect(parser).toEqual({ pattern: uiFormatter.regex, title, @@ -270,7 +270,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/definitions.ts b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts index 83e2c01b7a2..f612701d5d3 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts @@ -302,7 +302,7 @@ export function resolveParser( ? undefined : (fullField as LiteralField).whiteSpaceSensitive, ...(typeof formatter === 'object' - ? formatterToParser(field, formatter) + ? fieldFormatterToParser(field, formatter) : {}), }); } @@ -367,7 +367,7 @@ function resolveDate( return callback(...(values as RA)); } -export function formatterToParser( +export function fieldFormatterToParser( field: Partial, formatter: UiFormatter ): Parser { From 1bdece9848e4ca1bcdae08d98bcad3a3adbdd257 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 13 Jul 2024 08:44:29 -0700 Subject: [PATCH 12/27] fix(FieldFormatters): don't expose internal autoNumbering state in UI --- .../components/FieldFormatters/FieldFormatter.tsx | 15 +-------------- .../lib/components/FieldFormatters/List.tsx | 1 - .../js_src/lib/components/FieldFormatters/spec.ts | 9 ++++++--- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index 71eeb0edb78..40a1943ae50 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { formsText } from '../../localization/forms'; import { resourcesText } from '../../localization/resources'; import { schemaText } from '../../localization/schema'; import { @@ -10,7 +9,6 @@ import { import type { GetSet, RA } from '../../utils/types'; import { ErrorMessage } from '../Atoms'; import { Input, Label } from '../Atoms/Form'; -import { ReadOnlyContext } from '../Core/Contexts'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; import type { LiteralField } from '../DataModel/specifyField'; @@ -29,20 +27,9 @@ export function FieldFormatterElement({ }: { readonly item: GetSet; }): JSX.Element { - const [fieldFormatter, setFieldFormatter] = item; - const isReadOnly = React.useContext(ReadOnlyContext); + const [fieldFormatter] = item; return ( <> - - - setFieldFormatter({ ...fieldFormatter, autoNumber }) - } - /> - {formsText.autoNumbering()} - {fieldFormatter.external === undefined && typeof fieldFormatter.table === 'object' ? ( diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx index a050d57b641..5c497d18cfd 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/List.tsx @@ -62,7 +62,6 @@ export function FieldFormattersList(): JSX.Element { isDefault: currentItems.length === 0, legacyType: undefined, legacyPartialDate: undefined, - autoNumber: false, external: undefined, parts: [], raw: { diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index a03ac359d05..af006add80d 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -21,7 +21,6 @@ export const fieldFormattersSpec = f.store(() => ({ javaClass, rawAutoNumber, ...formatter }) => ({ ...formatter, table: getTable(javaClass ?? ''), - autoNumber: rawAutoNumber !== undefined, raw: { javaClass, legacyAutoNumber: rawAutoNumber, @@ -29,7 +28,6 @@ export const fieldFormattersSpec = f.store(() => }), ({ table, - autoNumber, raw: { javaClass, legacyAutoNumber }, ...formatter }) => ({ @@ -40,7 +38,7 @@ export const fieldFormattersSpec = f.store(() => (getTable(javaClass ?? '') === undefined ? javaClass : undefined), - rawAutoNumber: autoNumber + rawAutoNumber: formatter.parts.some(isAutoNumbering) ? legacyAutoNumber ?? inferLegacyAutoNumber(table, formatter.parts) : undefined, @@ -84,6 +82,11 @@ function inferLegacyAutoNumber( } 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 >['fieldFormatters'][number]; From a118d308b5828e91d302d7451390a42170b26d0e Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 13 Jul 2024 15:49:32 +0000 Subject: [PATCH 13/27] Lint code with ESLint and Prettier Triggered by b4736f8e5a8d44c7db10e34e8e867de7142dffe6 on branch refs/heads/field-editor --- .../js_src/lib/components/FormMeta/CarryForward.tsx | 10 +++++----- .../frontend/js_src/lib/components/Forms/Save.tsx | 3 +-- .../lib/utils/parser/__tests__/definitions.test.ts | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx index 445594a3861..675486695b2 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/CarryForward.tsx @@ -204,11 +204,11 @@ export const tableValidForBulkClone = (table: SpecifyTable): boolean => !( tables.CollectionObject.strictGetLiteralField('catalogNumber') .getUiFormatter() - ?.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 === 'numeric' && !parts.canAutonumber()) ) ?? false ); diff --git a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx index c7f2f14ddad..f20450395de 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/Save.tsx @@ -272,7 +272,6 @@ export function SaveButton({ tables.CollectionObject.strictGetLiteralField( 'catalogNumber' ).getUiFormatter()!; - const wildCard = formatter.valueOrWild(); const clonePromises = Array.from( { length: carryForwardAmount }, @@ -283,7 +282,7 @@ export function SaveButton({ ); clonedResource.set( 'catalogNumber', - wildCard as never + formatter.defaultValue as never ); return clonedResource; } 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 a40183509ab..c98947a8759 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 @@ -18,8 +18,8 @@ import { removeKey } from '../../utils'; import type { Parser } from '../definitions'; import { browserifyRegex, - formatter, fieldFormatterToParser, + formatter, getValidationAttributes, lengthToRegex, mergeParsers, From 7978624c2955ed79880a155f5b1d6a3fb8a1516b Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sun, 21 Jul 2024 11:46:28 -0700 Subject: [PATCH 14/27] fix(FieldFormatters): resolve minor UX issues Fixes https://github.com/specify/specify7/pull/5075#pullrequestreview-2189080837 --- .../lib/components/FieldFormatters/Parts.tsx | 4 ++-- .../frontend/js_src/lib/localization/resources.ts | 14 +------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index 86c0558236e..4a6661f8e8e 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -159,7 +159,7 @@ function Part({ aria-label={commonText.size()} disabled={enforcePlaceholderSize} isReadOnly={isReadOnly} - min={0} + min={1} required value={part.size} onValueChange={(size): void => @@ -173,7 +173,7 @@ function Part({ Date: Mon, 29 Jul 2024 17:27:48 -0700 Subject: [PATCH 15/27] fix(FieldFormatters): fix schema config indexes being off Fixes https://github.com/specify/specify7/pull/5075#pullrequestreview-2194509397 --- .../FieldFormatters/FieldFormatter.tsx | 2 +- .../__snapshots__/index.test.ts.snap | 24 +++++++++++++++++++ .../lib/components/FieldFormatters/index.ts | 13 ++++++---- .../lib/components/SchemaConfig/Format.tsx | 6 ++--- .../lib/components/SchemaConfig/schemaData.ts | 2 ++ 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index 40a1943ae50..ddf760e6927 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -95,7 +95,7 @@ function FieldFormatterPreview({ readonly fieldFormatter: FieldFormatter; }): JSX.Element | null { const resolvedFormatter = React.useMemo( - () => resolveFieldFormatter(fieldFormatter), + () => resolveFieldFormatter(fieldFormatter, 0), [fieldFormatter] ); const doFormatting = React.useCallback( 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 ac849aacaca..126c6ca2946 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 @@ -5,6 +5,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "AccessionNumber": UiFormatter { "field": "[literalField Accession.accessionNumber]", "isSystem": true, + "originalIndex": 0, "parts": [ YearPart { "autoIncrement": false, @@ -53,6 +54,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "AccessionNumberByYear": UiFormatter { "field": "[literalField Accession.accessionNumber]", "isSystem": true, + "originalIndex": 1, "parts": [ YearPart { "autoIncrement": false, @@ -101,6 +103,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "AccessionStringFormatter": UiFormatter { "field": "[literalField Accession.accessionNumber]", "isSystem": true, + "originalIndex": 2, "parts": [ AlphaNumberPart { "autoIncrement": false, @@ -117,6 +120,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "CatalogNumber": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", "isSystem": false, + "originalIndex": 3, "parts": [ YearPart { "autoIncrement": false, @@ -149,6 +153,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "CatalogNumberAlphaNumByYear": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", "isSystem": false, + "originalIndex": 5, "parts": [ YearPart { "autoIncrement": false, @@ -181,6 +186,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "CatalogNumberNumeric": CatalogNumberNumeric { "field": "[literalField CollectionObject.catalogNumber]", "isSystem": true, + "originalIndex": 0, "parts": [ CatalogNumberNumericField { "autoIncrement": true, @@ -197,6 +203,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "CatalogNumberNumericRegex": UiFormatter { "field": "[literalField CollectionObject.catalogNumber]", "isSystem": false, + "originalIndex": 4, "parts": [ RegexPart { "autoIncrement": false, @@ -213,6 +220,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "Date": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 8, "parts": [], "table": undefined, "title": "Date", @@ -220,6 +228,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "DeaccessionNumber": UiFormatter { "field": "[literalField Deaccession.deaccessionNumber]", "isSystem": true, + "originalIndex": 9, "parts": [ YearPart { "autoIncrement": false, @@ -268,6 +277,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "GiftNumber": UiFormatter { "field": "[literalField Gift.giftNumber]", "isSystem": true, + "originalIndex": 10, "parts": [ YearPart { "autoIncrement": false, @@ -300,6 +310,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "InfoRequestNumber": UiFormatter { "field": "[literalField InfoRequest.infoReqNumber]", "isSystem": true, + "originalIndex": 11, "parts": [ YearPart { "autoIncrement": false, @@ -332,6 +343,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "KUITeach": CatalogNumberNumeric { "field": "[literalField CollectionObject.catalogNumber]", "isSystem": true, + "originalIndex": 0, "parts": [ CatalogNumberNumericField { "autoIncrement": true, @@ -348,6 +360,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "LoanNumber": UiFormatter { "field": "[literalField Loan.loanNumber]", "isSystem": true, + "originalIndex": 13, "parts": [ YearPart { "autoIncrement": false, @@ -380,6 +393,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "NumericBigDecimal": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 14, "parts": [ NumericPart { "autoIncrement": false, @@ -396,6 +410,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "NumericByte": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 15, "parts": [ NumericPart { "autoIncrement": false, @@ -412,6 +427,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "NumericDouble": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 16, "parts": [ NumericPart { "autoIncrement": false, @@ -428,6 +444,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "NumericFloat": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 17, "parts": [ NumericPart { "autoIncrement": false, @@ -444,6 +461,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "NumericInteger": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 18, "parts": [ NumericPart { "autoIncrement": false, @@ -460,6 +478,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "NumericLong": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 19, "parts": [ NumericPart { "autoIncrement": false, @@ -476,6 +495,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "NumericShort": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 20, "parts": [ NumericPart { "autoIncrement": false, @@ -492,6 +512,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "PartialDate": UiFormatter { "field": undefined, "isSystem": false, + "originalIndex": 21, "parts": [], "table": undefined, "title": "PartialDate", @@ -499,6 +520,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "PartialDateMonth": UiFormatter { "field": undefined, "isSystem": false, + "originalIndex": 22, "parts": [], "table": undefined, "title": "PartialDateMonth", @@ -506,6 +528,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "PartialDateYear": UiFormatter { "field": undefined, "isSystem": false, + "originalIndex": 23, "parts": [], "table": undefined, "title": "PartialDateYear", @@ -513,6 +536,7 @@ exports[`field formatters are fetched and parsed correctly 1`] = ` "SearchDate": UiFormatter { "field": undefined, "isSystem": true, + "originalIndex": 24, "parts": [], "table": undefined, "title": "SearchDate", diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 1e8c2dbfbc5..24880d35e8d 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -29,8 +29,8 @@ export const fetchContext = Promise.all([ uiFormatters = Object.fromEntries( filterArray( xmlToSpec(formatters, fieldFormattersSpec()).fieldFormatters.map( - (formatter) => { - const resolvedFormatter = resolveFieldFormatter(formatter); + (formatter, index) => { + const resolvedFormatter = resolveFieldFormatter(formatter, index); return resolvedFormatter === undefined ? undefined : [formatter.name, resolvedFormatter]; @@ -44,7 +44,8 @@ export const getUiFormatters = (): typeof uiFormatters => uiFormatters ?? error('Tried to access UI formatters before fetching them'); export function resolveFieldFormatter( - formatter: FieldFormatter + formatter: FieldFormatter, + index: number ): UiFormatter | undefined { if (typeof formatter.external === 'string') { return parseJavaClassName(formatter.external) === @@ -65,7 +66,8 @@ export function resolveFieldFormatter( formatter.title || formatter.name, parts, formatter.table, - formatter.field + formatter.field, + index ); } } @@ -78,7 +80,8 @@ export class UiFormatter { public readonly parts: RA, public readonly table: SpecifyTable | undefined, // The field which this formatter is formatting - public readonly field: LiteralField | undefined + public readonly field: LiteralField | undefined, + public readonly originalIndex = 0 ) {} public get defaultValue(): string { diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx index 903ff94c6a5..26bf0f7aefb 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx @@ -363,9 +363,9 @@ function FieldFormatterEditing({ readonly value: string | null; readonly schemaData: SchemaData; }): JSX.Element | null { - const index = schemaData.uiFormatters - .filter((formatter) => formatter.tableName === table.name) - .findIndex(({ name }) => name === value); + const index = schemaData.uiFormatters.find( + ({ name }) => name === value + )?.index; const resourceId = appResourceIds.UIFormatters; const navigate = useNavigate(); if (resourceId === undefined) return null; diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts index dc71b52b337..42120bd4698 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts @@ -47,6 +47,7 @@ type SimpleFieldFormatter = { readonly value: string; readonly field: LiteralField | undefined; readonly tableName: keyof Tables | undefined; + readonly index: number; }; export const fetchSchemaData = async (): Promise => @@ -71,6 +72,7 @@ export const fetchSchemaData = async (): Promise => value: formatter.defaultValue, field: formatter.field, tableName: formatter.table?.name, + index: formatter.originalIndex, })) .filter(({ value }) => value) ), From f7a6b3ecc3502806282eeafe5e33458893786677 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Wed, 27 Nov 2024 10:24:12 -0800 Subject: [PATCH 16/27] fix(FieldFormatters): don't trigger "Save" without user changes Fixes https://github.com/specify/specify7/pull/5075#pullrequestreview-2219847360 I should have known better than to use `useEffect` for this. Moved the logic to a more appropriate lace. --- .../lib/components/FieldFormatters/Parts.tsx | 42 ++++--------------- .../lib/components/FieldFormatters/index.ts | 2 +- .../lib/components/FieldFormatters/spec.ts | 21 ++++++++++ 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index 4a6661f8e8e..2985dddf611 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -15,6 +15,7 @@ import { icons } from '../Atoms/Icons'; import { ReadOnlyContext } from '../Core/Contexts'; import { fieldFormatterLocalization, fieldFormatterTypeMapper } from '.'; import type { FieldFormatter, FieldFormatterPart } from './spec'; +import { fieldFormatterTypesWithForcedSize } from './spec'; export function FieldFormatterParts({ fieldFormatter: [fieldFormatter, setFieldFormatter], @@ -98,40 +99,9 @@ function Part({ }): JSX.Element { const isReadOnly = React.useContext(ReadOnlyContext); - React.useEffect(() => { - if (part.type === 'year') - handleChange({ - ...part, - size: 4, - placeholder: fieldFormatterTypeMapper.year.placeholder, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [part.type]); - - React.useEffect(() => { - if (part.type === 'numeric') - handleChange({ - ...part, - placeholder: fieldFormatterTypeMapper.numeric.buildPlaceholder( - part.size ?? 1 - ), - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [part.size, part.type]); - - const enforcePlaceholderSize = - part.type === 'constant' || - part.type === 'separator' || - part.type === 'year'; - - React.useEffect(() => { - if (enforcePlaceholderSize) - handleChange({ - ...part, - size: part.placeholder.length, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [part.placeholder, enforcePlaceholderSize]); + const enforcePlaceholderSize = fieldFormatterTypesWithForcedSize.has( + part.type as 'constant' + ); return ( @@ -166,6 +136,10 @@ function Part({ handleChange({ ...part, size, + placeholder: + part.type === 'numeric' + ? fieldFormatterTypeMapper.numeric.buildPlaceholder(size) + : part.placeholder, }) } /> diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 24880d35e8d..def5e373178 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -227,7 +227,7 @@ class NumericPart extends Part { return localized(`\\d{${this.size}}`); } - public static buildPlaceholder(size: number): LocalizedString { + public static buildPlaceholder(size = 1): LocalizedString { return localized(''.padStart(size, '#')); } } diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index af006add80d..98fff2f444a 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -135,6 +135,10 @@ const formatterSpec = f.store(() => placeholder: part.type === 'regex' ? localized(trimRegexString(part.placeholder)) + : part.type === 'year' + ? fieldFormatterTypeMapper.year.placeholder + : part.type === 'numeric' + ? fieldFormatterTypeMapper.numeric.buildPlaceholder(part.size) : part.placeholder, }), (part) => ({ @@ -144,6 +148,17 @@ const formatterSpec = f.store(() => ? localized(normalizeRegexString(part.placeholder)) : part.placeholder, }) + ), + syncer( + (part) => ({ + ...part, + size: fieldFormatterTypesWithForcedSize.has( + part.type as 'constant' + ) + ? part.placeholder.length + : part.size, + }), + (part) => part ) ) ) @@ -151,6 +166,12 @@ const formatterSpec = f.store(() => }) ); +export const fieldFormatterTypesWithForcedSize = new Set([ + 'constant', + 'separator', + 'year', +] as const); + /** * Specify 6 expects the regex pattern to start with "/^" and end with "$/" * because it parts each field part individually. From 7ac3dedb0528341920127d813ffefb2ceecdc479 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Wed, 27 Nov 2024 09:50:34 -0800 Subject: [PATCH 17/27] docs(localization): fix __init__.py file name typo --- specifyweb/frontend/js_src/lib/localization/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/localization/README.md b/specifyweb/frontend/js_src/lib/localization/README.md index 6297d1abc86..104a010090d 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 `production` branch (weblate is setup to only look at that branch). From 341915db58b3b39814a0c9ff82db4748a75a8659 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 14 Dec 2024 09:14:15 -0800 Subject: [PATCH 18/27] refactor(FieldFormatters): deduplicate formatter normalization logic --- .../lib/components/FieldFormatters/Parts.tsx | 43 +++++++------- .../lib/components/FieldFormatters/spec.ts | 57 ++++++++----------- .../lib/components/SchemaConfig/Format.tsx | 4 +- .../js_src/lib/localization/schema.ts | 2 +- 4 files changed, 52 insertions(+), 54 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index 2985dddf611..78f7d698c78 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -13,9 +13,12 @@ import { className } from '../Atoms/className'; import { Input, Label, Select } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; import { ReadOnlyContext } from '../Core/Contexts'; -import { fieldFormatterLocalization, fieldFormatterTypeMapper } from '.'; +import { fieldFormatterLocalization } from '.'; import type { FieldFormatter, FieldFormatterPart } from './spec'; -import { fieldFormatterTypesWithForcedSize } from './spec'; +import { + fieldFormatterTypesWithForcedSize, + normalizeFieldFormatterPart, +} from './spec'; export function FieldFormatterParts({ fieldFormatter: [fieldFormatter, setFieldFormatter], @@ -111,10 +114,12 @@ function Part({ disabled={isReadOnly} value={part.type ?? 'constant'} onValueChange={(newType): void => - handleChange({ - ...part, - type: newType as keyof typeof fieldFormatterLocalization, - }) + handleChange( + normalizeFieldFormatterPart({ + ...part, + type: newType as keyof typeof fieldFormatterLocalization, + }) + ) } > {Object.entries(fieldFormatterLocalization).map(([type, label]) => ( @@ -133,14 +138,12 @@ function Part({ required value={part.size} onValueChange={(size): void => - handleChange({ - ...part, - size, - placeholder: - part.type === 'numeric' - ? fieldFormatterTypeMapper.numeric.buildPlaceholder(size) - : part.placeholder, - }) + handleChange( + normalizeFieldFormatterPart({ + ...part, + size, + }) + ) } /> @@ -156,11 +159,13 @@ function Part({ : part.placeholder } onValueChange={(placeholder): void => - handleChange({ - ...part, - [part.type === 'regex' ? 'regexPlaceholder' : 'placeholder']: - placeholder, - }) + handleChange( + normalizeFieldFormatterPart({ + ...part, + [part.type === 'regex' ? 'regexPlaceholder' : 'placeholder']: + placeholder, + }) + ) } /> diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index 98fff2f444a..9aaaea6efc7 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -129,43 +129,36 @@ const formatterSpec = f.store(() => syncers.map( pipe( syncers.object(partSpec()), - syncer( - (part) => ({ - ...part, - placeholder: - part.type === 'regex' - ? localized(trimRegexString(part.placeholder)) - : part.type === 'year' - ? fieldFormatterTypeMapper.year.placeholder - : part.type === 'numeric' - ? fieldFormatterTypeMapper.numeric.buildPlaceholder(part.size) - : part.placeholder, - }), - (part) => ({ - ...part, - placeholder: - part.type === 'regex' - ? localized(normalizeRegexString(part.placeholder)) - : part.placeholder, - }) - ), - syncer( - (part) => ({ - ...part, - size: fieldFormatterTypesWithForcedSize.has( - part.type as 'constant' - ) - ? part.placeholder.length - : part.size, - }), - (part) => part - ) + syncer(normalizeFieldFormatterPart, (part) => ({ + ...part, + placeholder: + part.type === 'regex' + ? localized(normalizeRegexString(part.placeholder)) + : part.placeholder, + })) ) ) ), }) ); +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', @@ -174,7 +167,7 @@ export const fieldFormatterTypesWithForcedSize = new Set([ /** * Specify 6 expects the regex pattern to start with "/^" and end with "$/" - * because it parts each field part individually. + * 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 diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx index 26bf0f7aefb..954551f9742 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Format.tsx @@ -88,7 +88,7 @@ export function SchemaConfigFormat({ [`${ field === undefined ? '' - : schemaText.uiFormattersForField({ fieldLabel: field.label }) + : schemaText.fieldFormattersForField({ fieldLabel: field.label }) }`]: formattersForField .map( ({ name, isSystem, value }) => @@ -105,7 +105,7 @@ export function SchemaConfigFormat({ ] as const ) .sort(sortFunction((value) => value[1])), - [resourcesText.uiFormatters()]: otherFormatters + [resourcesText.fieldFormatters()]: otherFormatters .map( ({ name, isSystem, value }) => [ diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 0836fa2df7a..0dd3b6ff0b6 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -209,7 +209,7 @@ export const schemaText = createDictionary({ 'uk-ua': 'Формат поля', 'de-ch': 'Feldformat', }, - uiFormattersForField: { + fieldFormattersForField: { 'en-us': 'Field Formatters for {fieldLabel:string}', }, formatted: { From 93eefcad6bed395c4f6e4d0efc73766182586269 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 14 Dec 2024 09:59:07 -0800 Subject: [PATCH 19/27] refactor(FieldFormatters): remove duplidate regex normalization logic --- .../js_src/lib/components/FieldFormatters/index.ts | 9 ++------- .../js_src/lib/components/FieldFormatters/spec.ts | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index def5e373178..c419863af10 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -19,7 +19,7 @@ import { error } from '../Errors/assert'; import { load } from '../InitialContext'; import { xmlToSpec } from '../Syncer/xmlUtils'; import type { FieldFormatter, FieldFormatterPart } from './spec'; -import { fieldFormattersSpec } from './spec'; +import { fieldFormattersSpec, trimRegexString } from './spec'; let uiFormatters: IR; export const fetchContext = Promise.all([ @@ -262,16 +262,11 @@ class RegexPart extends Part { public readonly type = 'regex'; public get regex(): LocalizedString { - let pattern: string = this.placeholder; /* * In UiFormatter.getRegex() we are adding ^ and $ as necessary, so trim * them if they were present here */ - 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); - return pattern as LocalizedString; + return trimRegexString(this.placeholder) as LocalizedString; } } diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts index 9aaaea6efc7..028455f853a 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/spec.ts @@ -174,7 +174,7 @@ export const fieldFormatterTypesWithForcedSize = new Set([ * Specify 7 front-end is concerned, but we want it to be part of the pattern * in the .xml to work with Specify 6. */ -function trimRegexString(regexString: string): string { +export function trimRegexString(regexString: string): string { let pattern = regexString; if (pattern.startsWith('/')) pattern = pattern.slice(1); if (pattern.startsWith('^')) pattern = pattern.slice(1); From d2dcc58a55be39365e772a7c8a05ffecace0a110 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 14 Dec 2024 09:59:29 -0800 Subject: [PATCH 20/27] fix(FieldFormatters): prevent CustomElementSelect not closing on click --- .../js_src/lib/components/FieldFormatters/FieldFormatter.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index ddf760e6927..47dfc8c7620 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -8,6 +8,7 @@ import { } 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'; @@ -53,7 +54,7 @@ function FieldPicker({ [fieldFormatter.field] ); return fieldFormatter.table === undefined ? null : ( - +
    {schemaText.field()} - +
    ); } From c4318aa082333ff6ad144b3b294c12d735c0b949 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 14 Dec 2024 10:02:23 -0800 Subject: [PATCH 21/27] fix(FieldFormatters): set max field size at 9999 --- .../frontend/js_src/lib/components/FieldFormatters/Parts.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index 78f7d698c78..e74dc66ad55 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -134,6 +134,7 @@ function Part({ aria-label={commonText.size()} disabled={enforcePlaceholderSize} isReadOnly={isReadOnly} + max={maxSize} min={1} required value={part.size} @@ -226,6 +227,8 @@ function Part({ ); } +const maxSize = 9999; + function RegexField({ value, onChange: handleChange, From 52365e15303f0d2f59df6b238119cefda396e431 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sat, 14 Dec 2024 10:16:45 -0800 Subject: [PATCH 22/27] revert(localization): delete unused string --- specifyweb/frontend/js_src/lib/localization/resources.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index b42e44fb698..ff72f5f2734 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -892,7 +892,4 @@ export const resourcesText = createDictionary({ pattern: { 'en-us': 'Pattern', }, - parentCogSameAsChild: { - 'en-us': 'A Collection Object Group cannot be a parent to itself', - }, } as const); From b7d3cb0c8dd1c5a65564001a7e9a18def4983a18 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Wed, 25 Dec 2024 15:03:08 -0800 Subject: [PATCH 23/27] fix(FieldFormatter): resolve reported issues Fixes https://github.com/specify/specify7/pull/5075#pullrequestreview-2506791959 --- .../js_src/lib/components/Atoms/Form.tsx | 1 + .../FieldFormatters/FieldFormatter.tsx | 17 +++++++--- .../lib/components/FieldFormatters/Parts.tsx | 6 ++-- .../lib/components/QueryBuilder/Line.tsx | 8 +++-- .../lib/components/SchemaConfig/Format.tsx | 31 ++++++++++--------- .../SchemaConfig/UniquenessRuleScope.tsx | 6 ++-- .../components/WbPlanView/LineComponents.tsx | 4 +-- .../WbPlanView/MapperComponents.tsx | 4 +-- .../lib/components/WbPlanView/navigator.ts | 4 +-- .../js_src/lib/localization/resources.ts | 20 ++++++------ 10 files changed, 58 insertions(+), 43 deletions(-) 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/FieldFormatters/FieldFormatter.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx index 47dfc8c7620..87627115e0f 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/FieldFormatter.tsx @@ -12,7 +12,8 @@ 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 } from '../DataModel/specifyField'; +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'; @@ -84,9 +85,17 @@ function FieldPicker({ const excludeNonLiteral = (mappingData: MappingLineData): MappingLineData => ({ ...mappingData, fieldsData: Object.fromEntries( - Object.entries(mappingData.fieldsData).filter( - ([_name, fieldData]) => fieldData.tableName === undefined - ) + 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 + ); + }) ), }); diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index e74dc66ad55..84d2d7cf6f2 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -49,7 +49,7 @@ export function FieldFormatterParts({ {resourcesText.type()} {commonText.size()} - {resourcesText.hint()} + {resourcesText.value()} @@ -150,7 +150,7 @@ function Part({ handleFormatted( name, + // Select first option if there is such typeof values === 'object' - ? (Object.values(values)[0][0][0]! ?? null) + ? (Object.values(values).at(0)?.at(0)?.at(0) ?? + Object.values(values).at(1)?.at(0)?.at(0) ?? + null) : null ) } @@ -312,6 +315,9 @@ function PickListEditing({ ); } +const overlayPrefix = '/specify/overlay/resources/app-resource/'; +const fullScreenPrefix = '/specify/resources/app-resource/'; + function WebLinkEditing({ value, schemaData, @@ -322,32 +328,29 @@ 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`); }} /> @@ -370,29 +373,29 @@ function FieldFormatterEditing({ const navigate = useNavigate(); if (resourceId === undefined) return null; - const baseUrl = `/specify/resources/app-resource/${resourceId}/field-formatters/${table.name}/`; + const commonUrl = `${resourceId}/field-formatters/${table.name}`; return ( <> {typeof index === 'number' && ( { event.preventDefault(); - navigate(`${baseUrl}${index}/`); + navigate(`${overlayPrefix}${commonUrl}/${index}`); }} /> )} { event.preventDefault(); - navigate(baseUrl); + 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/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 80f0044c554..82ff6749b18 100644 --- a/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbPlanView/MapperComponents.tsx @@ -19,7 +19,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'; @@ -230,7 +230,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 1dd5a3291a1..4c8ad3a5d9d 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'; @@ -284,7 +284,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/localization/resources.ts b/specifyweb/frontend/js_src/lib/localization/resources.ts index ff72f5f2734..e4174d726b3 100644 --- a/specifyweb/frontend/js_src/lib/localization/resources.ts +++ b/specifyweb/frontend/js_src/lib/localization/resources.ts @@ -155,9 +155,9 @@ export const resourcesText = createDictionary({ }, fieldFormattersDescription: { 'en-us': ` - Field formatter controls how data for a specific table field is - shown in query results, exports, and on the form. It determines - autonumbering and individual parts that make up the field. + 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: { @@ -855,13 +855,13 @@ export const resourcesText = createDictionary({ nonConformingInline: { 'en-us': '(non-conforming)', }, - hint: { - 'en-us': 'Hint', - 'de-ch': 'Hinweis', - 'es-es': 'Sugerencia', - 'fr-fr': 'Indice', - 'ru-ru': 'Подсказка', - 'uk-ua': 'Підказка', + value: { + 'en-us': 'Value', + 'de-ch': 'Wert', + 'es-es': 'Valor', + 'fr-fr': 'Valeur', + 'ru-ru': 'Значение', + 'uk-ua': 'Значення', }, constant: { 'en-us': 'Constant', From 3d4b5113a38df4d5ad3fb9a78ad8b7c4df6a0c6d Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sun, 19 Jan 2025 19:56:04 -0800 Subject: [PATCH 24/27] fix(FieldFormatters): clear stale regex validation error --- .../frontend/js_src/lib/components/FieldFormatters/Parts.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index 84d2d7cf6f2..f9e1753b866 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -156,7 +156,7 @@ function Part({ required value={ part.type === 'regex' - ? part.regexPlaceholder ?? '' + ? (part.regexPlaceholder ?? '') : part.placeholder } onValueChange={(placeholder): void => @@ -251,6 +251,7 @@ function RegexField({ // 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(); From b95c145a07a6322d539d5683afca9dda2afd178b Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sun, 19 Jan 2025 20:07:12 -0800 Subject: [PATCH 25/27] fix(FieldFormatters): disable auto-numering in UI where not allowed by back-end --- .../frontend/js_src/lib/components/DataModel/specifyTable.ts | 2 +- .../frontend/js_src/lib/components/FieldFormatters/Parts.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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/Parts.tsx b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx index f9e1753b866..634d135b81d 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/Parts.tsx @@ -13,6 +13,7 @@ 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 { @@ -62,6 +63,7 @@ export function FieldFormatterParts({ part, (part): void => setParts(replaceItem(parts, index, part)), ]} + table={fieldFormatter.table} onRemove={(): void => setParts(removeItem(parts, index))} /> ))} @@ -96,9 +98,11 @@ export function FieldFormatterParts({ 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); @@ -175,6 +179,7 @@ function Part({ handleChange({ From c8acc261f884e0acdca4d08cf241c2bee41f8b59 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Sun, 26 Jan 2025 16:53:25 -0800 Subject: [PATCH 26/27] fix(FieldFormatters): force-wrap long preview lines --- .../frontend/js_src/lib/components/Formatters/Preview.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx index 7c5ceac76a6..f6ee996461f 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Preview.tsx @@ -97,7 +97,9 @@ export function useResourcePreview( setResources(removeItem(resources, index)), }} /> - {output} + + {output} +
    ); }) From 37ca347b4918629e23bf3526fdd3214bacd8fa53 Mon Sep 17 00:00:00 2001 From: Max Patiiuk Date: Mon, 27 Jan 2025 01:00:45 +0000 Subject: [PATCH 27/27] Lint code with ESLint and Prettier Triggered by c8acc261f884e0acdca4d08cf241c2bee41f8b59 on branch refs/heads/field-editor --- .../js_src/lib/components/RouterCommands/SwitchCollection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/RouterCommands/SwitchCollection.tsx b/specifyweb/frontend/js_src/lib/components/RouterCommands/SwitchCollection.tsx index aeb54a2407e..ef201ec09d8 100644 --- a/specifyweb/frontend/js_src/lib/components/RouterCommands/SwitchCollection.tsx +++ b/specifyweb/frontend/js_src/lib/components/RouterCommands/SwitchCollection.tsx @@ -42,8 +42,8 @@ export function SwitchCollectionCommand(): null { body: collectionId!.toString(), errorMode: 'dismissible', }) - .then(clearAllCache) - .then(() => globalThis.location.replace(nextUrl)), + .then(clearAllCache) + .then(() => globalThis.location.replace(nextUrl)), [collectionId, nextUrl] ), true