diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Formatter.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Formatter.tsx index 8afd30e2c02..81d8cda9fd6 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Formatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Formatter.tsx @@ -1,18 +1,29 @@ import React from 'react'; -import { usePromise } from '../../hooks/useAsyncState'; +import { useAsyncState, usePromise } from '../../hooks/useAsyncState'; import { useId } from '../../hooks/useId'; import { commonText } from '../../localization/common'; import { queryText } from '../../localization/query'; import { resourcesText } from '../../localization/resources'; +import type { RA } from '../../utils/types'; +import { filterArray } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Select } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; -import type { Tables } from '../DataModel/types'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { resourceFromUrl } from '../DataModel/resource'; +import { fetchContext as fetchDomain, schema } from '../DataModel/schema'; +import type { CollectionObjectType, Tables } from '../DataModel/types'; import { fetchFormatters } from '../Formatters/formatters'; import { customSelectElementBackground } from '../WbPlanView/CustomSelectElement'; -export function QueryFieldFormatter({ +type SimpleFormatter = { + readonly name: string; + readonly title: string; + readonly isDefault: boolean; +}; + +export function QueryFieldRecordFormatter({ type, tableName, formatter, @@ -24,7 +35,7 @@ export function QueryFieldFormatter({ readonly onChange: ((formatter: string | undefined) => void) | undefined; }): JSX.Element | null { const [formatters] = usePromise(fetchFormatters, false); - const availableFormatters = React.useMemo( + const availableFormatters = React.useMemo | undefined>( () => // Some code duplication, but required by TypeScript (type === 'formatter' @@ -50,24 +61,103 @@ export function QueryFieldFormatter({ [type, formatters, tableName] ); + return ( + + ); +} + +export function CatalogNumberFormatSelection({ + formatter, + onChange: handleChange, +}: { + readonly formatter: string | undefined; + readonly onChange: ((formatter: string | undefined) => void) | undefined; +}): JSX.Element | null { + const [availableFormatters] = useAsyncState( + React.useCallback( + async () => + fetchDomain + .then(async (schema) => + Promise.all( + Object.keys(schema.collectionObjectTypeCatalogNumberFormats).map( + async (cotUri) => + resourceFromUrl(cotUri, { + noBusinessRules: true, + })?.fetch() as Promise< + SpecifyResource | undefined + > + ) + ) + ) + .then((cots) => + filterArray(cots).map((cot) => { + const format = + cot.get('catalogNumberFormatName') ?? + schema.catalogNumFormatName; + return { + name: format, + title: cot.get('name'), + isDefault: false, + }; + }) + ), + [] + ), + false + ); + + return ( + format !== null && format !== schema.catalogNumFormatName + ) || + (formatter !== undefined && + !Object.values(schema.collectionObjectTypeCatalogNumberFormats) + .filter((formatName) => formatName !== null) + .includes(formatter)) + } + onChange={handleChange} + /> + ); +} + +function FormatSelect({ + availableFormatters, + currentFormat, + showSingular = false, + onChange: handleChange, +}: { + readonly availableFormatters: RA | undefined; + readonly currentFormat: string | undefined; + readonly showSingular?: boolean; + readonly onChange: ((formatter: string | undefined) => void) | undefined; +}): JSX.Element | null { const [formatterSelectIsOpen, setFormatterSelect] = React.useState(false); const id = useId('formatters-selection'); return availableFormatters === undefined ? ( - typeof formatter === 'string' ? ( + typeof currentFormat === 'string' ? ( <>{commonText.loading()} ) : null - ) : availableFormatters.length > 1 ? ( + ) : showSingular || availableFormatters.length > 1 ? ( <> selected.name === formatter) - ?.isDefault || - formatter === undefined || - formatter === '' + availableFormatters.find( + (selected) => selected.name === currentFormat + )?.isDefault || + currentFormat === undefined || + currentFormat === '' ? 'bg-white dark:bg-neutral-600' : 'bg-yellow-250 dark:bg-yellow-900 ' }`} @@ -83,7 +173,7 @@ export function QueryFieldFormatter({ className={customSelectElementBackground} disabled={handleChange === undefined} id={id('list')} - value={formatter} + value={currentFormat} onValueChange={handleChange} > ))} + {currentFormat !== undefined && + !availableFormatters + .map(({ name }) => name) + .includes(currentFormat) ? ( + + ) : undefined} )} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/FromMap.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/FromMap.tsx index 93aa2244814..a8cdeb15158 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/FromMap.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/FromMap.tsx @@ -233,7 +233,6 @@ function getNewQueryLines( sortType: undefined, isDisplay: definedField.isDisplay, ...(fields[latitudeLineIndex] as Partial), - dataObjFormatter: undefined, filters: [ { type: 'between', @@ -253,7 +252,6 @@ function getNewQueryLines( sortType: undefined, isDisplay: definedField.isDisplay, ...(fields[longitudeLineIndex] as Partial), - dataObjFormatter: undefined, filters: operator === 'between' ? [ diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx index 665116332ae..a0d4f635b2a 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { commonText } from '../../localization/common'; import type { Parser } from '../../utils/parser/definitions'; -import { resolveParser } from '../../utils/parser/definitions'; +import { + formatterToParser, + resolveParser, +} from '../../utils/parser/definitions'; import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { replaceItem } from '../../utils/utils'; @@ -13,6 +16,7 @@ import { icons } from '../Atoms/Icons'; import { schema } from '../DataModel/schema'; import { genericTables, getTable } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; +import { getUiFormatters } from '../FieldFormatters'; import { join } from '../Molecules'; import { TableIcon } from '../Molecules/TableIcon'; import { customSelectElementBackground } from '../WbPlanView/CustomSelectElement'; @@ -45,7 +49,10 @@ import { import { FieldFilterTool } from './FieldFilterTool'; import type { DatePart } from './fieldSpec'; import { QueryFieldSpec } from './fieldSpec'; -import { QueryFieldFormatter } from './Formatter'; +import { + CatalogNumberFormatSelection, + QueryFieldRecordFormatter, +} from './Formatter'; import type { QueryField } from './helpers'; import { QueryLineTools } from './QueryLineTools'; @@ -141,7 +148,7 @@ export function QueryLine({ : getTable(tableName ?? '')?.getField(fieldName); let fieldType: QueryFieldType | undefined = undefined; - let parser = undefined; + let parser: Parser | undefined = undefined; const hasParser = typeof dataModelField === 'object' && !dataModelField.isRelationship && @@ -377,23 +384,32 @@ export function QueryLine({ )} {(fieldMeta.fieldType === 'formatter' || fieldMeta.fieldType === 'aggregator') && - typeof fieldMeta.tableName === 'string' ? ( - type === 'any')?.fieldFormat + } tableName={fieldMeta.tableName} type={fieldMeta.fieldType} onChange={ handleChange === undefined ? undefined - : (dataObjectFormatter): void => - handleChange({ - ...field, - dataObjFormatter: dataObjectFormatter, - }) + : (dataObjectFormatter): void => { + const filterIndex = field.filters.findIndex( + ({ type }) => type === 'any' + ); + handleFilterChange(filterIndex, { + ...field.filters[filterIndex], + fieldFormat: dataObjectFormatter, + }); + } } /> ) : undefined} + {filtersVisible ? (
- {field.filters.map((filter, index) => ( -
1 - ? 'flex flex-wrap gap-2' - : 'contents' - } - key={index} - > -
- -
- { - const newFilter = (target as HTMLSelectElement) - .value as QueryFieldFilter; - const startValue = - queryFieldFilters[newFilter].component === undefined - ? '' - : filter.type === 'any' && - filtersWithDefaultValue.has(newFilter) && - filter.startValue === '' && - typeof fieldMeta.parser?.value === 'string' - ? fieldMeta.parser.value - : filter.startValue; + disabled={handleChange === undefined} + title={ + queryFieldFilters[filter.type].description ?? + commonText.filter() + } + value={filter.type} + onChange={({ target }): void => { + const newFilter = (target as HTMLSelectElement) + .value as QueryFieldFilter; + const startValue = + queryFieldFilters[newFilter].component === + undefined + ? '' + : filter.type === 'any' && + filtersWithDefaultValue.has(newFilter) && + filter.startValue === '' && + typeof parser?.value === 'string' + ? parser.value + : filter.startValue; - /* - * When going from "in" to another filter type, throw away - * all but first one or two values - */ - const valueLength = newFilter === 'between' ? 2 : 1; - const trimmedValue = - filter.type === 'in' - ? startValue - : startValue - .split(',') - .slice(0, valueLength) - .join(', '); + /* + * When going from "in" to another filter type, throw away + * all but first one or two values + */ + const valueLength = newFilter === 'between' ? 2 : 1; + const trimmedValue = + filter.type === 'in' + ? startValue + : startValue + .split(',') + .slice(0, valueLength) + .join(', '); - handleFilterChange?.(index, { - ...field.filters[index], - type: newFilter, - startValue: trimmedValue, - }); - }} - > - {availableFilters.map(([filterName, { label }]) => ( - - ))} - + handleFilterChange?.(index, { + ...field.filters[index], + type: newFilter, + startValue: trimmedValue, + }); + }} + > + {availableFilters.map(([filterName, { label }]) => ( + + ))} + +
+
+
+ {typeof parser === 'object' && ( + + handleFilterChange(index, { + ...field.filters[index], + startValue, + }) + : undefined + } + /> + )} + {/** + * The CO catalogNumber format can be determined by the + * Collection Object Type (COT) catalogNumberFormatName + * + * This format selection allows selecting which COT + * field formatter is being used for this query filter + */} + {fieldMeta.tableName === 'CollectionObject' && + terminatingField?.name === 'catalogNumber' && + queryFieldFilters[filter.type].hasParser ? ( + { + handleFilterChange(index, { + ...field.filters[index], + fieldFormat: + (formatName ?? '') || undefined, + }); + } + } + /> + ) : undefined}
-
- {typeof fieldMeta.parser === 'object' && ( - - handleFilterChange(index, { - ...field.filters[index], - startValue, - }) - : undefined - } - /> - )} -
-
- ))} + ); + })} ) : ( diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineTools.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineTools.tsx index 3d570aad992..5818521bd4a 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineTools.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineTools.tsx @@ -81,7 +81,7 @@ export function QueryLineTools({ : queryText.sort() } className={` - ${isFieldComplete ? undefined : 'invisible'} ${isBasic ? 'h-full' : ''} + ${isFieldComplete ? '' : 'invisible'} ${isBasic ? 'h-full' : ''} `} title={ field.sortType === 'ascending' diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 1a5e722c8d1..1c1b1c82095 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -190,7 +190,6 @@ function Wrapped({ id: Math.max(-1, ...state.fields.map(({ id }) => id)) + 1, mappingPath, sortType: undefined, - dataObjFormatter: undefined, filters: [ { type: 'any', diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts index e842721fbf2..d96bacb3a64 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts @@ -43,12 +43,20 @@ export type QueryField = { readonly mappingPath: MappingPath; readonly sortType: SortTypes; readonly isDisplay: boolean; - readonly dataObjFormatter: string | undefined; readonly filters: RA<{ readonly type: QueryFieldFilter; readonly startValue: string; readonly isNot: boolean; readonly isStrict: boolean; + /** + * Can either be a Record Formatter (for formatted/aggregated query fields) + * or a Field Formatter that can be set for each filter + * + * Currently the only configurable Field Formatter in the UI is + * CollectionObject -> catalogNumber + * See https://github.com/specify/specify7/issues/5474 + */ + readonly fieldFormat?: string; }>; }; @@ -97,7 +105,6 @@ export function parseQueryFields( id: index, mappingPath, sortType: sortTypes[field.sortType], - dataObjFormatter: field.formatName ?? undefined, filter: { type: defined( Object.entries(queryFieldFilters).find( @@ -105,6 +112,7 @@ export function parseQueryFields( ), `Unknown SpQueryField.operStart value: ${field.operStart}` )[KEY], + fieldFormat: field.formatName ?? undefined, isNot, isStrict, startValue, @@ -211,7 +219,6 @@ const addQueryFields = ( sortType: undefined, isDisplay: true, parser: undefined, - dataObjFormatter: undefined, filters: [ { type: 'any', @@ -282,7 +289,6 @@ export const addFormattedField = (fields: RA): RA => mappingPath: [formattedEntry], sortType: undefined, isDisplay: true, - dataObjFormatter: undefined, filters: [ { type: 'any', @@ -304,7 +310,6 @@ export const unParseQueryFields = ( ([field, fieldSpec], index) => { const commonData = { ...fieldSpec.toSpQueryAttributes(), - formatName: field.dataObjFormatter, sortType: sortTypes.indexOf(field.sortType), position: index, isDisplay: field.isDisplay, @@ -317,9 +322,10 @@ export const unParseQueryFields = ( hasFilters ? filter.type !== 'any' : index === 0 ) .map( - ({ type, startValue, isNot, isStrict }, index) => + ({ type, startValue, isNot, isStrict, fieldFormat }, index) => ({ ...commonData, + formatName: fieldFormat, operStart: defined( // Back-end treats "equal" with blank startValue as "any" Object.entries(queryFieldFilters).find( diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/reducer.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/reducer.ts index d3ba3a35c52..1abaeb53af7 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/reducer.ts +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/reducer.ts @@ -156,11 +156,6 @@ export const reducer = generateReducer({ fields: replaceItem(state.fields, line, { ...state.fields[line], mappingPath: newMappingPath, - dataObjFormatter: - mappingPathIsComplete(newMappingPath) && - action.currentTableName === action.newTableName - ? undefined - : state.fields[line].dataObjFormatter, }), autoMapperSuggestions: undefined, }; diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx index bd50b7d0a15..e46d5401e45 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx @@ -115,7 +115,6 @@ function getFields(query: SerializedResource): RA { id: fields.length, mappingPath: ['collectingEvent', 'locality'], sortType: undefined, - dataObjFormatter: undefined, isDisplay: true, filters: [ { diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/__tests__/Map.test.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/__tests__/Map.test.tsx index fc560e833e3..b453fc92028 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/__tests__/Map.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/__tests__/Map.test.tsx @@ -68,7 +68,6 @@ test('getFields and extractQueryTaxonId', async () => { expect(addedFields).toMatchInlineSnapshot(` [ { - "dataObjFormatter": undefined, "filters": [ { "isNot": false, diff --git a/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts index 93bd96ed913..d2c4a7cacc6 100644 --- a/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts +++ b/specifyweb/frontend/js_src/lib/utils/parser/definitions.ts @@ -401,6 +401,10 @@ export function formatterToParser( value === undefined || value === null ? title : undefined, ], placeholder: formatter.pattern() ?? undefined, + 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, diff --git a/specifyweb/stored_queries/format.py b/specifyweb/stored_queries/format.py index d1359e942e3..034da95faa2 100644 --- a/specifyweb/stored_queries/format.py +++ b/specifyweb/stored_queries/format.py @@ -125,8 +125,9 @@ def lookup_default(attr: str, val: str) -> Optional[Element]: result = lookup('name', aggregator_name) return result if result is not None else lookup_default('class', specify_model.classname) - def catalog_number_is_numeric(self): - return self.collection.catalognumformatname == 'CatalogNumberNumeric' + def catalog_number_is_numeric(self, raw_format_name: Optional[str] = None): + format_name = raw_format_name if raw_format_name else self.collection.catalognumformatname + return format_name == 'CatalogNumberNumeric' def pseudo_sprintf(self, format, expr): """Handle format attribute of fields in data object formatter definitions. @@ -310,7 +311,7 @@ def fieldformat(self, query_field: QueryField, pass else: - field = self._fieldformat(field_spec.get_field(), field) + field = self._fieldformat(field_spec.get_field(), field, query_field.format_name) return blank_nulls(field) if self.replace_nulls else field def _dateformat(self, specify_field, field): @@ -328,7 +329,8 @@ def _dateformat(self, specify_field, field): return func.date_format(field, format_expr) def _fieldformat(self, specify_field: Field, - field: Union[InstrumentedAttribute, Extract]): + field: Union[InstrumentedAttribute, Extract], + format_name: Optional[str] = None): if specify_field.type == "java.lang.Boolean": return field != 0 @@ -336,7 +338,7 @@ def _fieldformat(self, specify_field: Field, return field if specify_field is CollectionObject_model.get_field('catalogNumber') \ - and self.catalog_number_is_numeric(): + and self.catalog_number_is_numeric(format_name): return cast(field, types.Numeric(65)) # 65 is the mysql max precision diff --git a/specifyweb/stored_queries/queryfieldspec.py b/specifyweb/stored_queries/queryfieldspec.py index 98f8686b42a..2d80d8aeb61 100644 --- a/specifyweb/stored_queries/queryfieldspec.py +++ b/specifyweb/stored_queries/queryfieldspec.py @@ -69,40 +69,6 @@ def make_stringid(fs, table_list): field_name += 'Numeric' + fs.date_part return table_list, fs.table.name.lower(), field_name -# class QueryFieldSpec(NamedTuple): -# root_table: 'Table' -# root_sql_table: 'SQLTable' # type: ignore -# join_path: Tuple['Field', ...] -# table: 'Table' -# date_part: Optional[str] -# tree_rank: Optional[str] -# tree_field: Optional[str] - -# @classmethod -# def create(cls, root_table, root_sql_table, join_path, table, date_part=None, tree_rank=None, tree_field=None): -# # Create a new QueryFieldSpec instance -# instance = cls( -# root_table=root_table, -# root_sql_table=root_sql_table, -# join_path=join_path, -# table=table, -# date_part=date_part, -# tree_rank=tree_rank, -# tree_field=tree_field -# ) -# # Validate the instance -# instance.validate() -# return instance - -# def validate(self): -# valid_date_parts = ('Full Date', 'Day', 'Month', 'Year', None) -# assert self.is_temporal() or self.date_part is None -# if self.date_part not in valid_date_parts: -# raise AssertionError( -# f"Invalid date part '{self.date_part}'. Expected one of {valid_date_parts}", -# {"datePart": self.date_part, -# "validDateParts": str(valid_date_parts), -# "localizationKey": "invalidDatePart"}) class QueryFieldSpec(namedtuple("QueryFieldSpec", "root_table root_sql_table join_path table date_part tree_rank tree_field")): @classmethod def from_path(cls, path_in, add_id=False): @@ -321,4 +287,4 @@ def add_spec_to_query(self, query, formatter=None, aggregator=None, cycle_detect if field.is_temporal() and self.date_part != "Full Date": orm_field = sql.extract(self.date_part, orm_field) - return query, orm_field, field, table \ No newline at end of file + return query, orm_field, field, table