diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleUtils.ts b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleUtils.ts index 3b67d92bdf6..7bbca188f81 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleUtils.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/businessRuleUtils.ts @@ -13,7 +13,7 @@ export const PREPARATION_GIFTED_KEY = 'preparation-isGifted'; export const PREPARATION_DISPOSED_KEY = 'preparation-isDisposed'; export const PREPARATION_EXCHANGED_OUT_KEY = 'preparation-isExchangedOut'; export const PREPARATION_EXCHANGED_IN_KEY = 'preparation-isExchangedIn'; -export const COJO_PRIMARY_DELETE_KEY = 'primary-cojo-delete' +export const COJO_PRIMARY_DELETE_KEY = 'primary-cojo-delete'; /** * diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx index 4128cd7ff80..31f363bf390 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx @@ -126,33 +126,42 @@ function Field({ 'rightAlignNumberFields' ); - const isNew = resource?.isNew() - const isCO = resource?.specifyTable.name === "CollectionObject" - const isPartOfCOG = isCO ? resource?.get('cojo') !== null && resource?.get('cojo') !== undefined : false; - const isCatNumberField = field?.name === "catalogNumber" + const isNew = resource?.isNew(); + const isCO = resource?.specifyTable.name === 'CollectionObject'; + const isPartOfCOG = isCO + ? resource?.get('cojo') !== null && resource?.get('cojo') !== undefined + : false; + const isCatNumberField = field?.name === 'catalogNumber'; // Check if collection pref wants to inherit primary cat num for empty CO cat num sibilings inside of a COG const [displayPrimaryCatNumberPref] = collectionPreferences.use( - 'catalogNumberInheritance', - 'behavior', - 'inheritance' - ); - const displayCatNumberPlaceHolder = isNew === false && isCO && isPartOfCOG && isCatNumberField && displayPrimaryCatNumberPref - - const [primaryCatalogNumber, setPrimaryCatalogNumber] = React.useState(null); + 'catalogNumberInheritance', + 'behavior', + 'inheritance' + ); + const displayCatNumberPlaceHolder = + isNew === false && + isCO && + isPartOfCOG && + isCatNumberField && + displayPrimaryCatNumberPref; + + const [primaryCatalogNumber, setPrimaryCatalogNumber] = React.useState< + string | null + >(null); React.useEffect(() => { if (resource && displayCatNumberPlaceHolder) { ajax('/api/specify/catalog_number_for_sibling/', { method: 'POST', headers: { Accept: 'application/json' }, - body: resource + body: resource, }) - .then((response) => { - setPrimaryCatalogNumber(response.data); - }) - .catch((error) => { - console.error("Error fetching catalog number:", error); - }); + .then((response) => { + setPrimaryCatalogNumber(response.data); + }) + .catch((error) => { + console.error('Error fetching catalog number:', error); + }); } }, [resource, displayCatNumberPlaceHolder]); @@ -161,7 +170,11 @@ function Field({ forwardRef={validationRef} key={parser.title} name={name} - placeholder={displayCatNumberPlaceHolder && typeof primaryCatalogNumber === 'string' ? primaryCatalogNumber : undefined} + placeholder={ + displayCatNumberPlaceHolder && typeof primaryCatalogNumber === 'string' + ? primaryCatalogNumber + : undefined + } {...validationAttributes} className={ /* diff --git a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx index 5e21ae7ccb8..b7f5d50a3cc 100644 --- a/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormMeta/index.tsx @@ -131,7 +131,7 @@ function MetaDialog({ readonly onClose: () => void; }): JSX.Element { const subView = React.useContext(SubViewContext); - const canMergeTable = canMerge(resource.specifyTable) + const canMergeTable = canMerge(resource.specifyTable); return ( ) : undefined} diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx b/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx index 0cec2618a53..4bfb3654822 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/Formatters/Components.tsx @@ -204,7 +204,7 @@ export function ResourceMapping({ showHiddenFields: true, generateFieldData: 'all', spec: navigatorSpecs.formatterEditor, - }).map(line => ({ + }).map((line) => ({ ...line, fieldsData: Object.fromEntries( Object.entries(line.fieldsData).filter(([key]) => key !== 'age') diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/FieldFilter.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/FieldFilter.tsx index 208ebb30874..afcaf1bbdd7 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/FieldFilter.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/FieldFilter.tsx @@ -46,7 +46,7 @@ export type QueryFieldType = | 'id' | 'number' | 'text'; -export type QueryFieldFilter = +export type QueryFieldFilterType = | 'ageName' | 'ageRange' | 'any' @@ -65,7 +65,7 @@ export type QueryFieldFilter = | 'startsWith' | 'true' | 'trueOrNull'; -export const filtersWithDefaultValue = new Set([ +export const filtersWithDefaultValue = new Set([ 'equal', 'in', ]); @@ -401,7 +401,7 @@ function In({ } export const queryFieldFilters: RR< - QueryFieldFilter, + QueryFieldFilterType, { readonly id: number; readonly label: LocalizedString; diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/FieldFilterTool.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/FieldFilterTool.tsx index 04be9d10057..ff5c43656b3 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/FieldFilterTool.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/FieldFilterTool.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { commonText } from '../../localization/common'; import { queryText } from '../../localization/query'; -import type { RA } from '../../utils/types'; +import { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; import { iconClassName, icons } from '../Atoms/Icons'; @@ -10,74 +10,60 @@ import { mappingElementDivider, mappingElementDividerClassName, } from '../WbPlanView/LineComponents'; -import type { QueryFieldFilter } from './FieldFilter'; -import type { QueryField } from './helpers'; - -type FieldFilterToolProps = { - readonly fieldFilters: RA<{ - readonly type: QueryFieldFilter; - readonly startValue: string; - readonly isNot: boolean; - /** - * When 'isStrict' is True - * each CO's age_range must be fully contained by - * the age range provided by start_time and end_time filter (complete overlap). - * When 'isStrict' is False, only a partial overlap - * between a CO's age range and the provided - * start_time and end_time filter are needed. - */ - readonly isStrict: boolean; - }>; - readonly index: number; - readonly isBasic: boolean; - readonly hasAny: boolean; - readonly isFieldComplete: boolean; - readonly fieldName: string; - readonly handleChange: ((newField: QueryField) => void) | undefined; - readonly handleFilterChange: ( - index: number, - filter: QueryField['filters'][number] | undefined - ) => void; -}; +import { QueryFieldFilterType } from './FieldFilter'; +import { QueryFieldFilterProps } from './QueryLineFieldFilter'; +/** + * Buttons relating to the management of the filter or change the behavior + * of the filter + * + * Examples of these buttons are: + * + * Add Filter Button, Negate Filter Button, Remove Filter Button, + * Toggle IsStrict Button (for CollectionObject -> Age queries) + */ export function FieldFilterTool({ - fieldFilters, - index, + fieldFilter, + isFirst, isBasic, hasAny, + hasMultipleFilters, isFieldComplete, - fieldName, - handleChange, - handleFilterChange, -}: FieldFilterToolProps): JSX.Element { + onChange: handleChange, + onAddFieldFilter: handleAddFieldFilter, + onRemoveFieldFilter: handleRemoveFieldFilter, +}: QueryFieldFilterProps): JSX.Element { return ( <> - {index === 0 ? ( + {/* REFACTOR: Extract this to separate component */} + {isFirst ? ( <> {isBasic ? null : mappingElementDivider} {!hasAny && ( 1} + aria-pressed={hasMultipleFilters} className={` print:hidden ${className.ariaHandled} ${isFieldComplete ? '' : 'invisible'} `} - disabled={handleChange === undefined} title={queryText.or()} variant={ - fieldFilters.length > 1 + hasMultipleFilters ? className.infoButton : className.secondaryLightButton } - onClick={(): void => - handleFilterChange(fieldFilters.length, { - type: 'any', - isNot: false, - startValue: '', - isStrict: false, - }) + onClick={ + handleAddFieldFilter === undefined + ? undefined + : (): void => + handleAddFieldFilter({ + type: 'any', + isNot: false, + startValue: '', + isStrict: false, + }) } > {icons.plus} @@ -99,63 +85,82 @@ export function FieldFilterTool({ handleFilterChange(index, undefined)} + onClick={ + handleRemoveFieldFilter === undefined + ? undefined + : (): void => handleRemoveFieldFilter() + } > {icons.trash} )} - {fieldFilters[index].type !== 'any' && ( + {fieldFilter.type !== 'any' && ( - handleFilterChange(index, { - ...fieldFilters[index], - isNot: !fieldFilters[index].isNot, - }) + onClick={ + handleChange === undefined + ? undefined + : (): void => + handleChange({ + ...fieldFilter, + isNot: !fieldFilter.isNot, + }) } > {icons.ban} )} - {fieldName === 'age' && index === 0 ? ( - - handleFilterChange(index, { - ...fieldFilters[index], - isStrict: !fieldFilters[index].isStrict, - }) - } - > - {fieldFilters[index].isStrict ? icons.strict : icons.nonStrict} - + {/** + * REFACTOR: Add a property to queryFieldFilters to determine + * FieldFilterTool component based on type + */} + {(['ageRange', 'ageName'] as RA).includes( + fieldFilter.type + ) ? ( + ) : undefined} ); } + +function AgeQueryFieldFilter({ + fieldFilter, + onChange: handleChange, +}: Pick): JSX.Element { + return ( + + handleChange({ + ...fieldFilter, + isStrict: !fieldFilter.isStrict, + }) + } + > + {fieldFilter.isStrict ? icons.strict : icons.nonStrict} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Fields.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Fields.tsx index fd6f7f982fc..d1374605256 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Fields.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useReadyEffect } from '../../hooks/useReadyEffect'; import { f } from '../../utils/functools'; import type { RA } from '../../utils/types'; +import { replaceItem } from '../../utils/utils'; import { Ul } from '../Atoms'; import type { Tables } from '../DataModel/types'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; @@ -22,7 +23,7 @@ export function QueryFields({ openedElement, showHiddenFields, getMappedFields, - onChangeField: handleChangeField, + onChangeFields: handleChangeFields, onMappingChange: handleMappingChange, onRemoveField: handleRemoveField, onOpen: handleOpen, @@ -30,7 +31,6 @@ export function QueryFields({ onLineFocus: handleLineFocus, onLineMove: handleLineMove, onOpenMap: handleOpenMap, - onChangeFields: handleChangeFields, }: { readonly baseTableName: keyof Tables; readonly fields: RA; @@ -41,9 +41,7 @@ export function QueryFields({ }; readonly showHiddenFields: boolean; readonly getMappedFields: (mappingPathFilter: MappingPath) => RA; - readonly onChangeField: - | ((line: number, field: QueryField) => void) - | undefined; + readonly onChangeFields: ((fields: RA) => void) | undefined; readonly onMappingChange: | (( line: number, @@ -66,19 +64,18 @@ export function QueryFields({ | ((line: number, direction: 'down' | 'up') => void) | undefined; readonly onOpenMap: ((line: number) => void) | undefined; - readonly onChangeFields?: ((fields: RA) => void) | undefined; }): JSX.Element { const fieldsContainerRef = React.useRef(null); const fieldsRef = React.useRef(fields); fieldsRef.current = fields; - const handleChangeFieldRef = React.useRef(handleChangeFields); - handleChangeFieldRef.current = handleChangeFields; + const handleChangeFieldsRef = React.useRef(handleChangeFields); + handleChangeFieldsRef.current = handleChangeFields; // Draggable and sortable code React.useEffect(() => { - if (handleChangeFieldRef.current === undefined) return; + if (handleChangeFieldsRef.current === undefined) return; if (fieldsContainerRef.current === null) return; const sortable = new Sortable(fieldsContainerRef.current, { @@ -127,7 +124,7 @@ export function QueryFields({ newItems.splice(oldIndex + 1, 1); } - handleChangeFieldRef.current?.(newItems); + handleChangeFieldsRef.current?.(newItems); handleLineFocus?.(newIndex); }); @@ -141,24 +138,41 @@ export function QueryFields({ return () => sortable.destroy(); }, []); - // Scroll to bottom if added a child - const oldFieldCount = React.useRef(fields.length); + /** + * If one or more children are added, scroll to the first added child + * + * This assumes that each child of the fieldsContainerRef element corresponds + * to the QueryField of the same index: + * i.e., fields[index] == fieldsContainerRef.children[index] + */ + const oldFields = React.useRef>(fields); // REFACTOR: extract this into hook and use everywhere where applicable useReadyEffect( React.useCallback(() => { if ( fieldsContainerRef.current !== null && - fieldsContainerRef.current.lastChild !== null && + fieldsContainerRef.current.children.length > 1 && fieldsContainerRef.current.clientHeight !== fieldsContainerRef.current.scrollHeight && - fields.length > oldFieldCount.current + fields.length > oldFields.current.length ) { - const lastElement = fieldsContainerRef.current.lastChild as HTMLElement; + const firstChangedFieldIndex = fields + .map(({ id }) => id) + .findIndex( + (id, index) => + index === oldFields.current.length || + id !== oldFields.current[index].id + ); + const lineToScrollTo = ( + firstChangedFieldIndex === -1 + ? fieldsContainerRef.current.lastChild + : fieldsContainerRef.current.children.item(firstChangedFieldIndex) + ) as HTMLElement; const firstNonContentsChild = - lastElement.querySelector(':not(.contents)'); + lineToScrollTo.querySelector(':not(.contents)'); scrollIntoView(firstNonContentsChild as HTMLElement, 'nearest'); } - oldFieldCount.current = fields.length; + oldFields.current = fields; }, [fields.length]) ); @@ -195,6 +209,7 @@ export function QueryFields({ { + handleChangeFields(replaceItem(fields, line, field)); + } + } + onChangeFields={handleChangeFields} onClose={handleClose} onLineFocus={(target): void => (target === 'previous' && line === 0) || diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Formatter.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Formatter.tsx index 7925fc32b19..15e0d1df724 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Formatter.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Formatter.tsx @@ -5,13 +5,12 @@ 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 type { IR, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Select } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; -import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { resourceFromUrl } from '../DataModel/resource'; +import { fetchCollection } from '../DataModel/collection'; +import { SerializedResource } from '../DataModel/helperTypes'; import { fetchContext as fetchDomain, schema } from '../DataModel/schema'; import type { CollectionObjectType, Tables } from '../DataModel/types'; import { fetchFormatters } from '../Formatters/formatters'; @@ -23,10 +22,6 @@ type SimpleFormatter = { readonly isDefault: boolean; }; -type FormatterWithCOTs = SimpleFormatter & { - readonly cotNames: RA; -}; - export function QueryFieldRecordFormatter({ type, tableName, @@ -85,56 +80,45 @@ export function CatalogNumberFormatSelection({ 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) => { - const formattersMap = filterArray(cots).reduce((map, cot) => { - const format = - cot.get('catalogNumberFormatName') ?? - schema.catalogNumFormatName; - const cotName = cot.get('name'); - - if (!map.has(format)) { - map.set(format, { - name: format, - title: format, - isDefault: format === schema.catalogNumFormatName, - cotNames: [], - }); - } - - const formatter = map.get(format)!; - map.set(format, { - ...formatter, - cotNames: [...formatter.cotNames, cotName], - }); - return map; - }, new Map()); - - return Array.from( - formattersMap.values(), - ({ name, isDefault, cotNames }) => { - const title = queryText.formatInputAs({ - commaSeparatedFormats: cotNames.join(', '), - }); - return { - name, - title, - isDefault, - }; - } - ); - }), + .then((schema) => ({ + collection: schema.domainLevelIds['collection'], + defaultCatNumFormat: schema.catalogNumFormatName, + })) + .then(async ({ collection, defaultCatNumFormat }) => ({ + defaultCatNumFormat, + cots: await fetchCollection('CollectionObjectType', { + collection, + }).then( + ({ records }) => + records.map((cot) => ({ + ...cot, + catalogNumberFormatName: + cot.catalogNumberFormatName ?? defaultCatNumFormat, + })) as RA> + ), + })) + .then(({ cots, defaultCatNumFormat }) => ({ + defaultCatNumFormat, + cotsByFormat: cots.reduce( + (map, cot) => ({ + ...map, + [cot.catalogNumberFormatName!]: [ + ...(map[cot.catalogNumberFormatName!] ?? []), + cot.name, + ], + }), + {} as IR> + ), + })) + .then(({ cotsByFormat, defaultCatNumFormat }) => + Object.entries(cotsByFormat).map(([format, names]) => ({ + title: queryText.formatInputAs({ + commaSeparatedFormats: names.join(', '), + }), + name: format, + isDefault: format === defaultCatNumFormat, + })) + ), [] ), false @@ -209,14 +193,14 @@ function FormatSelect({ ))} {currentFormat !== undefined && !availableFormatters .map(({ name }) => name) .includes(currentFormat) ? ( - ) : undefined} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx index a0d4f635b2a..2cb2883f054 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Line.tsx @@ -2,24 +2,18 @@ import React from 'react'; import { commonText } from '../../localization/common'; import type { Parser } from '../../utils/parser/definitions'; -import { - formatterToParser, - resolveParser, -} from '../../utils/parser/definitions'; +import { resolveParser } from '../../utils/parser/definitions'; import type { RA } from '../../utils/types'; import { filterArray } from '../../utils/types'; import { replaceItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; -import { Select } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; import { schema } from '../DataModel/schema'; -import { genericTables, getTable } from '../DataModel/tables'; +import { 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'; import { mappingPathIsComplete } from '../WbPlanView/helpers'; import { getMappingLineProps, @@ -29,7 +23,6 @@ import { import type { MappingPath } from '../WbPlanView/Mapper'; import { formattedEntry, - mappingPathToString, parsePartialField, valueIsPartialField, } from '../WbPlanView/mappingHelpers'; @@ -40,20 +33,13 @@ import { } from '../WbPlanView/navigator'; import { navigatorSpecs } from '../WbPlanView/navigatorSpecs'; import { IsQueryBasicContext } from './Context'; -import type { QueryFieldFilter, QueryFieldType } from './FieldFilter'; -import { - filtersWithDefaultValue, - queryFieldFilters, - QueryLineFilter, -} from './FieldFilter'; -import { FieldFilterTool } from './FieldFilterTool'; +import type { QueryFieldType } from './FieldFilter'; +import { queryFieldFilters } from './FieldFilter'; import type { DatePart } from './fieldSpec'; import { QueryFieldSpec } from './fieldSpec'; -import { - CatalogNumberFormatSelection, - QueryFieldRecordFormatter, -} from './Formatter'; +import { QueryFieldRecordFormatter } from './Formatter'; import type { QueryField } from './helpers'; +import { QueryLineFieldFilter } from './QueryLineFieldFilter'; import { QueryLineTools } from './QueryLineTools'; // REFACTOR: split this component into smaller components @@ -61,6 +47,7 @@ export function QueryLine({ isLast, baseTableName, field, + fields, fieldHash, enforceLengthLimit = false, isFocused, @@ -77,10 +64,12 @@ export function QueryLine({ onMoveUp: handleMoveUp, onMoveDown: handleMoveDown, onOpenMap: handleOpenMap, + onChangeFields: handleChangeFields, }: { readonly isLast: boolean; readonly baseTableName: keyof Tables; readonly field: QueryField; + readonly fields: RA; readonly fieldHash: string; readonly enforceLengthLimit?: boolean; readonly isFocused: boolean; @@ -107,6 +96,7 @@ export function QueryLine({ readonly onMoveUp: (() => void) | undefined; readonly onMoveDown: (() => void) | undefined; readonly onOpenMap: (() => void) | undefined; + readonly onChangeFields: ((fields: RA) => void) | undefined; }): JSX.Element { const lineRef = React.useRef(null); @@ -245,14 +235,17 @@ export function QueryLine({ openSelectElement: openedElement, }); - const handleFilterChange = ( - index: number, - filter: QueryField['filters'][number] | undefined - ): void => - handleChange?.({ - ...field, - filters: filterArray(replaceItem(field.filters, index, filter)), - }); + const handleFilterChange = + handleChange === undefined + ? undefined + : ( + index: number, + filter: QueryField['filters'][number] | undefined + ): void => + handleChange({ + ...field, + filters: filterArray(replaceItem(field.filters, index, filter)), + }); const isFieldComplete = mappingPathIsComplete(field.mappingPath); @@ -386,7 +379,6 @@ export function QueryLine({ fieldMeta.fieldType === 'aggregator') && typeof fieldMeta.tableName === 'string' && hasAny ? ( - // REFACTOR: move this to the field.filters map type === 'any')?.fieldFormat @@ -400,7 +392,7 @@ export function QueryLine({ const filterIndex = field.filters.findIndex( ({ type }) => type === 'any' ); - handleFilterChange(filterIndex, { + handleFilterChange?.(filterIndex, { ...field.filters[filterIndex], fieldFormat: dataObjectFormatter, }); @@ -419,24 +411,6 @@ export function QueryLine({ } > {field.filters.map((filter, index) => { - const terminatingField = isFieldComplete - ? genericTables[baseTableName].getField( - mappingPathToString(field.mappingPath) - ) - : undefined; - - const fieldFormatter = - filter.fieldFormat === undefined - ? undefined - : getUiFormatters()[filter.fieldFormat]; - - const parser = - (terminatingField === undefined || - fieldFormatter === undefined - ? undefined - : formatterToParser(terminatingField, fieldFormatter)) ?? - fieldMeta.parser; - return (
-
- -
- -
-
-
- {typeof parser === 'object' && ( - - handleFilterChange(index, { - ...field.filters[index], - startValue, - }) - : undefined - } - /> + {/* REFACTOR: Simplify this */} + 1} + enforceLengthLimit={enforceLengthLimit} + shownFilters={availableFilters} + onChange={handleFilterChange?.bind(undefined, index)} + onChangeFields={handleChangeFields} + onAddFieldFilter={handleFilterChange?.bind( + undefined, + field.filters.length + )} + onRemoveFieldFilter={handleFilterChange?.bind( + undefined, + index, + 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} -
+ />
); })} @@ -568,7 +456,7 @@ export function QueryLine({ )} void) + | undefined; + readonly onAddFieldFilter: + | ((newFieldFilter: QueryFieldFilter) => void) + | undefined; + readonly onRemoveFieldFilter: (() => void) | undefined; +}; + +export function QueryLineFieldFilter({ + fields, + queryField, + fieldFilter, + terminatingTableName, + baseTableName, + baseParser, + isBasic, + isFirst, + isFieldComplete, + hasAny, + hasMultipleFilters, + enforceLengthLimit, + shownFilters, + onChangeFields: handleChangeFields, + onChange: handleFilterChange, + onAddFieldFilter: handleAddFieldFilter, + onRemoveFieldFilter: handleRemoveFieldFilter, +}: { + readonly fields: RA; + readonly queryField: QueryField; + readonly fieldFilter: QueryFieldFilter; + readonly baseParser: Parser | undefined; + readonly baseTableName: keyof Tables; + readonly terminatingTableName: keyof Tables | undefined; + readonly shownFilters: RA< + readonly [keyof typeof queryFieldFilters, ValueOf] + >; + readonly enforceLengthLimit: boolean; + readonly onChangeFields: ((fields: RA) => void) | undefined; +} & QueryFieldFilterProps): JSX.Element { + const loading = React.useContext(LoadingContext); + + const terminatingField = isFieldComplete + ? genericTables[baseTableName].getField( + mappingPathToString( + queryField.mappingPath.filter( + (fieldName) => !valueIsToManyIndex(fieldName) + ) + ) + ) + : undefined; + + const fieldFormatter = + fieldFilter.fieldFormat === undefined + ? undefined + : getUiFormatters()[fieldFilter.fieldFormat]; + + const parser = + (terminatingField === undefined || fieldFormatter === undefined + ? undefined + : formatterToParser(terminatingField, fieldFormatter)) ?? baseParser; + return ( + <> +
+ +
+ +
+
+
+ {typeof parser === 'object' && ( + + handleFilterChange({ ...fieldFilter, 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 + */} + {terminatingTableName === 'CollectionObject' && + terminatingField?.name === 'catalogNumber' && + queryFieldFilters[fieldFilter.type].hasParser ? ( + { + const collectionObjectStart = f.max( + 0, + queryField.mappingPath.length - 1 + ) as number; + const containsCotFilter = fields.some( + (field) => + field.mappingPath + .at(collectionObjectStart) + ?.toLowerCase() === 'collectionobjecttype' + ); + handleFilterChange({ + ...fieldFilter, + fieldFormat: (formatName ?? '') || undefined, + }); + if ( + formatName !== '' && + formatName !== undefined && + !containsCotFilter + ) { + loading( + fetchDomain + .then((schema) => ({ + collection: schema.domainLevelIds['collection'], + defaultCatNumFormat: schema.catalogNumFormatName, + })) + .then(({ collection, defaultCatNumFormat }) => + fetchCollection('CollectionObjectType', { + collection, + }).then(({ records }) => + records + .filter( + (cot) => + cot.catalogNumberFormatName === + formatName || + (cot.catalogNumberFormatName === null && + formatName === defaultCatNumFormat) + ) + .map(({ name }) => name) + ) + ) + .then((cotNames) => { + handleChangeFields?.( + insertItem( + fields, + fields.indexOf(queryField) + 1, + { + id: undefined!, + mappingPath: ['collectionObjectType', 'name'], + filters: cotNames.map((cotName) => ({ + type: 'equal', + startValue: cotName, + isNot: false, + isStrict: false, + })), + isDisplay: true, + sortType: undefined, + } + ) + ); + }) + ); + } + } + } + /> + ) : undefined} +
+ + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineTools.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineTools.tsx index 5818521bd4a..f3fde0bface 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineTools.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryLineTools.tsx @@ -11,10 +11,16 @@ import type { QueryFieldType } from './FieldFilter'; import type { QueryField } from './helpers'; import { sortTypes } from './helpers'; +/** + * Buttons which deal with the management and behavior of an entire QueryLine + * + * The Show in Results Button, Sort Button, Move Up, and Move Down buttons on a + * QueryLine + */ export function QueryLineTools({ fieldMeta, onOpenMap: handleOpenMap, - field, + queryField, isFieldComplete, onChange: handleChange, onMoveUp: handleMoveUp, @@ -26,7 +32,7 @@ export function QueryLineTools({ readonly canOpenMap: boolean; }; readonly onOpenMap: (() => void) | undefined; - readonly field: QueryField; + readonly queryField: QueryField; readonly isFieldComplete: boolean; readonly onChange: ((newField: QueryField) => void) | undefined; readonly onMoveUp: (() => void) | undefined; @@ -53,7 +59,7 @@ export function QueryLineTools({ ) : undefined} {icons.check} - {field.sortType === 'ascending' + {queryField.sortType === 'ascending' ? icons.arrowCircleUp - : field.sortType === 'descending' + : queryField.sortType === 'descending' ? icons.arrowCircleDown : icons.circle} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 3678b72de17..12ccce0737b 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -474,30 +474,17 @@ function Wrapped({ )} - dispatch({ - type: 'ChangeFieldAction', - line, - field, - }) - } onChangeFields={ isReadOnly ? undefined - : (fields): void => - dispatch({ - type: 'ChangeFieldsAction', - fields, - }) + : (fields) => + dispatch({ type: 'ChangeFieldsAction', fields }) } onClose={(): void => dispatch({ diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts index d96bacb3a64..1c88cfb36db 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/helpers.ts @@ -21,7 +21,7 @@ import { splitJoinedMappingPath, valueIsToManyIndex, } from '../WbPlanView/mappingHelpers'; -import type { QueryFieldFilter } from './FieldFilter'; +import type { QueryFieldFilterType } from './FieldFilter'; import { queryFieldFilters } from './FieldFilter'; import { QueryFieldSpec } from './fieldSpec'; import { currentUserValue } from './SpecifyUserAutoComplete'; @@ -34,6 +34,30 @@ export const flippedSortTypes = { descending: 2, } as const; +export type QueryFieldFilter = { + readonly type: QueryFieldFilterType; + readonly startValue: string; + readonly isNot: boolean; + /** + * When 'isStrict' is True + * each CO's age_range must be fully contained by + * the age range provided by start_time and end_time filter (complete overlap). + * When 'isStrict' is False, only a partial overlap + * between a CO's age range and the provided + * start_time and end_time filter are needed. + */ + 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; +}; + export type QueryField = { /* * ID is used only as a React [key] prop in order to optimize rendering when @@ -43,21 +67,7 @@ export type QueryField = { readonly mappingPath: MappingPath; readonly sortType: SortTypes; readonly isDisplay: boolean; - 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; - }>; + readonly filters: RA; }; /** Convert SpQueryField to internal QueryField representation */ diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/reducer.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/reducer.ts index 5647d4ff472..05e61f412dc 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/reducer.ts +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/reducer.ts @@ -92,11 +92,11 @@ type Actions = export const reducer = generateReducer({ ResetStateAction: ({ action: { state } }) => ({ - ...state, + ...state, openedElement: { - line: (state.fields.length) - 1, - index: undefined - } + line: state.fields.length - 1, + index: undefined, + }, }), RunQueryAction: ({ state }) => ({ ...state, @@ -157,8 +157,8 @@ export const reducer = generateReducer({ mappingView: newMappingPath, openedElement: { line: state.fields.length, - index: undefined - } + index: undefined, + }, }; return { diff --git a/specifyweb/frontend/js_src/lib/components/Reports/Parameters.tsx b/specifyweb/frontend/js_src/lib/components/Reports/Parameters.tsx index 5b792028f9b..adfef9f8534 100644 --- a/specifyweb/frontend/js_src/lib/components/Reports/Parameters.tsx +++ b/specifyweb/frontend/js_src/lib/components/Reports/Parameters.tsx @@ -8,7 +8,7 @@ import { preferencesText } from '../../localization/preferences'; import { reportsText } from '../../localization/report'; import type { IR, RA } from '../../utils/types'; import { localized } from '../../utils/types'; -import { replaceItem, replaceKey } from '../../utils/utils'; +import { replaceKey } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { Form } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; @@ -119,13 +119,11 @@ export function QueryParametersDialog({ []} openedElement={undefined} showHiddenFields={false} - onChangeField={(line, field): void => - setFields(replaceItem(fields, line, field)) - } + fields={fields} + onChangeFields={(fields) => setFields(fields)} onClose={undefined} onLineFocus={undefined} onLineMove={undefined} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/TableUniquenessRules.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/TableUniquenessRules.tsx index 8b6d783d5ee..7dbefe3fd83 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/TableUniquenessRules.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/TableUniquenessRules.tsx @@ -118,7 +118,7 @@ export function TableUniquenessRules(): JSX.Element { buttons={ <> handleRuleValidation( diff --git a/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx b/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx index c6633066a53..e66a94f79eb 100644 --- a/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SearchDialog/index.tsx @@ -31,7 +31,7 @@ import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { ProtectedAction } from '../Permissions/PermissionDenied'; import { userPreferences } from '../Preferences/userPreferences'; import { createQuery } from '../QueryBuilder'; -import type { QueryFieldFilter } from '../QueryBuilder/FieldFilter'; +import type { QueryFieldFilterType } from '../QueryBuilder/FieldFilter'; import { queryFieldFilters } from '../QueryBuilder/FieldFilter'; import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; import { QueryBuilder } from '../QueryBuilder/Wrapped'; @@ -46,7 +46,7 @@ export type QueryComboBoxFilter = { readonly queryBuilderFieldPath?: MappingPath; readonly isRelationship: boolean; readonly isNot: boolean; - readonly operation: QueryFieldFilter & ('between' | 'in' | 'less'); + readonly operation: QueryFieldFilterType & ('between' | 'in' | 'less'); readonly value: string; }; diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/MergeRecordSets.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/MergeRecordSets.tsx index e295c16c775..72bdf129151 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/MergeRecordSets.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/MergeRecordSets.tsx @@ -14,10 +14,10 @@ import { RecordSetSelection } from '../SearchDialog/SelectRecordSet'; export function MergeRecordSets({ recordSets, - closeParent + closeParent, }: { readonly recordSets: RA> | undefined; - readonly closeParent: () => void + readonly closeParent: () => void; }): JSX.Element { const [isOpen, handleOpen, handleClose] = useBooleanState(); @@ -69,7 +69,7 @@ export function MergeRecordSets({ }).then(({ response }) => { if (!response.ok) return; handleClose(); - closeParent() + closeParent(); }) ); }; diff --git a/specifyweb/stored_queries/execution.py b/specifyweb/stored_queries/execution.py index 1d0cd46aac7..4caf14c7604 100644 --- a/specifyweb/stored_queries/execution.py +++ b/specifyweb/stored_queries/execution.py @@ -12,7 +12,9 @@ from django.conf import settings from django.db import transaction -from sqlalchemy import sql, orm, func, select +from specifyweb.specify.models import datamodel +from sqlalchemy import sql, orm, func +from sqlalchemy.orm import aliased from sqlalchemy.sql.expression import asc, desc, insert, literal from specifyweb.specify.field_change_info import FieldChangeInfo @@ -554,40 +556,6 @@ def run_ephemeral_query(collection, user, spquery): formatauditobjs=format_audits, ) - -def augment_field_specs(field_specs: List[QueryField], formatauditobjs=False): - print("augment_field_specs ######################################") - new_field_specs = [] - for fs in field_specs: - print(fs) - print(fs.fieldspec.table.tableId) - field = fs.fieldspec.join_path[-1] - model = models.models_by_tableid[fs.fieldspec.table.tableId] - if field.type == "java.util.Calendar": - precision_field = field.name + "Precision" - has_precision = hasattr(model, precision_field) - if has_precision: - new_field_specs.append( - make_augmented_field_spec(fs, model, precision_field) - ) - elif formatauditobjs and model.name.lower().startswith("spauditlog"): - if field.name.lower() in "newvalue, oldvalue": - log_model = models.models_by_tableid[530] - new_field_specs.append( - make_augmented_field_spec(fs, log_model, "TableNum") - ) - new_field_specs.append( - make_augmented_field_spec(fs, model, "FieldName") - ) - elif field.name.lower() == "recordid": - new_field_specs.append(make_augmented_field_spec(fs, model, "TableNum")) - print("################################ sceps_dleif_tnemgua") - - -def make_augmented_field_spec(field_spec, model, field_name): - print("make_augmented_field_spec ######################################") - - def recordset(collection, user, user_agent, recordset_info): "Create a record set from the records matched by a query." spquery = recordset_info["fromquery"] @@ -865,7 +833,6 @@ def build_query( order_by_exprs = [] selected_fields = [] predicates_by_field = defaultdict(list) - # augment_field_specs(field_specs, formatauditobjs) for fs in field_specs: sort_type = QuerySort.by_id(fs.sort_type)