diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 8ed0453e65..5b3df65bac 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,16 +1,16 @@ { "name": "@labkey/components", - "version": "6.7.0", + "version": "6.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.7.0", + "version": "6.8.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "17.0.0", - "@labkey/api": "1.36.0", + "@labkey/api": "1.37.0", "@testing-library/dom": "~10.4.0", "@testing-library/jest-dom": "~6.6.3", "@testing-library/react": "~16.0.1", @@ -3048,9 +3048,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.36.0.tgz", - "integrity": "sha512-cWQd1Umwkg7H/KLWpQ0I3p7GfLHw8kwFVAAtJZDeaykd21lyIypyOgQq+gLvmQJTAi9vRP4eaJ85L+b4o4x9Gw==" + "version": "1.37.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.37.0.tgz", + "integrity": "sha512-PIuzYGEm0O6ydWoXWEqCV+hHGqzDsVZ5Q3JD6i/d/bvp6On0jML9lnmz45hw4ZAXiwLSpd09whaTeSPVxnDxig==" }, "node_modules/@labkey/build": { "version": "8.3.0", diff --git a/packages/components/package.json b/packages/components/package.json index 3129d46c49..052a457a9a 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.7.0", + "version": "6.8.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ @@ -49,7 +49,7 @@ "homepage": "https://github.com/LabKey/labkey-ui-components#readme", "dependencies": { "@hello-pangea/dnd": "17.0.0", - "@labkey/api": "1.36.0", + "@labkey/api": "1.37.0", "@testing-library/dom": "~10.4.0", "@testing-library/jest-dom": "~6.6.3", "@testing-library/react": "~16.0.1", diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index f754807fe1..17a754666c 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,18 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 6.8.0 +*Released*: 19 December 2024 +- Add FilterCriteriaRenderer +- Add FilterCriteriaModal +- Add useLoadableState + - Helper hook that takes a loader method and returns loadingState, value, and error +- DomainField: Add filterCriteria +- Render Filter Criteria components in AssayDesignerPanels +- Add `request` method + - `request` is an async wrapper for `Ajax.request` which takes the same config as `Ajax.request` except `success` and + `failure` callbacks. Instead of using `success` and `failure` you `await request(config)` and `catch` errors. + ### version 6.7.0 *Released*: 18 December 2024 - Parent type selector updates for adding and removing from EditableGrid diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 4e51f36595..7c5aa38e5b 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -907,6 +907,7 @@ import { BaseModal, Modal, ModalHeader } from './internal/Modal'; import { Tab, Tabs } from './internal/Tabs'; import { CheckboxLK } from './internal/Checkbox'; import { ArchivedFolderTag } from './internal/components/folder/ArchivedFolderTag'; +import { FilterCriteriaRenderer } from './internal/FilterCriteriaRenderer'; // See Immer docs for why we do this: https://immerjs.github.io/immer/docs/installation#pick-your-immer-version enableMapSet(); @@ -1841,6 +1842,7 @@ export { CheckboxLK, // Custom labels getModuleCustomLabels, + FilterCriteriaRenderer, }; // Due to babel-loader & typescript babel plugins we need to export/import types separately. The babel plugins require diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx new file mode 100644 index 0000000000..33e383ea5d --- /dev/null +++ b/packages/components/src/internal/FilterCriteriaModal.tsx @@ -0,0 +1,201 @@ +import React, { FC, memo, useCallback, useMemo, useState } from 'react'; + +import { Filter } from '@labkey/api'; + +import { isLoading } from '../public/LoadingState'; + +import { QueryColumn } from '../public/QueryColumn'; + +import { Modal } from './Modal'; +import { DomainDesign, FilterCriteria, FilterCriteriaMap } from './components/domainproperties/models'; +import { useLoadableState } from './useLoadableState'; +import { LoadingSpinner } from './components/base/LoadingSpinner'; +import { ChoicesListItem } from './components/base/ChoicesListItem'; +import { FilterExpressionView } from './components/search/FilterExpressionView'; +import { useAppContext } from './AppContext'; +import { FilterCriteriaColumns } from './components/assay/models'; +import { AssayProtocolModel } from './components/domainproperties/assay/models'; +import { Alert } from './components/base/Alert'; + +type BaseFilterCriteriaField = Omit; +interface FilterCriteriaField extends BaseFilterCriteriaField { + isKeyField: boolean; + jsonType: string; +} +type FieldLoader = () => Promise; +type ReferenceFieldLoader = ( + protocolId: number, + columnNames: string[], + containerPath: string +) => Promise; + +function fieldLoaderFactory( + protocolId: number, + container: string, + domain: DomainDesign, + load: ReferenceFieldLoader +): FieldLoader { + return async () => { + const sourceFields = domain.fields + .filter(field => field.isFilterCriteriaField()) + .map(field => field.name) + .toArray(); + // The API returns an error if you don't pass any fields, so we can skip the API request + if (sourceFields.length === 0) return []; + const referenceFields = await load(protocolId, sourceFields, container); + + return Object.keys(referenceFields).reduce((result, sourceName) => { + const sourceField = domain.fields.find(field => field.name === sourceName); + result.push({ + propertyId: sourceField.propertyId, + isKeyField: sourceField.isPrimaryKey || sourceField.isUniqueIdField(), + jsonType: sourceField.dataType.getJsonType(), + name: sourceField.name, + }); + return result.concat( + referenceFields[sourceName].map((field, index) => ({ + isKeyField: false, + jsonType: field.dataType.getJsonType(), + name: field.name, + propertyId: field.propertyId === 0 ? sourceField.propertyId * 100 + index : field.propertyId, + referencePropertyId: sourceField.propertyId, + })) + ); + }, []); + }; +} + +/** + * openTo: The propertyId of the domain field you want to open the modal to + */ +interface Props { + onClose: () => void; + onSave: (filterCriteria: FilterCriteriaMap) => void; + openTo?: number; + protocolModel: AssayProtocolModel; +} + +export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, protocolModel }) => { + const { api } = useAppContext(); + const { protocolId, container } = protocolModel; + const domain = useMemo(() => protocolModel.getDomainByNameSuffix('Data'), [protocolModel]); + const [filterCriteria, setFilterCriteria] = useState(() => { + return domain.fields.reduce((result, field) => { + if (field.filterCriteria) result.set(field.propertyId, [...field.filterCriteria]); + return result; + }, new Map()); + }); + const loader = useMemo( + () => fieldLoaderFactory(protocolId, container, domain, api.assay.getFilterCriteriaColumns), + [api.assay.getFilterCriteriaColumns, container, domain, protocolId] + ); + const { error, loadingState, value: filterCriteriaFields } = useLoadableState(loader); + // If we're opening the modal to a specific field we only want to show that field and any related fields + const fieldsToRender = useMemo( + () => + filterCriteriaFields?.filter( + field => openTo === undefined || field.propertyId === openTo || field.referencePropertyId === openTo + ), + [filterCriteriaFields, openTo] + ); + const [selectedFieldId, setSelectedFieldId] = useState(openTo); + + const onSelect = useCallback((idx: number) => setSelectedFieldId(fieldsToRender[idx].propertyId), [fieldsToRender]); + + const onFieldFilterUpdate = useCallback( + (newFilters: Filter.IFilter[]) => { + setFilterCriteria(current => { + const filterCriteriaField = filterCriteriaFields.find(field => field.propertyId === selectedFieldId); + // Use the referencePropertyId if it exists, because all filterCriteria are stored on the parent field + const sourcePropertyId = filterCriteriaField.referencePropertyId ?? filterCriteriaField.propertyId; + // Remove the existing filter criteria for the filterCriteriaField + const existingValues = current + .get(sourcePropertyId) + .filter(value => value.propertyId !== filterCriteriaField.propertyId); + const newValues = newFilters.map(filter => ({ + name: filterCriteriaField.name, + op: filter.getFilterType().getURLSuffix(), + propertyId: filterCriteriaField.propertyId, + referencePropertyId: filterCriteriaField.referencePropertyId, + value: filter.getValue(), + })); + const updated = new Map(current); + updated.set(sourcePropertyId, existingValues.concat(newValues)); + return updated; + }); + }, + [filterCriteriaFields, selectedFieldId] + ); + + const onConfirm = useCallback(() => onSave(filterCriteria), [filterCriteria, onSave]); + + const fieldFilters = useMemo(() => { + const filterCriteriaField = filterCriteriaFields?.find(field => field.propertyId === selectedFieldId); + + if (!filterCriteriaField) return undefined; + + const sourcePropertyId = filterCriteriaField.referencePropertyId ?? filterCriteriaField.propertyId; + return filterCriteria + .get(sourcePropertyId) + .filter(value => value.propertyId === filterCriteriaField.propertyId) + .map(fc => Filter.create(fc.name, fc.value, Filter.Types[fc.op.toUpperCase()])); + }, [filterCriteriaFields, filterCriteria, selectedFieldId]); + + const currentColumn: QueryColumn = useMemo(() => { + const currentField = filterCriteriaFields?.find(field => field.propertyId === selectedFieldId); + if (currentField === undefined) return undefined; + + return new QueryColumn({ + caption: currentField.name, + fieldKey: currentField.name, + isKeyField: currentField.isKeyField, + jsonType: currentField.jsonType, + }); + }, [filterCriteriaFields, selectedFieldId]); + + const loading = isLoading(loadingState); + const hasFields = fieldsToRender !== undefined && fieldsToRender.length > 0; + + return ( + + {loading && } + {!loading && !error && ( +
+
+
Fields
+
+
+ {!hasFields && ( +
No fields defined yet.
+ )} + {fieldsToRender?.map((field, index) => ( + + ))} +
+
+
+
+
Filter Criteria
+
+ {!currentColumn &&
Select a field.
} + {currentColumn && ( + + )} +
+
+
+ )} + {error} +
+ ); +}); diff --git a/packages/components/src/internal/FilterCriteriaRenderer.tsx b/packages/components/src/internal/FilterCriteriaRenderer.tsx new file mode 100644 index 0000000000..c91c139cda --- /dev/null +++ b/packages/components/src/internal/FilterCriteriaRenderer.tsx @@ -0,0 +1,49 @@ +import React, { FC, memo, useMemo } from 'react'; + +import { DomainField, filterCriteriaToStr } from './components/domainproperties/models'; + +interface FieldWithCriteria { + field: DomainField; +} + +const FilterCriteriaField: FC = memo(({ field }) => { + return ( + <> + {field.filterCriteria.map(criteria => ( +
  • + {filterCriteriaToStr(criteria)} +
  • + ))} + + ); +}); +FilterCriteriaField.displayName = 'FilterCriteriaField'; + +interface Props { + fields: DomainField[]; + renderEmptyMessage?: boolean; +} + +export const FilterCriteriaRenderer: FC = memo(({ fields, renderEmptyMessage = true }) => { + const fieldsWithCriteria = useMemo( + () => fields.filter(field => field.filterCriteria && field.filterCriteria.length > 0), + [fields] + ); + const showEmptyMessage = fieldsWithCriteria.length === 0 && renderEmptyMessage; + + return ( +
    + {showEmptyMessage && ( +
    + No Hit Selection Criteria +
    + )} +
      + {fieldsWithCriteria.map(field => ( + + ))} +
    +
    + ); +}); +FilterCriteriaRenderer.displayName = 'FilterCriteriaRenderer'; diff --git a/packages/components/src/internal/components/assay/APIWrapper.ts b/packages/components/src/internal/components/assay/APIWrapper.ts index 6fe05aa9b4..dfde6a9059 100644 --- a/packages/components/src/internal/components/assay/APIWrapper.ts +++ b/packages/components/src/internal/components/assay/APIWrapper.ts @@ -1,7 +1,13 @@ +import { ActionURL } from '@labkey/api'; + import { AssayDefinitionModel } from '../../AssayDefinitionModel'; import { AssayProtocolModel } from '../domainproperties/assay/models'; +import { request } from '../../request'; + +import { DomainField } from '../domainproperties/models'; + import { checkForDuplicateAssayFiles, clearAssayDefinitionCache, @@ -13,12 +19,17 @@ import { ImportAssayRunOptions, importAssayRun, } from './actions'; -import { AssayUploadResultModel } from './models'; +import { AssayUploadResultModel, FilterCriteriaColumns } from './models'; export interface AssayAPIWrapper { checkForDuplicateAssayFiles: (fileNames: string[], containerPath?: string) => Promise; clearAssayDefinitionCache: () => void; getAssayDefinitions: (options: GetAssayDefinitionsOptions) => Promise; + getFilterCriteriaColumns: ( + protocolId: number, + columnNames: string[], + containerPath: string + ) => Promise; getProtocol: (options: GetProtocolOptions) => Promise; importAssayRun: (options: ImportAssayRunOptions) => Promise; } @@ -27,6 +38,24 @@ export class AssayServerAPIWrapper implements AssayAPIWrapper { checkForDuplicateAssayFiles = checkForDuplicateAssayFiles; clearAssayDefinitionCache = clearAssayDefinitionCache; getAssayDefinitions = getAssayDefinitions; + getFilterCriteriaColumns = async ( + protocolId: number, + columnNames: string[], + containerPath: string + ): Promise => { + const resp = await request( + { + url: ActionURL.buildURL('assay', 'filterCriteriaColumns.api', containerPath), + method: 'POST', + jsonData: { protocolId, columnNames }, + }, + 'Problem fetching filter criteria columns' + ); + return Object.keys(resp).reduce((result, key) => { + result[key] = resp[key].map(rawField => DomainField.create(rawField)); + return result; + }, {}); + }; getProtocol = getProtocol; importAssayRun = importAssayRun; } @@ -44,6 +73,7 @@ export function getAssayTestAPIWrapper( getAssayDefinitions: mockFn(), getProtocol: mockFn(), importAssayRun: mockFn(), + getFilterCriteriaColumns: mockFn(), ...overrides, }; } diff --git a/packages/components/src/internal/components/assay/models.ts b/packages/components/src/internal/components/assay/models.ts index 90aae01d09..cfadd30237 100644 --- a/packages/components/src/internal/components/assay/models.ts +++ b/packages/components/src/internal/components/assay/models.ts @@ -17,6 +17,7 @@ import { immerable, produce } from 'immer'; import { AssayDefinitionModel } from '../../AssayDefinitionModel'; import { LoadingState } from '../../../public/LoadingState'; +import { DomainField } from '../domainproperties/models'; export class AssayUploadResultModel { [immerable] = true; @@ -83,3 +84,5 @@ export class AssayStateModel { }); } } + +export type FilterCriteriaColumns = Record; diff --git a/packages/components/src/internal/components/domainproperties/DomainForm.tsx b/packages/components/src/internal/components/domainproperties/DomainForm.tsx index f948b3b71f..14b1faa9d7 100644 --- a/packages/components/src/internal/components/domainproperties/DomainForm.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainForm.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC, memo, PropsWithChildren, ReactNode } from 'react'; +import React, { ChangeEvent, FC, memo, PropsWithChildren, ReactNode, useCallback } from 'react'; import { List, Map } from 'immutable'; import { DragDropContext, Droppable } from '@hello-pangea/dnd'; import classNames from 'classnames'; @@ -104,6 +104,106 @@ import { SystemFields } from './SystemFields'; import { DomainPropertiesAPIWrapper } from './APIWrapper'; import { Collapsible } from './Collapsible'; +interface DomainFormToolbarProps { + disableExport: boolean; + domainFormDisplayOptions?: IDomainFormDisplayOptions; + domainIndex: number; + fields: List; + onAddField: () => void; + onBulkDeleteClick: () => void; + onExportFields: () => void; + onSearch: (value: string) => void; + onToggleSummaryView: () => void; + search: string; + shouldShowImportExport: boolean; + summaryViewMode: boolean; + visibleSelection: Set; +} + +const DomainFormToolbar: FC = memo(props => { + const { + disableExport, + domainFormDisplayOptions, + domainIndex, + fields, + search, + onAddField, + onBulkDeleteClick, + onExportFields, + onSearch, + onToggleSummaryView, + shouldShowImportExport, + summaryViewMode, + visibleSelection, + } = props; + const onSearchChange = useCallback( + (event: ChangeEvent) => onSearch(event.target.value), + [onSearch] + ); + return ( +
    +
    + {!domainFormDisplayOptions?.hideAddFieldsButton && ( + + )} + + Delete + + + {shouldShowImportExport && ( + + Export + + )} +
    +
    +
    + {!valueIsEmpty(search) && ( + + Showing {fields.filter(f => f.visible).size} of {fields.size} {' '} + field{fields.size > 1 ? 's' : ''}. + + )} + + +
    + Mode: + +
    +
    +
    +
    + ); +}); +DomainFormToolbar.displayName = 'DomainFormToolbar'; + export interface DomainFormProps extends PropsWithChildren { api?: DomainPropertiesAPIWrapper; appDomainHeaderRenderer?: HeaderRenderer; @@ -189,6 +289,9 @@ export class DomainFormImpl extends React.PureComponent domainFormDisplayOptions: DEFAULT_DOMAIN_FORM_DISPLAY_OPTIONS, // add configurations options to DomainForm through this object }; + // Negative index new propertyIds so we can easily differentiate between new and existing fields + propertyIdCounter = -1; + constructor(props: DomainFormProps) { super(props); @@ -591,7 +694,7 @@ export class DomainFormImpl extends React.PureComponent applyAddField = (config?: Partial): void => { const { newFieldConfig } = this.props; - const newConfig = config ? { ...config } : newFieldConfig; + const newConfig = Object.assign({}, config ? config : newFieldConfig, { propertyId: this.propertyIdCounter-- }); const newDomain = addDomainField(this.props.domain, newConfig); this.onDomainChange(newDomain, true); this.setState({ selectAll: false, visibleFieldsCount: getVisibleFieldCount(newDomain) }, this.collapseRow); @@ -1019,11 +1122,6 @@ export class DomainFormImpl extends React.PureComponent this.setState(state => ({ summaryViewMode: !state.summaryViewMode })); }; - onSearch = (evt): void => { - const { value } = evt.target; - this.updateFilteredFields(value); - }; - getFilteredFields = (domain: DomainDesign, value?: string): DomainDesign => { const filteredFields = domain.fields.map(field => { const fieldSearchMatch = @@ -1247,6 +1345,7 @@ export class DomainFormImpl extends React.PureComponent const disableExport = !hasFields || fields.filter(f => f.visible).size < 1; const hasException = domain.hasException(); const isApp_ = isApp(); + const showToolbar = hasFields || !(this.shouldShowInferFromFile() || this.shouldShowImportExport()); return ( <> @@ -1284,68 +1383,21 @@ export class DomainFormImpl extends React.PureComponent /> )} - {(hasFields || - !(this.shouldShowInferFromFile() || this.shouldShowImportExport())) && ( -
    -
    - {!domainFormDisplayOptions?.hideAddFieldsButton && ( - - )} - - Delete - - - {this.shouldShowImportExport() && ( - - {' '} - Export - - )} -
    -
    -
    - {!valueIsEmpty(search) && ( - - Showing {fields.filter(f => f.visible).size} of{' '} - {fields.size} field{fields.size > 1 ? 's' : ''}. - - )} - - -
    - Mode: - -
    -
    -
    -
    + {showToolbar && ( + )}
    @@ -1372,16 +1424,17 @@ export class DomainFormImpl extends React.PureComponent {hasFields && summaryViewMode && (
    )} diff --git a/packages/components/src/internal/components/domainproperties/DomainPropertiesGrid.tsx b/packages/components/src/internal/components/domainproperties/DomainPropertiesGrid.tsx index 1257f7ef04..34f2f09efc 100644 --- a/packages/components/src/internal/components/domainproperties/DomainPropertiesGrid.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainPropertiesGrid.tsx @@ -26,6 +26,7 @@ interface DomainPropertiesGridProps { hasOntologyModule: boolean; search: string; selectAll: boolean; + showFilterCriteria: boolean; } interface DomainPropertiesGridState { @@ -38,10 +39,10 @@ interface DomainPropertiesGridState { export class DomainPropertiesGrid extends React.PureComponent { constructor(props: DomainPropertiesGridProps) { super(props); - const { domain, actions, appPropertiesOnly, hasOntologyModule } = this.props; + const { domain, actions, appPropertiesOnly, hasOntologyModule, showFilterCriteria } = this.props; const { onFieldsChange, scrollFunction } = actions; const { domainKindName } = domain; - const gridData = domain.getGridData(appPropertiesOnly, hasOntologyModule); + const gridData = domain.getGridData(appPropertiesOnly, hasOntologyModule, showFilterCriteria); // TODO: Maintain hash of fieldIndex : gridIndex on state in order to make delete and filter run in N rather than N^2 time. this.state = { @@ -51,7 +52,8 @@ export class DomainPropertiesGrid extends React.PureComponent): void { - const { appPropertiesOnly, domain, hasOntologyModule } = this.props; + const { appPropertiesOnly, domain, hasOntologyModule, showFilterCriteria } = this.props; const prevSearch = prevProps.search; const newSearch = this.props.search; - const prevGridData = prevProps.domain.getGridData(appPropertiesOnly, hasOntologyModule); - const newGridData = domain.getGridData(appPropertiesOnly, hasOntologyModule); + const prevGridData = prevProps.domain.getGridData(appPropertiesOnly, hasOntologyModule, showFilterCriteria); + const newGridData = domain.getGridData(appPropertiesOnly, hasOntologyModule, showFilterCriteria); // When new field added if (prevGridData.size < newGridData.size) { @@ -93,9 +95,9 @@ export class DomainPropertiesGrid extends React.PureComponent { - const { appPropertiesOnly, domain, hasOntologyModule } = this.props; + const { appPropertiesOnly, domain, hasOntologyModule, showFilterCriteria } = this.props; const { gridData } = this.state; - const initGridData = domain.getGridData(appPropertiesOnly, hasOntologyModule); + const initGridData = domain.getGridData(appPropertiesOnly, hasOntologyModule, showFilterCriteria); // Handle bug that occurs if multiple fields have the same name const replaceGridData = new Set(gridData.map(row => row.get('name')).toJS()).size !== gridData.size; @@ -117,9 +119,9 @@ export class DomainPropertiesGrid extends React.PureComponent { - const { appPropertiesOnly, domain, hasOntologyModule } = this.props; + const { appPropertiesOnly, domain, hasOntologyModule, showFilterCriteria } = this.props; const { gridData } = this.state; - const initGridData = domain.getGridData(appPropertiesOnly, hasOntologyModule); + const initGridData = domain.getGridData(appPropertiesOnly, hasOntologyModule, showFilterCriteria); const updatedGridData = gridData.map(row => { const nextRowIndex = initGridData.findIndex(nextRow => nextRow.get('fieldIndex') === row.get('fieldIndex')); @@ -131,9 +133,9 @@ export class DomainPropertiesGrid extends React.PureComponent { - const { appPropertiesOnly, domain, hasOntologyModule } = this.props; + const { appPropertiesOnly, domain, hasOntologyModule, showFilterCriteria } = this.props; const { gridData } = this.state; - const initGridData = domain.getGridData(appPropertiesOnly, hasOntologyModule); + const initGridData = domain.getGridData(appPropertiesOnly, hasOntologyModule, showFilterCriteria); for (let i = 0; i < gridData.size; i++) { const row = gridData.get(i); diff --git a/packages/components/src/internal/components/domainproperties/DomainRow.tsx b/packages/components/src/internal/components/domainproperties/DomainRow.tsx index f633cd1f6d..2f85300ace 100644 --- a/packages/components/src/internal/components/domainproperties/DomainRow.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainRow.tsx @@ -236,6 +236,7 @@ export class DomainRow extends React.PureComponent { domainFormDisplayOptions, getDomainFields, } = this.props; + const showFilterCriteria = domainFormDisplayOptions.showFilterCriteria && field.isFilterCriteriaField(); return (
    @@ -324,6 +326,7 @@ export class DomainRowExpandedOptions extends React.Component { />
    )} + {showFilterCriteria && }
    ); diff --git a/packages/components/src/internal/components/domainproperties/FieldFilterCriteria.tsx b/packages/components/src/internal/components/domainproperties/FieldFilterCriteria.tsx new file mode 100644 index 0000000000..003a972916 --- /dev/null +++ b/packages/components/src/internal/components/domainproperties/FieldFilterCriteria.tsx @@ -0,0 +1,43 @@ +import React, { FC, memo, useCallback, useMemo } from 'react'; + +import { FilterCriteriaRenderer } from '../../FilterCriteriaRenderer'; + +import { SectionHeading } from './SectionHeading'; +import { DomainField } from './models'; +import { useFilterCriteriaContext } from './assay/FilterCriteriaContext'; + +interface Props { + field: DomainField; +} + +export const FieldFilterCriteria: FC = memo(({ field }) => { + const { propertyId } = field; + const context = useFilterCriteriaContext(); + const openModal = context?.openModal; + const onClick = useCallback(() => openModal(propertyId), [openModal, propertyId]); + const fields = useMemo(() => [field], [field]); + + if (!context) return null; + + return ( +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + ); +}); +FieldFilterCriteria.displayName = 'FieldFilterCriteria'; diff --git a/packages/components/src/internal/components/domainproperties/Lookup/Fields.tsx b/packages/components/src/internal/components/domainproperties/Lookup/Fields.tsx index e4e1c2f693..1f99ba355a 100644 --- a/packages/components/src/internal/components/domainproperties/Lookup/Fields.tsx +++ b/packages/components/src/internal/components/domainproperties/Lookup/Fields.tsx @@ -269,7 +269,7 @@ class TargetTableSelectImpl extends React.Component = {}): DomainField { - // Issue 38771: if the domain has a defaultDefaultValueType and the fieldConfig doesn't include its own, use the defaultDefaultValueType + // Issue 38771: if the domain has a defaultDefaultValueType and the fieldConfig doesn't include its own, use the + // defaultDefaultValueType if (domain.defaultDefaultValueType && !fieldConfig.defaultValueType) { fieldConfig.defaultValueType = domain.defaultDefaultValueType; } @@ -636,7 +638,8 @@ export async function mergeDomainFields(domain: DomainDesign, newFields: List { + // Note: we can't simply do name.replace(field.name, updatedName) because the name could be something like + // Mean, which would cause the calculated field Mean_Mean to become an empty string, which is bad. + const name = updatedName + criteria.name.slice(field.name.length); + + return { ...criteria, name }; + }); + + return field.set('filterCriteria', filterCriteria) as DomainField; +} + export function updateDomainField(domain: DomainDesign, change: IFieldChange): DomainDesign { const type = getNameFromId(change.id); const index = getIndexFromId(change.id); @@ -818,6 +834,11 @@ export function updateDomainField(domain: DomainDesign, change: IFieldChange): D const concept = change.value as ConceptModel; newField = newField.merge({ principalConceptCode: concept?.code }) as DomainField; break; + case DOMAIN_FIELD_NAME: + // Note: it's important to update filter criteria names, because if the field we're updating is new + // we rely on the name when saving. For existing fields we can rely on propertyId. + newField = updateFilterCriteriaNames(newField, change.value); + // eslint-disable-next-line no-fallthrough -- Intentionally falling through here default: newField = newField.set(type, change.value) as DomainField; break; diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.test.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.test.tsx index 19ee249e5e..ffd6bef455 100644 --- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.test.tsx +++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.test.tsx @@ -48,7 +48,7 @@ const EXISTING_MODEL = AssayProtocolModel.create({ ], }, { - name: 'Sample Fields', + name: 'Data Fields', fields: [ { name: 'field1', @@ -365,7 +365,7 @@ describe('AssayDesignerPanels', () => { ); diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx index 24d8d6ac7d..1c76621913 100644 --- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx +++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { FC, memo, useCallback, useMemo } from 'react'; import { List, Map } from 'immutable'; import { getDefaultAPIWrapper } from '../../../APIWrapper'; @@ -6,7 +6,9 @@ import { DomainPropertiesAPIWrapper } from '../APIWrapper'; import { DomainDesign, + DomainField, DomainFieldIndexChange, + FilterCriteriaMap, HeaderRenderer, IDomainFormDisplayOptions, IFieldChange, @@ -27,13 +29,127 @@ import { AssayRunDataType } from '../../entities/constants'; import { FolderConfigurableDataType } from '../../entities/models'; +import { FilterCriteriaModal } from '../../../FilterCriteriaModal'; + import { saveAssayDesign } from './actions'; import { AssayProtocolModel } from './models'; import { AssayPropertiesPanel } from './AssayPropertiesPanel'; +import { FilterCriteriaContext } from './FilterCriteriaContext'; const PROPERTIES_PANEL_INDEX = 0; const DOMAIN_PANEL_INDEX = 1; +interface AssayDomainFormProps + extends Omit { + api: DomainPropertiesAPIWrapper; + appDomainHeaders: Map; + domain: DomainDesign; + domainFormDisplayOptions: IDomainFormDisplayOptions; + headerPrefix: string; + hideAdvancedProperties?: boolean; + index: number; + onDomainChange: ( + index: number, + updatedDomain: DomainDesign, + dirty: boolean, + rowIndexChange?: DomainFieldIndexChange[], + changes?: List + ) => void; + protocolModel: AssayProtocolModel; +} + +const AssayDomainForm: FC = memo(props => { + const { + api, + appDomainHeaders, + currentPanelIndex, + domain, + domainFormDisplayOptions, + firstState, + headerPrefix, + hideAdvancedProperties, + index, + onDomainChange, + onTogglePanel, + protocolModel, + validatePanel, + visitedPanels, + } = props; + const onChange = useCallback( + (updatedDomain, dirty, rowIndexChange, changes) => { + onDomainChange(index, updatedDomain, dirty, rowIndexChange, changes); + }, + [index, onDomainChange] + ); + const onToggle = useCallback( + (collapsed: boolean, callback: () => void) => { + onTogglePanel(index + DOMAIN_PANEL_INDEX, collapsed, callback); + }, + [index, onTogglePanel] + ); + const appDomainHeaderRenderer = useMemo(() => { + if (!appDomainHeaders) return; + + const headerKey = appDomainHeaders.keySeq().find(key => domain.isNameSuffixMatch(key)); + + return appDomainHeaders.get(headerKey); + }, [appDomainHeaders, domain]); + const displayOptions = useMemo(() => { + const isGpat = protocolModel.providerName === GENERAL_ASSAY_PROVIDER_NAME; + const isResultsDomain = domain.isNameSuffixMatch('Data'); + const isRunDomain = domain.isNameSuffixMatch('Run'); + const hideFilePropertyType = + domainFormDisplayOptions.hideFilePropertyType && !domain.isNameSuffixMatch('Batch') && !isRunDomain; + const hideInferFromFile = !isGpat || !isResultsDomain; + const textChoiceLockedForDomain = !( + (isRunDomain && protocolModel.editableRuns) || + (isResultsDomain && protocolModel.editableResults) + ); + return { + ...domainFormDisplayOptions, + domainKindDisplayName: 'assay design', + hideFilePropertyType, + hideInferFromFile, + showFilterCriteria: isResultsDomain && protocolModel.plateMetadata, + textChoiceLockedForDomain, + }; + }, [ + domain, + domainFormDisplayOptions, + protocolModel.editableResults, + protocolModel.editableRuns, + protocolModel.plateMetadata, + protocolModel.providerName, + ]); + return ( + +
    {domain.description}
    +
    + ); +}); + export interface AssayDesignerPanelsProps { allowFolderExclusion?: boolean; api?: DomainPropertiesAPIWrapper; @@ -55,13 +171,13 @@ export interface AssayDesignerPanelsProps { type Props = AssayDesignerPanelsProps & InjectedBaseDomainDesignerProps; interface State { + modalOpen: boolean; + openTo?: number; protocolModel: AssayProtocolModel; } // Exported for testing export class AssayDesignerPanelsImpl extends React.PureComponent { - panelCount = 1; // start at 1 for the AssayPropertiesPanel, will updated count after domains are defined in constructor - static defaultProps = { api: getDefaultAPIWrapper().domain, domainFormDisplayOptions: DEFAULT_DOMAIN_FORM_DISPLAY_OPTIONS, @@ -70,9 +186,9 @@ export class AssayDesignerPanelsImpl extends React.PureComponent { constructor(props: Props) { super(props); - this.panelCount = this.panelCount + props.initModel.domains.size; - this.state = { + modalOpen: false, + openTo: undefined, protocolModel: props.initModel, }; } @@ -204,27 +320,74 @@ export class AssayDesignerPanelsImpl extends React.PureComponent { }); }; - getAppDomainHeaderRenderer = (domain: DomainDesign): HeaderRenderer => { - const { appDomainHeaders } = this.props; + onUpdateExcludedFolders = (_: FolderConfigurableDataType, excludedContainerIds: string[]): void => { + const { protocolModel } = this.state; + const newModel = protocolModel.merge({ excludedContainerIds }) as AssayProtocolModel; + this.onAssayPropertiesChange(newModel); + }; - if (!appDomainHeaders) return undefined; + openModal = (openTo?: number): void => { + this.setState({ modalOpen: true, openTo }); + }; - return appDomainHeaders.filter((v, k) => domain.isNameSuffixMatch(k)).first(); + closeModal = (): void => { + this.setState({ modalOpen: false, openTo: undefined }); }; - onUpdateExcludedFolders = (_: FolderConfigurableDataType, excludedContainerIds: string[]): void => { + saveFilterCriteria = (filterCriteria: FilterCriteriaMap) => { + this.setState(current => { + const protocolModel = current.protocolModel; + const resultsIndex = current.protocolModel.domains.findIndex((domain: DomainDesign): boolean => + domain.isNameSuffixMatch('Data') + ); + const domains = current.protocolModel.domains; + let resultsDomain = domains.get(resultsIndex); + // Clear the existing values first + let fields = resultsDomain.fields.map(f => f.set('filterCriteria', []) as DomainField).toList(); + + filterCriteria.forEach((fieldCriteria, propertyId) => { + const domainFieldIdx = fields.findIndex(d => d.propertyId === propertyId); + + if (domainFieldIdx < 0) { + console.warn(`Unable to find domain field with property id ${propertyId}`); + return; + } + + let domainField = fields.get(domainFieldIdx); + domainField = domainField.set('filterCriteria', fieldCriteria) as DomainField; + fields = fields.set(domainFieldIdx, domainField); + }); + + resultsDomain = resultsDomain.set('fields', fields) as DomainDesign; + + return { + modalOpen: false, + openTo: undefined, + protocolModel: protocolModel.set( + 'domains', + protocolModel.domains.set(resultsIndex, resultsDomain) + ) as AssayProtocolModel, + }; + }); + }; + + togglePropertiesPanel = (collapsed, callback): void => { + this.props.onTogglePanel(PROPERTIES_PANEL_INDEX, collapsed, callback); + }; + + toggleFoldersPanel = (collapsed, callback): void => { const { protocolModel } = this.state; - const newModel = protocolModel.merge({ excludedContainerIds }) as AssayProtocolModel; - this.onAssayPropertiesChange(newModel); + this.props.onTogglePanel(protocolModel.domains.size + 1, collapsed, callback); }; render() { const { allowFolderExclusion, - initModel, api, + appDomainHeaders, appPropertiesOnly, hideAdvancedProperties, + initModel, domainFormDisplayOptions, currentPanelIndex, validatePanel, @@ -235,9 +398,14 @@ export class AssayDesignerPanelsImpl extends React.PureComponent { onCancel, saveBtnText, } = this.props; - const { protocolModel } = this.state; - + const { modalOpen, openTo, protocolModel } = this.state; const isGpat = protocolModel.providerName === GENERAL_ASSAY_PROVIDER_NAME; + + const filterCriteriaState = { openModal: this.openModal }; + const panelStatus = protocolModel.isNew() + ? getDomainPanelStatus(PROPERTIES_PANEL_INDEX, currentPanelIndex, visitedPanels, firstState) + : 'COMPLETE'; + return ( { onFinish={this.onFinish} saveBtnText={saveBtnText} > - { - onTogglePanel(PROPERTIES_PANEL_INDEX, collapsed, callback); - }} - canRename={isGpat} - /> - {protocolModel.domains - .map((domain, i) => { - // optionally hide the Batch Fields domain from the UI - if (this.shouldSkipBatchDomain(domain)) { - return; + + {appPropertiesOnly && allowFolderExclusion && ( { dataTypeName={protocolModel?.name} entityDataType={AssayRunDataType} initCollapsed={currentPanelIndex !== protocolModel.domains.size + 1} - onToggle={(collapsed, callback) => { - onTogglePanel(protocolModel.domains.size + 1, collapsed, callback); - }} + onToggle={this.toggleFoldersPanel} onUpdateExcludedFolders={this.onUpdateExcludedFolders} /> )} @@ -353,4 +484,4 @@ export class AssayDesignerPanelsImpl extends React.PureComponent { } export const AssayDesignerPanels = withBaseDomainDesigner(AssayDesignerPanelsImpl); -AssayDesignerPanels.displayName = 'AssayDesignerPanels' +AssayDesignerPanels.displayName = 'AssayDesignerPanels'; diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx index c0873ac4c7..c1e4310287 100644 --- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx +++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx @@ -1,4 +1,4 @@ -import React, { FC, memo, PropsWithChildren, ReactNode } from 'react'; +import React, { FC, memo, PropsWithChildren, ReactNode, useCallback, useMemo } from 'react'; import { List, Map } from 'immutable'; import classNames from 'classnames'; @@ -11,32 +11,26 @@ import { RUN_PROPERTIES_TOPIC, } from '../../../util/helpLinks'; import { DomainFieldLabel, DomainFieldLabelProps } from '../DomainFieldLabel'; - import { AutoLinkToStudyDropdown } from '../AutoLinkToStudyDropdown'; - import { buildURL } from '../../../url/AppURL'; import { Container } from '../../base/models/Container'; import { AddEntityButton } from '../../buttons/AddEntityButton'; import { RemoveEntityButton } from '../../buttons/RemoveEntityButton'; - import { FileAttachmentForm } from '../../../../public/files/FileAttachmentForm'; import { getWebDavFiles, getWebDavUrl, uploadWebDavFileToUrl } from '../../../../public/files/WebDav'; - import { Alert } from '../../base/Alert'; - import { AttachmentCard, IAttachment } from '../../../renderers/AttachmentCard'; - import { getAttachmentTitleFromName } from '../../../renderers/FileColumnRenderer'; - import { setCopyValue } from '../../../events'; - import { getFileExtension } from '../../files/actions'; - import { resolveErrorMessage } from '../../../util/messaging'; +import { FilterCriteriaRenderer } from '../../../FilterCriteriaRenderer'; +import { DisableableButton } from '../../buttons/DisableableButton'; import { AssayProtocolModel } from './models'; import { FORM_IDS, SCRIPTS_DIR } from './constants'; import { getScriptEngineForExtension, getValidPublishTargets } from './actions'; +import { useFilterCriteriaContext } from './FilterCriteriaContext'; interface AssayPropertiesInputProps extends DomainFieldLabelProps, PropsWithChildren { colSize?: number; @@ -78,232 +72,212 @@ interface InputProps { onChange: (evt) => void; } -export function NameInput(props: InputProps) { - return ( - - - - ); -} - -export function DescriptionInput(props: InputProps) { - return ( - A short description for this assay design.

    } - > -