From 4b668b5c6328fedf7deb649291db52041692422a Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 18 Nov 2024 16:15:16 -0600
Subject: [PATCH 01/46] Fix unique key warnings in domain designer
---
.../domainproperties/Lookup/Fields.tsx | 2 +-
.../components/entities/DataTypeSelector.tsx | 26 +++++++++----------
2 files changed, 13 insertions(+), 15 deletions(-)
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 = memo(props =>
return (
- {subList?.map((type, index) => {
- return (
-
- );
- })}
+ {subList?.map((type, index) => (
+
+ ))}
);
From c026642a2619a95597e755cca880f92a6358ce4c Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 21 Nov 2024 17:36:48 -0600
Subject: [PATCH 02/46] CLean up filter panel styles
---
packages/components/src/theme/filter.scss | 29 ++++-------------------
1 file changed, 5 insertions(+), 24 deletions(-)
diff --git a/packages/components/src/theme/filter.scss b/packages/components/src/theme/filter.scss
index 5da70df027..3ced587dcc 100644
--- a/packages/components/src/theme/filter.scss
+++ b/packages/components/src/theme/filter.scss
@@ -85,21 +85,7 @@
}
.field-modal__col {
- padding-right: 0;
- padding-left: 8px;
- }
-
- .field-modal__col-2 {
- padding-right: 0;
- padding-left: 15px;
- }
-
- .field-modal__col.col-sm-6, .field-modal__col.col-xs-6, .field-modal__col.col-xs-12 {
- padding-right: 8px;
- }
-
- .field-modal__col-2.col-sm-6, .field-modal__col-2.col-xs-6, .field-modal__col-2.col-xs-12 {
- padding-right: 15px;
+ padding: 0 8px;
}
.col-xs-12 .field-modal__footer {
@@ -114,7 +100,6 @@
overflow: auto;
border: 1px solid $light-gray;
- margin-bottom: 20px;
.loading-spinner {
display: inline-block;
@@ -125,10 +110,6 @@
border: none;
}
- .field-modal__tabs {
- padding: 8px 15px;
- }
-
.field-modal__tabs.content-tabs {
.nav-tabs {
border-bottom: 1px solid $light-gray;
@@ -144,6 +125,7 @@
&.field-modal__values {
overflow: visible;
+ padding: 8px 16px;
}
}
@@ -174,17 +156,14 @@
}
.filter-expression__input {
- width: 75%;
padding-left: 15px;
}
.filter-expression__textarea {
- width: 75%;
resize: none !important;
}
.filter-expression__input-select {
- width: 75%;
// Issue 45139
.select-input__menu-portal {
@@ -289,6 +268,8 @@
.field-modal__col-content-disabled {
opacity: 0.5;
-
}
+.field-modal__container .list-group {
+ margin-bottom: 0;
+}
From 07bc2b0c748453b6ee129ddbe682221214824454 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 21 Nov 2024 17:37:36 -0600
Subject: [PATCH 03/46] Add HitCriteriaRenderer
---
.../src/internal/HitCriteriaRenderer.tsx | 48 +++++++++++++++++++
1 file changed, 48 insertions(+)
create mode 100644 packages/components/src/internal/HitCriteriaRenderer.tsx
diff --git a/packages/components/src/internal/HitCriteriaRenderer.tsx b/packages/components/src/internal/HitCriteriaRenderer.tsx
new file mode 100644
index 0000000000..38298e0c60
--- /dev/null
+++ b/packages/components/src/internal/HitCriteriaRenderer.tsx
@@ -0,0 +1,48 @@
+import React, { FC, memo, useMemo } from 'react';
+import { Filter } from '@labkey/api';
+
+import { DomainField } from './components/domainproperties/models';
+import { HitCriteria, hitCriteriaKey } from './components/domainproperties/assay/models';
+
+interface FieldWithCriteria {
+ criteria: Filter.IFilter[];
+ field: DomainField;
+}
+
+const HitCriteriaField: FC = memo(({ criteria, field }) => {
+ return (
+ <>
+ {criteria.map((filter, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ {field.name} {filter.getFilterType().getDisplaySymbol()} {filter.getValue()}
+
+ ))}
+ >
+ );
+});
+
+interface Props {
+ criteria: HitCriteria;
+ fields: DomainField[];
+}
+
+export const HitCriteriaRenderer: FC = memo(({ fields, criteria }) => {
+ const fieldsWithCriteria = useMemo(
+ () =>
+ fields.reduce((result, field) => {
+ const key = hitCriteriaKey(field);
+ if (criteria[key]) result.push({ field, criteria: criteria[key] });
+ return result;
+ }, [] as FieldWithCriteria[]),
+ [fields, criteria]
+ );
+
+ return (
+
+ {fieldsWithCriteria.map(field => (
+
+ ))}
+
+ );
+});
From 27275cc1b5c160cafe805ff7828e5a88a33887c3 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 21 Nov 2024 17:39:50 -0600
Subject: [PATCH 04/46] Add HitCriteriaModal and useLoadableState
---
.../src/internal/HitCriteriaModal.tsx | 118 ++++++++++++++++++
.../src/internal/useLoadableState.ts | 53 ++++++++
2 files changed, 171 insertions(+)
create mode 100644 packages/components/src/internal/HitCriteriaModal.tsx
create mode 100644 packages/components/src/internal/useLoadableState.ts
diff --git a/packages/components/src/internal/HitCriteriaModal.tsx b/packages/components/src/internal/HitCriteriaModal.tsx
new file mode 100644
index 0000000000..a2e571aa6b
--- /dev/null
+++ b/packages/components/src/internal/HitCriteriaModal.tsx
@@ -0,0 +1,118 @@
+import React, { FC, memo, useCallback, useMemo, useState } from 'react';
+
+import { isLoading } from '../public/LoadingState';
+
+import { QueryColumn } from '../public/QueryColumn';
+
+import { Modal } from './Modal';
+import { DomainDesign, DomainField } from './components/domainproperties/models';
+import { AssayProtocolModel, HitCriteria, hitCriteriaKey } from './components/domainproperties/assay/models';
+import { useLoadableState } from './useLoadableState';
+import { LoadingSpinner } from './components/base/LoadingSpinner';
+import { ChoicesListItem } from './components/base/ChoicesListItem';
+import { useHitCriteriaContext } from './components/domainproperties/assay/HitCriteriaContext';
+import { FilterExpressionView } from './components/search/FilterExpressionView';
+
+async function fetchQueryColumns(domain: DomainDesign): Promise {
+ // TODO: use API to load fields so we get the extra fields like STD Deviation, Median, etc.
+ return domain.fields
+ .map(field => {
+ return new QueryColumn({
+ fieldKey: field.name,
+ caption: field.name,
+ isKeyField: field.isPrimaryKey || field.isUniqueIdField(),
+ });
+ })
+ .toArray();
+}
+
+/**
+ * openTo: The propertyId of the domain field you want to open the modal to
+ */
+interface Props {
+ loadQueryColumns?: (domain: DomainDesign) => Promise;
+ model: AssayProtocolModel;
+ onClose: () => void;
+ onSave: (hitCriteria: HitCriteria) => void;
+ openTo?: number;
+}
+
+export const HitCriteriaModal: FC = memo(props => {
+ const { loadQueryColumns = fetchQueryColumns, model, onClose, onSave, openTo } = props;
+ const { hitCriteria: initialHitCriteria } = useHitCriteriaContext();
+ const [hitCriteria, setHitCriteria] = useState(initialHitCriteria);
+ const domain = useMemo(() => model.domains.find(domain => domain.isNameSuffixMatch('Data')), [model.domains]);
+ const load = useCallback(() => loadQueryColumns(domain), [loadQueryColumns, domain]);
+ const { loadingState, value: columns } = useLoadableState(load);
+ const [selectedFieldKey, setSelectedFieldKey] = useState(() => {
+ return domain.fields.find(field => field.propertyId === openTo)?.name;
+ });
+ const currentColumn = useMemo(
+ () => columns?.find(column => column.fieldKey === selectedFieldKey),
+ [columns, selectedFieldKey]
+ );
+ const onSelect = useCallback((idx: number) => setSelectedFieldKey(columns[idx].fieldKey), [columns]);
+ const onFieldFilterUpdate = useCallback(
+ newFilters => {
+ setHitCriteria(current => {
+ const domainField = domain.fields.find(field => field.name === currentColumn.fieldKey);
+ const key = hitCriteriaKey(domainField);
+ return {
+ ...current,
+ [key]: newFilters,
+ };
+ });
+ },
+ [currentColumn?.fieldKey, domain.fields]
+ );
+ const onConfirm = useCallback(() => onSave(hitCriteria), [hitCriteria, onSave]);
+ const loading = isLoading(loadingState);
+ const fieldFilters = useMemo(() => {
+ if (!selectedFieldKey) return undefined;
+
+ const domainField = domain.fields.find(field => field.name === selectedFieldKey);
+ console.log('hitCriteriaKey:', hitCriteriaKey(domainField));
+ console.log('hitCriteria:', hitCriteria);
+ return hitCriteria[hitCriteriaKey(domainField)];
+ }, [domain.fields, hitCriteria, selectedFieldKey]);
+
+ console.log('Field Filters:', fieldFilters);
+
+ return (
+
+ {loading && }
+ {!loading && (
+
+
+
Fields
+
+
+ {columns.map((column, index) => (
+
+ ))}
+
+
+
+
+
Filter Criteria
+
+ {currentColumn && (
+
+ )}
+
+
+
+ )}
+
+ );
+});
diff --git a/packages/components/src/internal/useLoadableState.ts b/packages/components/src/internal/useLoadableState.ts
new file mode 100644
index 0000000000..1fe59aeb5a
--- /dev/null
+++ b/packages/components/src/internal/useLoadableState.ts
@@ -0,0 +1,53 @@
+import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
+
+import { LoadingState } from '../public/LoadingState';
+
+import { resolveErrorMessage } from './util/messaging';
+
+interface LoadableState {
+ error: string;
+ load: () => Promise;
+ loadingState: LoadingState;
+ setError: Dispatch>;
+ setLoadingState: Dispatch>;
+ setValue: Dispatch>;
+ value: T;
+}
+
+type Loader = () => Promise;
+
+export function useLoadableState(loader: Loader): LoadableState {
+ const [error, setError] = useState(undefined);
+ const [loadingState, setLoadingState] = useState(LoadingState.INITIALIZED);
+ const [value, setValue] = useState(undefined);
+ const load = useCallback(async () => {
+ setLoadingState(LoadingState.LOADING);
+
+ try {
+ const result = await loader();
+ setValue(result);
+ } catch (e) {
+ setError(resolveErrorMessage(e));
+ } finally {
+ setLoadingState(LoadingState.LOADED);
+ }
+ }, [loader]);
+ const state = useMemo(
+ () => ({
+ error,
+ load,
+ loadingState,
+ setError,
+ setLoadingState,
+ setValue,
+ value,
+ }),
+ [error, load, loadingState, value]
+ );
+
+ useEffect(() => {
+ load();
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return state;
+}
From 2db3dd80141c65a5fae1a1311d03a79fff3bab54 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 21 Nov 2024 17:48:26 -0600
Subject: [PATCH 05/46] Render HitCriteria
- Render HitCriteriaModal
- Add AssayDomainForm, which allows us to memoize props
---
.../domainproperties/DomainForm.tsx | 5 +-
.../DomainRowExpandedOptions.tsx | 2 +
.../domainproperties/FieldHitCriteria.tsx | 42 ++
.../components/domainproperties/actions.ts | 6 +-
.../assay/AssayDesignerPanels.tsx | 302 +++++---
.../assay/AssayPropertiesInput.tsx | 675 +++++++++---------
.../assay/AssayPropertiesPanel.tsx | 29 +-
.../assay/HitCriteriaContext.ts | 11 +
.../domainproperties/assay/models.ts | 24 +-
.../components/domainproperties/models.tsx | 3 +-
.../src/theme/domainproperties.scss | 8 +
11 files changed, 646 insertions(+), 461 deletions(-)
create mode 100644 packages/components/src/internal/components/domainproperties/FieldHitCriteria.tsx
create mode 100644 packages/components/src/internal/components/domainproperties/assay/HitCriteriaContext.ts
diff --git a/packages/components/src/internal/components/domainproperties/DomainForm.tsx b/packages/components/src/internal/components/domainproperties/DomainForm.tsx
index f948b3b71f..a975bff7a5 100644
--- a/packages/components/src/internal/components/domainproperties/DomainForm.tsx
+++ b/packages/components/src/internal/components/domainproperties/DomainForm.tsx
@@ -189,6 +189,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 +594,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);
diff --git a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
index b4a6f79cc5..3ae3dc8ac3 100644
--- a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
+++ b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
@@ -34,6 +34,7 @@ import { TextChoiceOptions } from './TextChoiceOptions';
import { FileAttachmentOptions } from './FileAttachmentOptions';
import { CalculatedFieldOptions } from './CalculatedFieldOptions';
import { CALCULATED_TYPE } from './PropDescType';
+import { FieldHitCriteria } from './FieldHitCriteria';
interface Props {
appPropertiesOnly?: boolean;
@@ -324,6 +325,7 @@ export class DomainRowExpandedOptions extends React.Component {
/>
)}
+ {domainFormDisplayOptions.showHitCriteria && }
);
diff --git a/packages/components/src/internal/components/domainproperties/FieldHitCriteria.tsx b/packages/components/src/internal/components/domainproperties/FieldHitCriteria.tsx
new file mode 100644
index 0000000000..546c47a1f7
--- /dev/null
+++ b/packages/components/src/internal/components/domainproperties/FieldHitCriteria.tsx
@@ -0,0 +1,42 @@
+import React, { FC, memo, useCallback, useMemo } from 'react';
+
+import { SectionHeading } from './SectionHeading';
+import { DomainField } from './models';
+import { useHitCriteriaContext } from './assay/HitCriteriaContext';
+import { HitCriteriaRenderer } from '../../HitCriteriaRenderer';
+
+interface Props {
+ field: DomainField;
+}
+
+export const FieldHitCriteria: FC = memo(({ field }) => {
+ const { propertyId } = field;
+ const context = useHitCriteriaContext();
+ const openModal = context?.openModal;
+ const onClick = useCallback(() => openModal(propertyId), [openModal, propertyId]);
+ // TODO: need to get the generate field (STD Dev, Median, etc)
+ const fields = useMemo(() => [field], [field]);
+
+ if (!context) return null;
+
+ return (
+
+
+
+
+
+
+ Edit Criteria
+
+
+
+
+
+
+
+ );
+});
diff --git a/packages/components/src/internal/components/domainproperties/actions.ts b/packages/components/src/internal/components/domainproperties/actions.ts
index 48c2042cd2..5d83d709d6 100644
--- a/packages/components/src/internal/components/domainproperties/actions.ts
+++ b/packages/components/src/internal/components/domainproperties/actions.ts
@@ -622,7 +622,8 @@ export function validateDomainNameExpressions(
}
export function createNewDomainField(domain: DomainDesign, fieldConfig: Partial = {}): 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 +637,8 @@ export async function mergeDomainFields(domain: DomainDesign, newFields: List {
+ api: DomainPropertiesAPIWrapper;
+ appDomainHeaders?: Map;
+ domain: DomainDesign;
+ domainFormDisplayOptions: IDomainFormDisplayOptions;
+ 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,
+ 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,
+ textChoiceLockedForDomain,
+ showHitCriteria: isResultsDomain,
+ };
+ }, [
+ domain,
+ domainFormDisplayOptions,
+ protocolModel.editableResults,
+ protocolModel.editableRuns,
+ protocolModel.providerName,
+ ]);
+ return (
+
+ {domain.description}
+
+ );
+});
+
export interface AssayDesignerPanelsProps {
allowFolderExclusion?: boolean;
api?: DomainPropertiesAPIWrapper;
@@ -55,13 +166,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 +181,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,24 +315,38 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
});
};
- getAppDomainHeaderRenderer = (domain: DomainDesign): HeaderRenderer => {
- const { appDomainHeaders } = this.props;
-
- if (!appDomainHeaders) return undefined;
-
- return appDomainHeaders.filter((v, k) => domain.isNameSuffixMatch(k)).first();
- };
-
onUpdateExcludedFolders = (_: FolderConfigurableDataType, excludedContainerIds: string[]): void => {
const { protocolModel } = this.state;
const newModel = protocolModel.merge({ excludedContainerIds }) as AssayProtocolModel;
this.onAssayPropertiesChange(newModel);
};
+ openModal = (openTo?: number): void => {
+ this.setState({ modalOpen: true, openTo });
+ };
+
+ closeModal = (): void => {
+ this.setState({ modalOpen: false, openTo: undefined });
+ };
+
+ saveHitCriteria = (hitCriteria: HitCriteria) => {
+ this.setState(current => {
+ // Note: use protocolModel.set instead of merge so hitCriteria doesn't get converted to an immutable object
+ return {
+ modalOpen: false,
+ openTo: undefined,
+ protocolModel: current.protocolModel.set('hitCriteria', hitCriteria) as AssayProtocolModel,
+ };
+ });
+ };
+
+ togglePropertiesPanel = (collapsed, callback): void => {
+ this.props.onTogglePanel(PROPERTIES_PANEL_INDEX, collapsed, callback);
+ };
+
render() {
const {
allowFolderExclusion,
- initModel,
api,
appPropertiesOnly,
hideAdvancedProperties,
@@ -235,9 +360,17 @@ 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 hitCriteriaState = {
+ openModal: this.openModal,
+ hitCriteria: protocolModel.hitCriteria,
+ };
+ 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;
+
+ {
- this.onDomainChange(i, updatedDomain, dirty, rowIndexChange, changes);
- }}
- onToggle={(collapsed, callback) => {
- onTogglePanel(i + DOMAIN_PANEL_INDEX, collapsed, callback);
- }}
- appDomainHeaderRenderer={appDomainHeaderRenderer}
- modelDomains={protocolModel.domains}
- appPropertiesOnly={hideAdvancedProperties}
- domainFormDisplayOptions={{
- ...domainFormDisplayOptions,
- domainKindDisplayName: 'assay design',
- hideFilePropertyType,
- hideInferFromFile,
- textChoiceLockedForDomain,
- }}
- >
- {domain.description}
-
- );
- })
- .toArray()}
+ onToggle={this.togglePropertiesPanel}
+ canRename={isGpat}
+ />
+ {protocolModel.domains
+ .toArray()
+ // FIXME: For some reason if you filter here it forces the results domain to render twice. Even
+ // if you keep the shouldSkipBatchDomain check in the map method below.
+ // .filter(domain => !this.shouldSkipBatchDomain(domain))
+ .map((domain, i) => {
+ // optionally hide the Batch Fields domain from the UI
+ if (this.shouldSkipBatchDomain(domain)) return;
+
+ return (
+
+ );
+ })}
+ {modalOpen && (
+
+ )}
+
{appPropertiesOnly && allowFolderExclusion && (
{
}
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..41763cf619 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';
@@ -37,6 +37,8 @@ import { resolveErrorMessage } from '../../../util/messaging';
import { AssayProtocolModel } from './models';
import { FORM_IDS, SCRIPTS_DIR } from './constants';
import { getScriptEngineForExtension, getValidPublishTargets } from './actions';
+import { useHitCriteriaContext } from './HitCriteriaContext';
+import { HitCriteriaRenderer } from '../../../HitCriteriaRenderer';
interface AssayPropertiesInputProps extends DomainFieldLabelProps, PropsWithChildren {
colSize?: number;
@@ -78,232 +80,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.
}
- >
-
-
- );
-}
-
-export const QCStatesInput: FC = props => {
- return (
-
- If enabled, QC states can be configured and assigned on a per run basis to control the visibility of
- imported run data. Users not in the QC Analyst role will not be able to view non-public data.
-
- }
- >
-
-
- );
-};
-
-export function PlateTemplatesInput(props: InputProps) {
- return (
-
- Specify the plate template definition used to map spots or wells on the plate to data fields in this
- assay design. More info
-
- }
- >
-
-
- {props.model.availablePlateTemplates.map((type, i) => (
-
- {type}
-
- ))}
-
-
- Configure Templates
-
-
- );
-}
-
-export function DetectionMethodsInput(props: InputProps) {
- return (
-
-
-
- {props.model.availableDetectionMethods.map((method, i) => (
-
- {method}
-
- ))}
-
-
- );
-}
-
-export function MetadataInputFormatsInput(props: InputProps) {
- return (
-
-
- Manual: Metadata is provided as form based manual entry.
-
-
- File Upload (metadata only): Metadata is provided from a file upload (separate
- from the run data file).
-
-
- Combined File Upload (metadata & run data): Metadata and run data are combined
- into a single file upload.
-
- >
- }
+export const NameInput: FC = memo(props => (
+
+
+
+));
+
+export const DescriptionInput: FC = memo(props => (
+ A short description for this assay design.}
+ >
+
+
+));
+
+export const QCStatesInput: FC = memo(props => (
+
+ If enabled, QC states can be configured and assigned on a per run basis to control the visibility of
+ imported run data. Users not in the QC Analyst role will not be able to view non-public data.
+
+ }
+ >
+
+
+));
+
+export const PlateTemplatesInput: FC = memo(props => (
+
+ Specify the plate template definition used to map spots or wells on the plate to data fields in this
+ assay design. More info
+
+ }
+ >
+
-
- {Object.keys(props.model.availableMetadataInputFormats).map((key, i) => (
-
- {props.model.availableMetadataInputFormats[key]}
-
- ))}
-
-
- );
-}
-
-export const AssayStatusInput = props => {
- return (
- If disabled, this assay design will be considered archived, and will be hidden in certain views.
- }
+
+ {props.model.availablePlateTemplates.map((type, i) => (
+
+ {type}
+
+ ))}
+
+
+ Configure Templates
+
+
+));
+
+export const DetectionMethodsInput: FC = memo(props => (
+
+
-
-
- );
-};
-
-export function EditableRunsInput(props: InputProps) {
- return (
-
+ {props.model.availableDetectionMethods.map(method => (
+
+ {method}
+
+ ))}
+
+
+));
+
+export const MetadataInputFormatsInput: FC = memo(props => (
+
- If enabled, users with sufficient permissions can edit values at the run level after the initial
- import is complete. These changes will be audited.
+ Manual: Metadata is provided as form based manual entry.
- }
- >
-
-
- );
-}
-
-export function EditableResultsInput(props: InputProps) {
- return (
-
- If enabled, users with sufficient permissions can edit and delete at the individual results row
- level after the initial import is complete. New result rows cannot be added to existing runs. These
- changes will be audited.
+ File Upload (metadata only): Metadata is provided from a file upload (separate
+ from the run data file).
- }
- >
-
-
- );
-}
-
-export function BackgroundUploadInput(props: InputProps) {
- return (
-
- If enabled, assay imports will be processed as jobs in the data pipeline. If there are any errors
- during the import, they can be viewed from the log file for that job.
+ Combined File Upload (metadata & run data): Metadata and run data are combined
+ into a single file upload.
- }
+ >
+ }
+ >
+
-
-
- );
-}
+ {Object.keys(props.model.availableMetadataInputFormats).map((key, i) => (
+
+ {props.model.availableMetadataInputFormats[key]}
+
+ ))}
+
+
+));
+
+export const AssayStatusInput: FC = memo(props => (
+ If disabled, this assay design will be considered archived, and will be hidden in certain views.
+ }
+ >
+
+
+));
+
+export const EditableRunsInput: FC = memo((props: InputProps) => (
+
+ If enabled, users with sufficient permissions can edit values at the run level after the initial import
+ is complete. These changes will be audited.
+
+ }
+ >
+
+
+));
+
+export const EditableResultsInput: FC = memo(props => (
+
+ If enabled, users with sufficient permissions can edit and delete at the individual results row level
+ after the initial import is complete. New result rows cannot be added to existing runs. These changes
+ will be audited.
+
+ }
+ >
+
+
+));
+
+export const BackgroundUploadInput: FC = memo(props => (
+
+ If enabled, assay imports will be processed as jobs in the data pipeline. If there are any errors during
+ the import, they can be viewed from the log file for that job.
+
+ }
+ >
+
+
+));
interface AutoLinkDataInputState {
containers: Container[];
@@ -359,70 +341,63 @@ export class AutoLinkDataInput extends React.PureComponent = memo(props => {
- const { model, onChange } = props;
-
- return (
-
-
- Specify the desired category for the Assay Dataset that will be created (or appended to) in the
- target study when rows are linked. If the category you specify does not exist, it will be
- created.
-
-
- If the Assay Dataset already exists, this setting will not overwrite a previously assigned
- category. Leave blank to use the default category of "Uncategorized".
-
- >
- }
- >
-
-
- );
-});
+export const AutoLinkCategoryInput: FC = memo(({ model, onChange }) => (
+
+
+ Specify the desired category for the Assay Dataset that will be created (or appended to) in the
+ target study when rows are linked. If the category you specify does not exist, it will be created.
+
+
+ If the Assay Dataset already exists, this setting will not overwrite a previously assigned category.
+ Leave blank to use the default category of "Uncategorized".
+
+ >
+ }
+ >
+
+
+));
interface ModuleProvidedScriptsInputProps {
model: AssayProtocolModel;
}
-export function ModuleProvidedScriptsInput(props: ModuleProvidedScriptsInputProps) {
- return (
-
-
- These scripts are part of the assay type and cannot be removed. They will run after any custom
- scripts configured above.
-
-
- The extension of the script file identifies the scripting engine that will be used to run the
- validation script. For example, a script named test.pl will be run with the Perl scripting
- engine. The scripting engine must be configured on the Views and Scripting page in the Admin
- Console. More info
-
- >
- }
- >
- {props.model.moduleTransformScripts
- .map((script, i) => (
-
- {script}
-
- ))
- .toArray()}
-
- );
-}
+export const ModuleProvidedScriptsInput: FC = props => (
+
+
+ These scripts are part of the assay type and cannot be removed. They will run after any custom
+ scripts configured above.
+
+
+ The extension of the script file identifies the scripting engine that will be used to run the
+ validation script. For example, a script named test.pl will be run with the Perl scripting engine.
+ The scripting engine must be configured on the Views and Scripting page in the Admin Console.{' '}
+ More info
+
+ >
+ }
+ >
+ {props.model.moduleTransformScripts
+ .map((script, i) => (
+
+ {script}
+
+ ))
+ .toArray()}
+
+);
enum AddingScriptType {
file,
@@ -689,75 +664,89 @@ export class TransformScriptsInput extends React.PureComponent
-
- Typically transform and validation script data files are deleted on script completion. For debug
- purposes, it can be helpful to be able to view the files generated by the server that are passed
- to the script.
-
+export const SaveScriptDataInput: FC = memo(({ model, onChange }) => (
+
+
+ Typically transform and validation script data files are deleted on script completion. For debug
+ purposes, it can be helpful to be able to view the files generated by the server that are passed to
+ the script.
+
+
+ If this checkbox is checked, files will be saved to a subfolder named:
+ "TransformAndValidationFiles", located in the same folder that the original script is located.
+
+ {!model.isNew() && (
- If this checkbox is checked, files will be saved to a subfolder named:
- "TransformAndValidationFiles", located in the same folder that the original script is located.
+ Use the "Download template files" link to get example files for your assay design.{' '}
+
+ More info
+
- {!model.isNew() && (
-
- Use the "Download template files" link to get example files for your assay design.{' '}
-
- More info
-
-
- )}
- >
- }
- >
-
- {!model.isNew() && (
-
- )}
-
- );
-}
+ )}
+ >
+ }
+ >
+
+ {!model.isNew() && (
+
+ )}
+
+));
+
+export const PlateMetadataInput: FC = memo(props => (
+
+ If enabled, plate template metadata can be added on a per run basis to combine tabular data that has
+ well location information with plate based data.
+
+ }
+ >
+
+
+));
+
+export const HitCriteriaInput: FC = memo(({ model }) => {
+ const context = useHitCriteriaContext();
+
+ if (!context) return null;
+
+ const { openModal } = context;
+ const onClick = useCallback(() => openModal(), [openModal]);
+ const domain = useMemo(() => model.domains.find(domain => domain.isNameSuffixMatch('Data')), [model.domains]);
-export function PlateMetadataInput(props: InputProps) {
return (
-
- If enabled, plate template metadata can be added on a per run basis to combine tabular data that has
- well location information with plate based data.
-
- }
- >
-
+
+
+
+
+ Edit Criteria
+
+
+
+
+
+
);
-}
+});
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
index 29d1531434..b84a9f4a77 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
@@ -30,6 +30,7 @@ import {
DetectionMethodsInput,
EditableResultsInput,
EditableRunsInput,
+ HitCriteriaInput,
MetadataInputFormatsInput,
ModuleProvidedScriptsInput,
NameInput,
@@ -122,13 +123,6 @@ const AssayPropertiesForm: FC = memo(props => {
onChange={onInputChange}
hideAdvancedProperties={hideAdvancedProperties}
/>
- {model.allowPlateTemplateSelection() && (
-
- )}
{model.allowDetectionMethodSelection() && (
= memo(props => {
{!hideAdvancedProperties && model.allowQCStates && isAssayQCEnabled(moduleContext) && (
)}
- {(!appPropertiesOnly || isPlatesEnabled(moduleContext)) && model.allowPlateMetadata && (
-
- )}
{isPremiumProductEnabled(moduleContext) && (
= memo(props => {
)}
+ {(!appPropertiesOnly || isPlatesEnabled(moduleContext)) && (
+
+
+
+ {model.allowPlateTemplateSelection() && (
+
+ )}
+ {model.allowPlateMetadata &&
}
+
+
+
+
+ )}
+
{!hideAdvancedProperties && !hideStudyProperties && hasModule('study', moduleContext) && (
diff --git a/packages/components/src/internal/components/domainproperties/assay/HitCriteriaContext.ts b/packages/components/src/internal/components/domainproperties/assay/HitCriteriaContext.ts
new file mode 100644
index 0000000000..8ea0a93839
--- /dev/null
+++ b/packages/components/src/internal/components/domainproperties/assay/HitCriteriaContext.ts
@@ -0,0 +1,11 @@
+import { createContext, useContext } from 'react';
+import { HitCriteria } from './models';
+
+export interface HitCriteriaContextState {
+ hitCriteria: HitCriteria;
+ openModal: (openToPropertyId?: number) => void;
+}
+
+export const HitCriteriaContext = createContext
(undefined);
+
+export const useHitCriteriaContext = () => useContext(HitCriteriaContext);
diff --git a/packages/components/src/internal/components/domainproperties/assay/models.ts b/packages/components/src/internal/components/domainproperties/assay/models.ts
index f85e4a9ff7..34ef903bb4 100644
--- a/packages/components/src/internal/components/domainproperties/assay/models.ts
+++ b/packages/components/src/internal/components/domainproperties/assay/models.ts
@@ -13,22 +13,38 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { List, Record } from 'immutable';
+import { List, Record as ImmutableRecord } from 'immutable';
+import { Filter } from '@labkey/api';
import { getServerContext, Utils } from '@labkey/api';
-import { DomainDesign, FieldErrors } from '../models';
+import { DomainDesign, DomainField, FieldErrors } from '../models';
import { AppURL } from '../../../url/AppURL';
import { getAppHomeFolderPath } from '../../../app/utils';
import { Container } from '../../base/models/Container';
+// HitCriteria should be stored as a Record, where the key is in the form of: propertyId: or fieldKey:
+// We use propertyId: to reference existing fields, and fieldKey: to reference new fields when sending
+// to the server. However, when working locally we always use propertyId: and use fake (negative indexed)
+// propertyIds in order to prevent issues when users re-name new fields.
+export type HitCriteria = Record;
+
+// Locally we always want to use the propertyId to handle changes to the field name, but during upload we have to use
+// the field name for new fields.
+export function hitCriteriaKey(field: DomainField, forUpload?: false): string {
+ const useFieldKey = forUpload && field.propertyId < 0;
+ const prefix = useFieldKey ? 'fieldKey' : 'propertyId';
+ const value = useFieldKey ? field.name : field.propertyId;
+ return `${prefix}:${value}`;
+}
+
// See ExpProtocol.Status in 'platform' repository.
export enum Status {
Active = 'Active',
Archived = 'Archived',
}
-export class AssayProtocolModel extends Record({
+export class AssayProtocolModel extends ImmutableRecord({
allowBackgroundUpload: false,
allowEditableResults: false,
allowQCStates: false,
@@ -41,6 +57,7 @@ export class AssayProtocolModel extends Record({
availableMetadataInputFormats: undefined,
availablePlateTemplates: undefined,
backgroundUpload: false,
+ hitCriteria: {},
description: undefined,
domains: undefined,
editableResults: false,
@@ -74,6 +91,7 @@ export class AssayProtocolModel extends Record({
declare availableMetadataInputFormats: {};
declare availablePlateTemplates: [];
declare backgroundUpload: boolean;
+ declare hitCriteria: HitCriteria;
declare description: string;
declare domains: List;
declare editableResults: boolean;
diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx
index a2902ed16a..a361b29829 100644
--- a/packages/components/src/internal/components/domainproperties/models.tsx
+++ b/packages/components/src/internal/components/domainproperties/models.tsx
@@ -1486,7 +1486,7 @@ export function updateSampleField(field: Partial, sampleQueryValue?
}
function isFieldNew(field: Partial): boolean {
- return field.propertyId === undefined;
+ return field.propertyId === undefined || field.propertyId < 0;
}
function isFieldSaved(field: Partial): boolean {
@@ -2065,6 +2065,7 @@ export interface IDomainFormDisplayOptions {
phiLevelDisabled?: boolean;
retainReservedFields?: boolean;
showScannableOption?: boolean;
+ showHitCriteria?: boolean;
textChoiceLockedForDomain?: boolean;
textChoiceLockedSqlFragment?: string;
}
diff --git a/packages/components/src/theme/domainproperties.scss b/packages/components/src/theme/domainproperties.scss
index b9fd6ca513..bad07c83a9 100644
--- a/packages/components/src/theme/domainproperties.scss
+++ b/packages/components/src/theme/domainproperties.scss
@@ -950,3 +950,11 @@
height: 34px;
}
}
+.hit-selection-criteria-input {
+ display: flex;
+ gap: 16px;
+}
+
+.field-modal__container.hit-criteria-modal-body .field-modal__col-content.field-modal__values {
+ padding: 16px;
+}
From 34a69e129007521a61828f11e1df26d0f450d982 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 21 Nov 2024 17:48:38 -0600
Subject: [PATCH 06/46] Update release notes
---
packages/components/releaseNotes/components.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md
index f754807fe1..8c96ac1622 100644
--- a/packages/components/releaseNotes/components.md
+++ b/packages/components/releaseNotes/components.md
@@ -1,6 +1,16 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages
+### version ?.??.?
+*Released*: ?? 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` an async wrapper for Ajax.request
+
### version 6.7.0
*Released*: 18 December 2024
- Parent type selector updates for adding and removing from EditableGrid
From 6cbdf4d2ead448f6121acd6eebd29c8b143f30bb Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 21 Nov 2024 17:49:04 -0600
Subject: [PATCH 07/46] FilterExpressionView: memoize callbacks
---
.../search/FilterExpressionView.tsx | 22 +++++++++++++------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/packages/components/src/internal/components/search/FilterExpressionView.tsx b/packages/components/src/internal/components/search/FilterExpressionView.tsx
index c352222aa4..b453c8f1ac 100644
--- a/packages/components/src/internal/components/search/FilterExpressionView.tsx
+++ b/packages/components/src/internal/components/search/FilterExpressionView.tsx
@@ -115,7 +115,7 @@ export const FilterExpressionView: FC = memo(props => {
);
const onFieldFilterTypeChange = useCallback(
- (fieldname: any, filterUrlSuffix: any, filterIndex: number) => {
+ (filterUrlSuffix: string, filterIndex: number) => {
const newActiveFilterType = fieldFilterOptions?.find(option => option.value === filterUrlSuffix);
const { shouldClear, filterSelection } = getUpdatedFilterSelection(
newActiveFilterType,
@@ -128,6 +128,18 @@ export const FilterExpressionView: FC = memo(props => {
},
[fieldFilterOptions, activeFilters]
);
+ const onUpdateFirstField = useCallback(
+ (_, filterUrlSuffix: string) => {
+ onFieldFilterTypeChange(filterUrlSuffix, 0);
+ },
+ [onFieldFilterTypeChange]
+ );
+ const onUpdateSecondField = useCallback(
+ (_, filterUrlSuffix: string) => {
+ onFieldFilterTypeChange(filterUrlSuffix, 1);
+ },
+ [onFieldFilterTypeChange]
+ );
const updateBooleanFilterFieldValue = useCallback(
(filterIndex: number, event: any) => {
@@ -400,9 +412,7 @@ export const FilterExpressionView: FC = memo(props => {
inputClass="filter-expression__input-select"
placeholder="Select a filter type..."
value={activeFilters[0]?.filterType?.value}
- onChange={(fieldname: any, filterUrlSuffix: any) =>
- onFieldFilterTypeChange(fieldname, filterUrlSuffix, 0)
- }
+ onChange={onUpdateFirstField}
options={unusedFilterOptions(0)}
disabled={disabled}
/>
@@ -417,9 +427,7 @@ export const FilterExpressionView: FC = memo(props => {
inputClass="filter-expression__input-select"
placeholder="Select a filter type..."
value={activeFilters[1]?.filterType?.value}
- onChange={(fieldname: any, filterUrlSuffix: any) =>
- onFieldFilterTypeChange(fieldname, filterUrlSuffix, 1)
- }
+ onChange={onUpdateSecondField}
options={unusedFilterOptions(1)}
menuPosition="fixed"
disabled={disabled}
From 12c29dd980ef8e0ee5c50962aba365520baa75b4 Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 25 Nov 2024 11:26:54 -0600
Subject: [PATCH 08/46] AssayDesignerPanels: Fix issue with appDomainHeaders,
fix tests
---
.../assay/AssayDesignerPanels.test.tsx | 4 ++--
.../domainproperties/assay/AssayDesignerPanels.tsx | 13 +++++++++----
.../domainproperties/assay/AssayPropertiesInput.tsx | 2 ++
3 files changed, 13 insertions(+), 6 deletions(-)
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 6c96a9289b..66efb48c2e 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -40,7 +40,7 @@ const DOMAIN_PANEL_INDEX = 1;
interface AssayDomainFormProps
extends Omit {
api: DomainPropertiesAPIWrapper;
- appDomainHeaders?: Map;
+ appDomainHeaders: Map;
domain: DomainDesign;
domainFormDisplayOptions: IDomainFormDisplayOptions;
hideAdvancedProperties?: boolean;
@@ -344,10 +344,16 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
this.props.onTogglePanel(PROPERTIES_PANEL_INDEX, collapsed, callback);
};
+ toggleFoldersPanel = (collapsed, callback): void => {
+ const { protocolModel } = this.state;
+ this.props.onTogglePanel(protocolModel.domains.size + 1, collapsed, callback);
+ };
+
render() {
const {
allowFolderExclusion,
api,
+ appDomainHeaders,
appPropertiesOnly,
hideAdvancedProperties,
domainFormDisplayOptions,
@@ -411,6 +417,7 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
return (
{
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}
/>
)}
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
index 41763cf619..cb3770d1d4 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
@@ -735,6 +735,8 @@ export const HitCriteriaInput: FC = memo(({ model }) => {
const onClick = useCallback(() => openModal(), [openModal]);
const domain = useMemo(() => model.domains.find(domain => domain.isNameSuffixMatch('Data')), [model.domains]);
+ if (!domain) return null;
+
return (
From 8a36111b7070bf3344765e9fcff32df1c4f140d4 Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 25 Nov 2024 14:49:08 -0600
Subject: [PATCH 09/46] Cleanup
---
.../assay/AssayDesignerPanels.tsx | 50 +++++++++----------
1 file changed, 23 insertions(+), 27 deletions(-)
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
index 66efb48c2e..df3fedf989 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -405,33 +405,29 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
onToggle={this.togglePropertiesPanel}
canRename={isGpat}
/>
- {protocolModel.domains
- .toArray()
- // FIXME: For some reason if you filter here it forces the results domain to render twice. Even
- // if you keep the shouldSkipBatchDomain check in the map method below.
- // .filter(domain => !this.shouldSkipBatchDomain(domain))
- .map((domain, i) => {
- // optionally hide the Batch Fields domain from the UI
- if (this.shouldSkipBatchDomain(domain)) return;
-
- return (
-
- );
- })}
+ {/* Note: We cannot filter this array because onChange needs the correct index for each domain */}
+ {protocolModel.domains.toArray().map((domain, i) => {
+ // optionally hide the Batch Fields domain from the UI
+ if (this.shouldSkipBatchDomain(domain)) return null;
+
+ return (
+
+ );
+ })}
{modalOpen && (
Date: Fri, 6 Dec 2024 10:38:09 -0800
Subject: [PATCH 10/46] Prevent serialization of "hitCriteria" (stop gap)
---
.../src/internal/components/domainproperties/assay/models.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/components/src/internal/components/domainproperties/assay/models.ts b/packages/components/src/internal/components/domainproperties/assay/models.ts
index 34ef903bb4..8dea2c7226 100644
--- a/packages/components/src/internal/components/domainproperties/assay/models.ts
+++ b/packages/components/src/internal/components/domainproperties/assay/models.ts
@@ -159,6 +159,10 @@ export class AssayProtocolModel extends ImmutableRecord({
delete json.autoCopyTargetContainer;
delete json.exception;
+ // TODO: "hitCriteria" have been moved to DomainField and are now called "filterCriteria".
+ // Removing for now so result can be saved without throwing a server error.
+ delete json.hitCriteria;
+
return json;
}
From ec826a84d607412246ecb40d99d1db77629a983f Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 11 Dec 2024 10:30:28 -0600
Subject: [PATCH 11/46] Bump @labkey/api
---
packages/components/package-lock.json | 8 ++++----
packages/components/package.json | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json
index 8ed0453e65..ef01b048fe 100644
--- a/packages/components/package-lock.json
+++ b/packages/components/package-lock.json
@@ -10,7 +10,7 @@
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@hello-pangea/dnd": "17.0.0",
- "@labkey/api": "1.36.0",
+ "@labkey/api": "1.36.0-fb-auto-hits.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.36.0-fb-auto-hits.0",
+ "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.36.0-fb-auto-hits.0.tgz",
+ "integrity": "sha512-RCtNg7XXVvYacOw5fHGZi3VpSraUu0HlO6P9aC1fm7XqKY0bkdF57yPxmbjAwvTHzjszCjDcb6kzuE84iT37hA=="
},
"node_modules/@labkey/build": {
"version": "8.3.0",
diff --git a/packages/components/package.json b/packages/components/package.json
index 3129d46c49..308163172a 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -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.36.0-fb-auto-hits.0",
"@testing-library/dom": "~10.4.0",
"@testing-library/jest-dom": "~6.6.3",
"@testing-library/react": "~16.0.1",
From 27f15a3e87841ec50dc1fa91909bcbef7b464aab Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 11 Dec 2024 13:54:40 -0600
Subject: [PATCH 12/46] Add request util
---
packages/components/releaseNotes/components.md | 4 +++-
packages/components/src/internal/request.ts | 17 +++++++++++++++++
2 files changed, 20 insertions(+), 1 deletion(-)
create mode 100644 packages/components/src/internal/request.ts
diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md
index 8c96ac1622..6db9af3b92 100644
--- a/packages/components/releaseNotes/components.md
+++ b/packages/components/releaseNotes/components.md
@@ -9,7 +9,9 @@ Components, models, actions, and utility functions for LabKey applications and p
- Helper hook that takes a loader method and returns loadingState, value, and error
- DomainField: Add filterCriteria
- Render Filter Criteria components in AssayDesignerPanels
-- Add `request` an async wrapper for Ajax.request
+- 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
diff --git a/packages/components/src/internal/request.ts b/packages/components/src/internal/request.ts
new file mode 100644
index 0000000000..4af093ad13
--- /dev/null
+++ b/packages/components/src/internal/request.ts
@@ -0,0 +1,17 @@
+import { Ajax, Utils, RequestOptions } from '@labkey/api';
+
+import { handleRequestFailure } from './util/utils';
+
+type Options = Omit;
+
+export function request(options: Options, errorLogMsg = 'Error making ajax request'): Promise {
+ return new Promise((resolve, reject) => {
+ Ajax.request({
+ ...options,
+ success: Utils.getCallbackWrapper((res: T) => {
+ resolve(res);
+ }),
+ failure: handleRequestFailure(reject, errorLogMsg),
+ });
+ });
+}
From db6e8ccfca53ae0b521a2c75287ad87aaabad625 Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 11 Dec 2024 18:25:59 -0600
Subject: [PATCH 13/46] Add getFilterCriteriaColumns to assay API wrapper
---
.../internal/components/assay/APIWrapper.ts | 26 ++++++++++++++++++-
.../src/internal/components/assay/models.ts | 3 +++
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/packages/components/src/internal/components/assay/APIWrapper.ts b/packages/components/src/internal/components/assay/APIWrapper.ts
index 6fe05aa9b4..08fa4a0283 100644
--- a/packages/components/src/internal/components/assay/APIWrapper.ts
+++ b/packages/components/src/internal/components/assay/APIWrapper.ts
@@ -1,7 +1,11 @@
+import { ActionURL } from '@labkey/api';
+
import { AssayDefinitionModel } from '../../AssayDefinitionModel';
import { AssayProtocolModel } from '../domainproperties/assay/models';
+import { request } from '../../request';
+
import {
checkForDuplicateAssayFiles,
clearAssayDefinitionCache,
@@ -13,12 +17,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 +36,20 @@ export class AssayServerAPIWrapper implements AssayAPIWrapper {
checkForDuplicateAssayFiles = checkForDuplicateAssayFiles;
clearAssayDefinitionCache = clearAssayDefinitionCache;
getAssayDefinitions = getAssayDefinitions;
+ getFilterCriteriaColumns = async (
+ protocolId: number,
+ columnNames: string[],
+ containerPath: string
+ ): Promise => {
+ return request(
+ {
+ url: ActionURL.buildURL('assay', 'filterCriteriaColumns.api', containerPath),
+ method: 'POST',
+ jsonData: { protocolId, columnNames },
+ },
+ 'Problem fetching filter criteria columns'
+ );
+ };
getProtocol = getProtocol;
importAssayRun = importAssayRun;
}
@@ -44,6 +67,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..e3b8b97a7a 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 { IDomainField } from '../domainproperties/models';
export class AssayUploadResultModel {
[immerable] = true;
@@ -83,3 +84,5 @@ export class AssayStateModel {
});
}
}
+
+export type FilterCriteriaColumns = Record;
From 3673d934a9732b7fe6b01359b57b5ca6fde4fb09 Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 11 Dec 2024 18:29:46 -0600
Subject: [PATCH 14/46] Update AssayDesigner to assume filterCriteria is on
DomainFields
---
.../src/internal/FilterCriteriaModal.tsx | 164 ++++++++++++++++++
.../src/internal/FilterCriteriaRenderer.tsx | 42 +++++
.../src/internal/HitCriteriaModal.tsx | 118 -------------
.../src/internal/HitCriteriaRenderer.tsx | 48 -----
.../DomainRowExpandedOptions.tsx | 7 +-
...itCriteria.tsx => FieldFilterCriteria.tsx} | 13 +-
.../assay/AssayDesignerPanels.tsx | 63 +++++--
.../assay/AssayPropertiesInput.tsx | 10 +-
.../assay/AssayPropertiesPanel.tsx | 4 +-
.../assay/FilterCriteriaContext.ts | 12 ++
.../assay/HitCriteriaContext.ts | 11 --
.../domainproperties/assay/models.ts | 29 +---
.../components/domainproperties/models.tsx | 30 +++-
13 files changed, 318 insertions(+), 233 deletions(-)
create mode 100644 packages/components/src/internal/FilterCriteriaModal.tsx
create mode 100644 packages/components/src/internal/FilterCriteriaRenderer.tsx
delete mode 100644 packages/components/src/internal/HitCriteriaModal.tsx
delete mode 100644 packages/components/src/internal/HitCriteriaRenderer.tsx
rename packages/components/src/internal/components/domainproperties/{FieldHitCriteria.tsx => FieldFilterCriteria.tsx} (73%)
create mode 100644 packages/components/src/internal/components/domainproperties/assay/FilterCriteriaContext.ts
delete mode 100644 packages/components/src/internal/components/domainproperties/assay/HitCriteriaContext.ts
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
new file mode 100644
index 0000000000..39020dab2c
--- /dev/null
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -0,0 +1,164 @@
+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 { DomainField, FilterCriteria, FilterCriteriaMap, IDomainField } from './components/domainproperties/models';
+import { useLoadableState } from './useLoadableState';
+import { LoadingSpinner } from './components/base/LoadingSpinner';
+import { ChoicesListItem } from './components/base/ChoicesListItem';
+import { useFilterCriteriaContext } from './components/domainproperties/assay/FilterCriteriaContext';
+import { FilterExpressionView } from './components/search/FilterExpressionView';
+import { useAppContext } from './AppContext';
+import { FilterCriteriaColumns } from './components/assay/models';
+
+/**
+ * openTo: The propertyId of the domain field you want to open the modal to
+ */
+interface Props {
+ onClose: () => void;
+ onSave: (filterCriteria: FilterCriteriaMap) => void;
+ openTo?: number;
+}
+
+export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo }) => {
+ const { api } = useAppContext();
+ const { protocolModel } = useFilterCriteriaContext();
+ const domain = useMemo(
+ () => protocolModel.domains.find(domain => domain.isNameSuffixMatch('Data')),
+ [protocolModel.domains]
+ );
+ const [filterCriteria, setFilterCriteria] = useState(() => {
+ // Initialize the filterCriteria from the existing domain fields
+ return domain.fields.reduce((result, field) => {
+ if (field.filterCriteria) {
+ for (const criteria of field.filterCriteria) {
+ if (!result[criteria.name]) result[criteria.name] = [];
+ result[criteria.name].push(criteria);
+ }
+ }
+ return result;
+ }, {} as FilterCriteriaMap);
+ });
+ const load = useCallback(() => {
+ const columnNames = domain.fields
+ .filter(field => {
+ // Note: Maybe this logic should be in the APIWrapper.getFilterCriteriaColumns?
+ const dataType = field.dataType.name;
+ return field.measure && (dataType === 'double' || dataType === 'int');
+ })
+ .map(field => field.name)
+ .toArray();
+ return api.assay.getFilterCriteriaColumns(protocolModel.protocolId, columnNames, protocolModel.container);
+ }, [api.assay, domain.fields, protocolModel.container, protocolModel.protocolId]);
+ const { loadingState, value: filterCriteriaColumns } = useLoadableState(load);
+ const [selectedFieldName, setSelectedFieldName] = useState(() => {
+ return domain.fields.find(field => field.propertyId === openTo)?.name;
+ });
+
+ // The array of all fields, including the fields on the domain, and the fields returned from the
+ // FilterCriteriaColumns API
+ const allFields = useMemo(() => {
+ if (filterCriteriaColumns === undefined) return [];
+ return Object.keys(filterCriteriaColumns).reduce((result, key) => {
+ // Push the original column
+ result.push(domain.fields.find(f => f.name === key).toJS());
+ // Concat the loaded columns from the FilterCriteriaColumns API
+ return result.concat(filterCriteriaColumns[key]);
+ }, [] as IDomainField[]);
+ }, [domain.fields, filterCriteriaColumns]);
+
+ // The currently selected DomainField
+ const currentField = useMemo(() => {
+ const rawField = allFields.find(field => field.name === selectedFieldName);
+ if (rawField === undefined) return undefined;
+ return DomainField.create(rawField);
+ }, [allFields, selectedFieldName]);
+
+ // The currently selected QueryColumn (needed by FilterExpressionView)
+ const currentColumn: QueryColumn = useMemo(() => {
+ if (currentField === undefined) return undefined;
+ return new QueryColumn({
+ fieldKey: currentField.name,
+ caption: currentField.name,
+ isKeyField: currentField.isPrimaryKey || currentField.isUniqueIdField(),
+ });
+ }, [currentField]);
+ const onSelect = useCallback((idx: number) => setSelectedFieldName(allFields[idx].name), [allFields]);
+ const onFieldFilterUpdate = useCallback(
+ (newFilters: Filter.IFilter[]) => {
+ setFilterCriteria(current => {
+ const domainField = allFields.find(field => field.name === selectedFieldName);
+ return {
+ ...current,
+ [domainField.name]: newFilters.map(filter => ({
+ name: domainField.name.indexOf('_') > -1 ? domainField.name : '', // FIXME: HACK
+ op: filter.getFilterType().getURLSuffix(),
+ // propertyId: domainField.propertyId,
+ propertyId: undefined, // TODO: this results in an error for computed fields
+ referencePropertyId: undefined, // TODO: wire this up for reference properties
+ value: filter.getValue(),
+ })),
+ };
+ });
+ },
+ [allFields, selectedFieldName]
+ );
+ const onConfirm = useCallback(() => onSave(filterCriteria), [filterCriteria, onSave]);
+ const loading = isLoading(loadingState);
+ const fieldFilters = useMemo(() => {
+ if (!selectedFieldName) return undefined;
+
+ const domainField = allFields.find(field => field.name === selectedFieldName);
+
+ if (!domainField) return undefined;
+
+ const fieldFilterCriteria: FilterCriteria[] = filterCriteria[domainField.name] ?? [];
+
+ return fieldFilterCriteria.map(fc => Filter.create(fc.name, fc.value, Filter.Types[fc.op.toUpperCase()]));
+ }, [allFields, filterCriteria, selectedFieldName]);
+
+ console.log(filterCriteria);
+
+ return (
+
+ {loading && }
+ {!loading && (
+
+
+
Fields
+
+
+ {allFields.map((column, index) => (
+
+ ))}
+
+
+
+
+
Filter Criteria
+
+ {currentColumn && (
+
+ )}
+
+
+
+ )}
+
+ );
+});
diff --git a/packages/components/src/internal/FilterCriteriaRenderer.tsx b/packages/components/src/internal/FilterCriteriaRenderer.tsx
new file mode 100644
index 0000000000..6b2ba29985
--- /dev/null
+++ b/packages/components/src/internal/FilterCriteriaRenderer.tsx
@@ -0,0 +1,42 @@
+import React, { FC, memo, useMemo } from 'react';
+
+import { Filter } from '@labkey/api';
+
+import { DomainField } from './components/domainproperties/models';
+
+function getFilterDisplaySymbol(op: string) {
+ const filterType = Object.values(Filter.Types).find(ft => ft.getURLSuffix() === op);
+ return filterType.getDisplaySymbol();
+}
+
+interface FieldWithCriteria {
+ field: DomainField;
+}
+
+const FilterCriteriaField: FC = memo(({ field }) => {
+ return (
+ <>
+ {field.filterCriteria.map(criteria => (
+
+ {criteria.name} {getFilterDisplaySymbol(criteria.op)} {criteria.value}
+
+ ))}
+ >
+ );
+});
+
+interface Props {
+ fields: DomainField[];
+}
+
+export const FilterCriteriaRenderer: FC = memo(({ fields }) => {
+ const fieldsWithCriteria = useMemo(() => fields.filter(field => field.filterCriteria), [fields]);
+
+ return (
+
+ {fieldsWithCriteria.map(field => (
+
+ ))}
+
+ );
+});
diff --git a/packages/components/src/internal/HitCriteriaModal.tsx b/packages/components/src/internal/HitCriteriaModal.tsx
deleted file mode 100644
index a2e571aa6b..0000000000
--- a/packages/components/src/internal/HitCriteriaModal.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import React, { FC, memo, useCallback, useMemo, useState } from 'react';
-
-import { isLoading } from '../public/LoadingState';
-
-import { QueryColumn } from '../public/QueryColumn';
-
-import { Modal } from './Modal';
-import { DomainDesign, DomainField } from './components/domainproperties/models';
-import { AssayProtocolModel, HitCriteria, hitCriteriaKey } from './components/domainproperties/assay/models';
-import { useLoadableState } from './useLoadableState';
-import { LoadingSpinner } from './components/base/LoadingSpinner';
-import { ChoicesListItem } from './components/base/ChoicesListItem';
-import { useHitCriteriaContext } from './components/domainproperties/assay/HitCriteriaContext';
-import { FilterExpressionView } from './components/search/FilterExpressionView';
-
-async function fetchQueryColumns(domain: DomainDesign): Promise {
- // TODO: use API to load fields so we get the extra fields like STD Deviation, Median, etc.
- return domain.fields
- .map(field => {
- return new QueryColumn({
- fieldKey: field.name,
- caption: field.name,
- isKeyField: field.isPrimaryKey || field.isUniqueIdField(),
- });
- })
- .toArray();
-}
-
-/**
- * openTo: The propertyId of the domain field you want to open the modal to
- */
-interface Props {
- loadQueryColumns?: (domain: DomainDesign) => Promise;
- model: AssayProtocolModel;
- onClose: () => void;
- onSave: (hitCriteria: HitCriteria) => void;
- openTo?: number;
-}
-
-export const HitCriteriaModal: FC = memo(props => {
- const { loadQueryColumns = fetchQueryColumns, model, onClose, onSave, openTo } = props;
- const { hitCriteria: initialHitCriteria } = useHitCriteriaContext();
- const [hitCriteria, setHitCriteria] = useState(initialHitCriteria);
- const domain = useMemo(() => model.domains.find(domain => domain.isNameSuffixMatch('Data')), [model.domains]);
- const load = useCallback(() => loadQueryColumns(domain), [loadQueryColumns, domain]);
- const { loadingState, value: columns } = useLoadableState(load);
- const [selectedFieldKey, setSelectedFieldKey] = useState(() => {
- return domain.fields.find(field => field.propertyId === openTo)?.name;
- });
- const currentColumn = useMemo(
- () => columns?.find(column => column.fieldKey === selectedFieldKey),
- [columns, selectedFieldKey]
- );
- const onSelect = useCallback((idx: number) => setSelectedFieldKey(columns[idx].fieldKey), [columns]);
- const onFieldFilterUpdate = useCallback(
- newFilters => {
- setHitCriteria(current => {
- const domainField = domain.fields.find(field => field.name === currentColumn.fieldKey);
- const key = hitCriteriaKey(domainField);
- return {
- ...current,
- [key]: newFilters,
- };
- });
- },
- [currentColumn?.fieldKey, domain.fields]
- );
- const onConfirm = useCallback(() => onSave(hitCriteria), [hitCriteria, onSave]);
- const loading = isLoading(loadingState);
- const fieldFilters = useMemo(() => {
- if (!selectedFieldKey) return undefined;
-
- const domainField = domain.fields.find(field => field.name === selectedFieldKey);
- console.log('hitCriteriaKey:', hitCriteriaKey(domainField));
- console.log('hitCriteria:', hitCriteria);
- return hitCriteria[hitCriteriaKey(domainField)];
- }, [domain.fields, hitCriteria, selectedFieldKey]);
-
- console.log('Field Filters:', fieldFilters);
-
- return (
-
- {loading && }
- {!loading && (
-
-
-
Fields
-
-
- {columns.map((column, index) => (
-
- ))}
-
-
-
-
-
Filter Criteria
-
- {currentColumn && (
-
- )}
-
-
-
- )}
-
- );
-});
diff --git a/packages/components/src/internal/HitCriteriaRenderer.tsx b/packages/components/src/internal/HitCriteriaRenderer.tsx
deleted file mode 100644
index 38298e0c60..0000000000
--- a/packages/components/src/internal/HitCriteriaRenderer.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import React, { FC, memo, useMemo } from 'react';
-import { Filter } from '@labkey/api';
-
-import { DomainField } from './components/domainproperties/models';
-import { HitCriteria, hitCriteriaKey } from './components/domainproperties/assay/models';
-
-interface FieldWithCriteria {
- criteria: Filter.IFilter[];
- field: DomainField;
-}
-
-const HitCriteriaField: FC = memo(({ criteria, field }) => {
- return (
- <>
- {criteria.map((filter, index) => (
- // eslint-disable-next-line react/no-array-index-key
-
- {field.name} {filter.getFilterType().getDisplaySymbol()} {filter.getValue()}
-
- ))}
- >
- );
-});
-
-interface Props {
- criteria: HitCriteria;
- fields: DomainField[];
-}
-
-export const HitCriteriaRenderer: FC = memo(({ fields, criteria }) => {
- const fieldsWithCriteria = useMemo(
- () =>
- fields.reduce((result, field) => {
- const key = hitCriteriaKey(field);
- if (criteria[key]) result.push({ field, criteria: criteria[key] });
- return result;
- }, [] as FieldWithCriteria[]),
- [fields, criteria]
- );
-
- return (
-
- {fieldsWithCriteria.map(field => (
-
- ))}
-
- );
-});
diff --git a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
index 3ae3dc8ac3..b8f1104668 100644
--- a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
+++ b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
@@ -34,7 +34,7 @@ import { TextChoiceOptions } from './TextChoiceOptions';
import { FileAttachmentOptions } from './FileAttachmentOptions';
import { CalculatedFieldOptions } from './CalculatedFieldOptions';
import { CALCULATED_TYPE } from './PropDescType';
-import { FieldHitCriteria } from './FieldHitCriteria';
+import { FieldFilterCriteria } from './FieldFilterCriteria';
interface Props {
appPropertiesOnly?: boolean;
@@ -270,6 +270,9 @@ export class DomainRowExpandedOptions extends React.Component {
domainFormDisplayOptions,
getDomainFields,
} = this.props;
+ const dataType = field.dataType.name;
+ const isNumber = dataType === 'double' || dataType === 'int';
+ const showFilterCriteria = domainFormDisplayOptions.showFilterCriteria && isNumber && field.measure;
return (
@@ -325,7 +328,7 @@ export class DomainRowExpandedOptions extends React.Component
{
/>
)}
- {domainFormDisplayOptions.showHitCriteria && }
+ {showFilterCriteria && }
);
diff --git a/packages/components/src/internal/components/domainproperties/FieldHitCriteria.tsx b/packages/components/src/internal/components/domainproperties/FieldFilterCriteria.tsx
similarity index 73%
rename from packages/components/src/internal/components/domainproperties/FieldHitCriteria.tsx
rename to packages/components/src/internal/components/domainproperties/FieldFilterCriteria.tsx
index 546c47a1f7..003a972916 100644
--- a/packages/components/src/internal/components/domainproperties/FieldHitCriteria.tsx
+++ b/packages/components/src/internal/components/domainproperties/FieldFilterCriteria.tsx
@@ -1,20 +1,20 @@
import React, { FC, memo, useCallback, useMemo } from 'react';
+import { FilterCriteriaRenderer } from '../../FilterCriteriaRenderer';
+
import { SectionHeading } from './SectionHeading';
import { DomainField } from './models';
-import { useHitCriteriaContext } from './assay/HitCriteriaContext';
-import { HitCriteriaRenderer } from '../../HitCriteriaRenderer';
+import { useFilterCriteriaContext } from './assay/FilterCriteriaContext';
interface Props {
field: DomainField;
}
-export const FieldHitCriteria: FC
= memo(({ field }) => {
+export const FieldFilterCriteria: FC = memo(({ field }) => {
const { propertyId } = field;
- const context = useHitCriteriaContext();
+ const context = useFilterCriteriaContext();
const openModal = context?.openModal;
const onClick = useCallback(() => openModal(propertyId), [openModal, propertyId]);
- // TODO: need to get the generate field (STD Dev, Median, etc)
const fields = useMemo(() => [field], [field]);
if (!context) return null;
@@ -34,9 +34,10 @@ export const FieldHitCriteria: FC = memo(({ field }) => {
-
+
);
});
+FieldFilterCriteria.displayName = 'FieldFilterCriteria';
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
index df3fedf989..dab4c97748 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -6,7 +6,9 @@ import { DomainPropertiesAPIWrapper } from '../APIWrapper';
import {
DomainDesign,
+ DomainField,
DomainFieldIndexChange,
+ FilterCriteriaMap,
HeaderRenderer,
IDomainFormDisplayOptions,
IFieldChange,
@@ -27,15 +29,16 @@ import { AssayRunDataType } from '../../entities/constants';
import { FolderConfigurableDataType } from '../../entities/models';
-import { HitCriteriaModal } from '../../../HitCriteriaModal';
+import { FilterCriteriaModal } from '../../../FilterCriteriaModal';
import { saveAssayDesign } from './actions';
-import { AssayProtocolModel, HitCriteria } from './models';
+import { AssayProtocolModel } from './models';
import { AssayPropertiesPanel } from './AssayPropertiesPanel';
-import { HitCriteriaContext } from './HitCriteriaContext';
+import { FilterCriteriaContext } from './FilterCriteriaContext';
const PROPERTIES_PANEL_INDEX = 0;
const DOMAIN_PANEL_INDEX = 1;
+const resultsDomainPredicate = (domain: DomainDesign): boolean => domain.isNameSuffixMatch('Data');
interface AssayDomainFormProps
extends Omit {
@@ -106,8 +109,8 @@ const AssayDomainForm: FC = memo(props => {
domainKindDisplayName: 'assay design',
hideFilePropertyType,
hideInferFromFile,
+ showFilterCriteria: isResultsDomain,
textChoiceLockedForDomain,
- showHitCriteria: isResultsDomain,
};
}, [
domain,
@@ -329,13 +332,46 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
this.setState({ modalOpen: false, openTo: undefined });
};
- saveHitCriteria = (hitCriteria: HitCriteria) => {
+ saveFilterCriteria = (filterCriteria: FilterCriteriaMap) => {
this.setState(current => {
- // Note: use protocolModel.set instead of merge so hitCriteria doesn't get converted to an immutable object
+ const protocolModel = current.protocolModel;
+ const resultsIndex = current.protocolModel.domains.findIndex(resultsDomainPredicate);
+ const domains = current.protocolModel.domains;
+ let resultsDomain = domains.find(resultsDomainPredicate);
+ // Clear the existing values first
+ let fields = resultsDomain.fields.map(f => f.set('filterCriteria', []) as DomainField).toList();
+
+ Object.keys(filterCriteria).forEach(fieldName => {
+ console.log('fieldFilterCriteria:', fieldName, filterCriteria[fieldName]);
+ const fieldCriteria = filterCriteria[fieldName];
+ let domainFieldIdx = fields.findIndex(d => d.name === fieldName);
+
+ if (!domainFieldIdx) {
+ // TODO: error prone a user could create a field named my_field which would result in the incorrect
+ // prefix. We'd get my instead of my_field, so we'd never find the field index
+ const prefix = fieldName.split('_')[0];
+ domainFieldIdx = fields.findIndex(d => d.name === prefix);
+ }
+
+ if (!domainFieldIdx) return;
+
+ let domainField = fields.get(domainFieldIdx);
+ domainField = domainField.set(
+ 'filterCriteria',
+ domainField.filterCriteria.concat(fieldCriteria)
+ ) as DomainField;
+ fields = fields.set(domainFieldIdx, domainField);
+ });
+
+ resultsDomain = resultsDomain.set('fields', fields) as DomainDesign;
+
return {
modalOpen: false,
openTo: undefined,
- protocolModel: current.protocolModel.set('hitCriteria', hitCriteria) as AssayProtocolModel,
+ protocolModel: protocolModel.set(
+ 'domains',
+ protocolModel.domains.set(resultsIndex, resultsDomain)
+ ) as AssayProtocolModel,
};
});
};
@@ -369,9 +405,9 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
const { modalOpen, openTo, protocolModel } = this.state;
const isGpat = protocolModel.providerName === GENERAL_ASSAY_PROVIDER_NAME;
- const hitCriteriaState = {
+ const filterCriteriaState = {
openModal: this.openModal,
- hitCriteria: protocolModel.hitCriteria,
+ protocolModel,
};
const panelStatus = protocolModel.isNew()
? getDomainPanelStatus(PROPERTIES_PANEL_INDEX, currentPanelIndex, visitedPanels, firstState)
@@ -389,7 +425,7 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
onFinish={this.onFinish}
saveBtnText={saveBtnText}
>
-
+
{
);
})}
{modalOpen && (
-
)}
-
+
{appPropertiesOnly && allowFolderExclusion && (
= memo(props => (
));
-export const HitCriteriaInput: FC = memo(({ model }) => {
- const context = useHitCriteriaContext();
+export const FilterCriteriaInput: FC = memo(({ model }) => {
+ const context = useFilterCriteriaContext();
if (!context) return null;
@@ -746,7 +746,7 @@ export const HitCriteriaInput: FC = memo(({ model }) => {
-
+
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
index b84a9f4a77..d07a681219 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
@@ -30,7 +30,7 @@ import {
DetectionMethodsInput,
EditableResultsInput,
EditableRunsInput,
- HitCriteriaInput,
+ FilterCriteriaInput,
MetadataInputFormatsInput,
ModuleProvidedScriptsInput,
NameInput,
@@ -196,7 +196,7 @@ const AssayPropertiesForm: FC = memo(props => {
)}
{model.allowPlateMetadata && }
-
+
)}
diff --git a/packages/components/src/internal/components/domainproperties/assay/FilterCriteriaContext.ts b/packages/components/src/internal/components/domainproperties/assay/FilterCriteriaContext.ts
new file mode 100644
index 0000000000..de324d0d1c
--- /dev/null
+++ b/packages/components/src/internal/components/domainproperties/assay/FilterCriteriaContext.ts
@@ -0,0 +1,12 @@
+import { createContext, useContext } from 'react';
+
+import { AssayProtocolModel } from './models';
+
+export interface FilterCriteriaState {
+ openModal: (openToPropertyId?: number) => void;
+ protocolModel: AssayProtocolModel;
+}
+
+export const FilterCriteriaContext = createContext(undefined);
+
+export const useFilterCriteriaContext = () => useContext(FilterCriteriaContext);
diff --git a/packages/components/src/internal/components/domainproperties/assay/HitCriteriaContext.ts b/packages/components/src/internal/components/domainproperties/assay/HitCriteriaContext.ts
deleted file mode 100644
index 8ea0a93839..0000000000
--- a/packages/components/src/internal/components/domainproperties/assay/HitCriteriaContext.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { createContext, useContext } from 'react';
-import { HitCriteria } from './models';
-
-export interface HitCriteriaContextState {
- hitCriteria: HitCriteria;
- openModal: (openToPropertyId?: number) => void;
-}
-
-export const HitCriteriaContext = createContext(undefined);
-
-export const useHitCriteriaContext = () => useContext(HitCriteriaContext);
diff --git a/packages/components/src/internal/components/domainproperties/assay/models.ts b/packages/components/src/internal/components/domainproperties/assay/models.ts
index 8dea2c7226..300e134109 100644
--- a/packages/components/src/internal/components/domainproperties/assay/models.ts
+++ b/packages/components/src/internal/components/domainproperties/assay/models.ts
@@ -14,30 +14,14 @@
* limitations under the License.
*/
import { List, Record as ImmutableRecord } from 'immutable';
-import { Filter } from '@labkey/api';
import { getServerContext, Utils } from '@labkey/api';
-import { DomainDesign, DomainField, FieldErrors } from '../models';
+import { DomainDesign, FieldErrors } from '../models';
import { AppURL } from '../../../url/AppURL';
import { getAppHomeFolderPath } from '../../../app/utils';
import { Container } from '../../base/models/Container';
-// HitCriteria should be stored as a Record, where the key is in the form of: propertyId: or fieldKey:
-// We use propertyId: to reference existing fields, and fieldKey: to reference new fields when sending
-// to the server. However, when working locally we always use propertyId: and use fake (negative indexed)
-// propertyIds in order to prevent issues when users re-name new fields.
-export type HitCriteria = Record;
-
-// Locally we always want to use the propertyId to handle changes to the field name, but during upload we have to use
-// the field name for new fields.
-export function hitCriteriaKey(field: DomainField, forUpload?: false): string {
- const useFieldKey = forUpload && field.propertyId < 0;
- const prefix = useFieldKey ? 'fieldKey' : 'propertyId';
- const value = useFieldKey ? field.name : field.propertyId;
- return `${prefix}:${value}`;
-}
-
// See ExpProtocol.Status in 'platform' repository.
export enum Status {
Active = 'Active',
@@ -57,7 +41,6 @@ export class AssayProtocolModel extends ImmutableRecord({
availableMetadataInputFormats: undefined,
availablePlateTemplates: undefined,
backgroundUpload: false,
- hitCriteria: {},
description: undefined,
domains: undefined,
editableResults: false,
@@ -91,7 +74,6 @@ export class AssayProtocolModel extends ImmutableRecord({
declare availableMetadataInputFormats: {};
declare availablePlateTemplates: [];
declare backgroundUpload: boolean;
- declare hitCriteria: HitCriteria;
declare description: string;
declare domains: List;
declare editableResults: boolean;
@@ -158,11 +140,6 @@ export class AssayProtocolModel extends ImmutableRecord({
// only need to serialize the id and not the autoCopyTargetContainer object
delete json.autoCopyTargetContainer;
delete json.exception;
-
- // TODO: "hitCriteria" have been moved to DomainField and are now called "filterCriteria".
- // Removing for now so result can be saved without throwing a server error.
- delete json.hitCriteria;
-
return json;
}
@@ -185,8 +162,8 @@ export class AssayProtocolModel extends ImmutableRecord({
return this.isNew()
? getAppHomeFolderPath(container)
: domainContainerId === container.id
- ? container.path
- : domainContainerId;
+ ? container.path
+ : domainContainerId;
}
get domainContainerId(): string {
diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx
index a361b29829..0d6fb09aa6 100644
--- a/packages/components/src/internal/components/domainproperties/models.tsx
+++ b/packages/components/src/internal/components/domainproperties/models.tsx
@@ -874,6 +874,32 @@ export interface IDomainField {
visible: boolean;
}
+/**
+ * name: Name of the field this criterion applies to. May not be the same as the field these filterCriteria are
+ * specified on. See "referencePropertyId".
+ *
+ * op: The filter operation for this criterion. This is accepted as the shorthand URL parameter for a LABKEY.Filter
+ * (e.g. "gt", "lt", etc.).
+ *
+ * propertyId: The propertyId of the property (field) this criterion applies to. When saving, if this is not specified,
+ * then it will be presumed to be the propertyId of the field on which the "filterCriteria" are specified.
+ *
+ * referencePropertyId: Reference property (field) identifier. All filter criterion specified on a field are referenced
+ * to the field, however, the criterion may be filtering against a different field. This makes it easier to determine
+ * which field a criterion is related to.
+ *
+ * value: The value of the filter. When saving the value can be a string, number, or boolean.The server always persists
+ * the value as a string. When retrieved the value will be a string.
+ */
+export interface FilterCriteria {
+ name: string;
+ op: string;
+ propertyId: number;
+ referencePropertyId: number;
+ value: string | number | boolean;
+}
+export type FilterCriteriaMap = Record;
+
export class DomainField
extends ImmutableRecord({
conceptURI: undefined,
@@ -885,6 +911,7 @@ export class DomainField
description: undefined,
dimension: undefined,
excludeFromShifting: false,
+ filterCriteria: [],
format: undefined,
hidden: false,
importAliases: undefined,
@@ -945,6 +972,7 @@ export class DomainField
declare description?: string;
declare dimension?: boolean;
declare excludeFromShifting?: boolean;
+ declare filterCriteria?: FilterCriteria[];
declare format?: string;
declare hidden?: boolean;
declare importAliases?: string;
@@ -2065,7 +2093,7 @@ export interface IDomainFormDisplayOptions {
phiLevelDisabled?: boolean;
retainReservedFields?: boolean;
showScannableOption?: boolean;
- showHitCriteria?: boolean;
+ showFilterCriteria?: boolean;
textChoiceLockedForDomain?: boolean;
textChoiceLockedSqlFragment?: string;
}
From 51cde569059f0d618ddd9b5ff8086ce2681b8536 Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 11 Dec 2024 18:43:01 -0600
Subject: [PATCH 15/46] Remove protocolModel from FilterCriteriaContext
---
packages/components/src/internal/FilterCriteriaModal.tsx | 6 +++---
.../domainproperties/assay/AssayDesignerPanels.tsx | 6 ++----
.../domainproperties/assay/FilterCriteriaContext.ts | 3 ---
3 files changed, 5 insertions(+), 10 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index 39020dab2c..81c5d757b8 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -11,10 +11,10 @@ import { DomainField, FilterCriteria, FilterCriteriaMap, IDomainField } from './
import { useLoadableState } from './useLoadableState';
import { LoadingSpinner } from './components/base/LoadingSpinner';
import { ChoicesListItem } from './components/base/ChoicesListItem';
-import { useFilterCriteriaContext } from './components/domainproperties/assay/FilterCriteriaContext';
import { FilterExpressionView } from './components/search/FilterExpressionView';
import { useAppContext } from './AppContext';
import { FilterCriteriaColumns } from './components/assay/models';
+import { AssayProtocolModel } from './components/domainproperties/assay/models';
/**
* openTo: The propertyId of the domain field you want to open the modal to
@@ -23,11 +23,11 @@ interface Props {
onClose: () => void;
onSave: (filterCriteria: FilterCriteriaMap) => void;
openTo?: number;
+ protocolModel: AssayProtocolModel;
}
-export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo }) => {
+export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, protocolModel }) => {
const { api } = useAppContext();
- const { protocolModel } = useFilterCriteriaContext();
const domain = useMemo(
() => protocolModel.domains.find(domain => domain.isNameSuffixMatch('Data')),
[protocolModel.domains]
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
index dab4c97748..13f856a417 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -405,10 +405,7 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
const { modalOpen, openTo, protocolModel } = this.state;
const isGpat = protocolModel.providerName === GENERAL_ASSAY_PROVIDER_NAME;
- const filterCriteriaState = {
- openModal: this.openModal,
- protocolModel,
- };
+ const filterCriteriaState = { openModal: this.openModal };
const panelStatus = protocolModel.isNew()
? getDomainPanelStatus(PROPERTIES_PANEL_INDEX, currentPanelIndex, visitedPanels, firstState)
: 'COMPLETE';
@@ -469,6 +466,7 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
onClose={this.closeModal}
openTo={openTo}
onSave={this.saveFilterCriteria}
+ protocolModel={protocolModel}
/>
)}
diff --git a/packages/components/src/internal/components/domainproperties/assay/FilterCriteriaContext.ts b/packages/components/src/internal/components/domainproperties/assay/FilterCriteriaContext.ts
index de324d0d1c..ab0f93489e 100644
--- a/packages/components/src/internal/components/domainproperties/assay/FilterCriteriaContext.ts
+++ b/packages/components/src/internal/components/domainproperties/assay/FilterCriteriaContext.ts
@@ -1,10 +1,7 @@
import { createContext, useContext } from 'react';
-import { AssayProtocolModel } from './models';
-
export interface FilterCriteriaState {
openModal: (openToPropertyId?: number) => void;
- protocolModel: AssayProtocolModel;
}
export const FilterCriteriaContext = createContext(undefined);
From c5525a5f788d53eec7f3f3fde1a193f300b5ae10 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 12 Dec 2024 09:59:33 -0600
Subject: [PATCH 16/46] Remove hack
---
packages/components/src/internal/FilterCriteriaModal.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index 81c5d757b8..d74fe0d0fe 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -96,7 +96,7 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
return {
...current,
[domainField.name]: newFilters.map(filter => ({
- name: domainField.name.indexOf('_') > -1 ? domainField.name : '', // FIXME: HACK
+ name: domainField.name,
op: filter.getFilterType().getURLSuffix(),
// propertyId: domainField.propertyId,
propertyId: undefined, // TODO: this results in an error for computed fields
From 647dc82c94e2c318d4512e085032631cdfbefcd6 Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 11:25:56 -0600
Subject: [PATCH 17/46] FilterCriteriaModal - use propertyIds
---
.../src/internal/FilterCriteriaModal.tsx | 177 ++++++++++--------
.../assay/AssayDesignerPanels.tsx | 20 +-
.../search/FilterExpressionView.tsx | 2 +-
3 files changed, 111 insertions(+), 88 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index d74fe0d0fe..e5b31454ed 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -7,7 +7,7 @@ import { isLoading } from '../public/LoadingState';
import { QueryColumn } from '../public/QueryColumn';
import { Modal } from './Modal';
-import { DomainField, FilterCriteria, FilterCriteriaMap, IDomainField } from './components/domainproperties/models';
+import { DomainDesign, FilterCriteria, FilterCriteriaMap } from './components/domainproperties/models';
import { useLoadableState } from './useLoadableState';
import { LoadingSpinner } from './components/base/LoadingSpinner';
import { ChoicesListItem } from './components/base/ChoicesListItem';
@@ -16,6 +16,57 @@ import { useAppContext } from './AppContext';
import { FilterCriteriaColumns } from './components/assay/models';
import { AssayProtocolModel } from './components/domainproperties/assay/models';
+type BaseFilterCriteriaField = Omit;
+interface FilterCriteriaField extends BaseFilterCriteriaField {
+ isKeyField: boolean;
+}
+type FieldLoader = () => Promise;
+type ReferenceFieldFetcher = (
+ protocolId: number,
+ columnNames: string[],
+ containerPath: string
+) => Promise;
+
+// This propertyIdCounter is strictly for reference properties. Starts at -100 so we don't conflict with new fields
+// which start at -1
+let propertyIdCounter = -100;
+
+function fieldLoaderFactory(
+ protocolId: number,
+ container: string,
+ domain: DomainDesign,
+ fetch: ReferenceFieldFetcher
+): FieldLoader {
+ return async () => {
+ const sourceFields = domain.fields
+ .filter(field => {
+ // Note: Maybe this logic should be in the APIWrapper.getFilterCriteriaColumns?
+ const dataType = field.dataType.name;
+ return field.measure && (dataType === 'double' || dataType === 'int');
+ })
+ .map(field => field.name)
+ .toArray();
+ const referenceFields = await fetch(protocolId, sourceFields, container);
+
+ return Object.keys(referenceFields).reduce((result, sourceName) => {
+ const sourceField = domain.fields.find(field => field.name === sourceName);
+ result.push({
+ name: sourceField.name,
+ propertyId: sourceField.propertyId,
+ isKeyField: sourceField.isPrimaryKey || sourceField.isUniqueIdField(),
+ });
+ return result.concat(
+ referenceFields[sourceName].map(rawField => ({
+ name: rawField.name,
+ propertyId: rawField.propertyId === 0 ? propertyIdCounter-- : rawField.propertyId,
+ referencePropertyId: sourceField.propertyId,
+ isKeyField: false,
+ }))
+ );
+ }, [] as FilterCriteriaField[]);
+ };
+}
+
/**
* openTo: The propertyId of the domain field you want to open the modal to
*/
@@ -28,101 +79,81 @@ interface Props {
export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, protocolModel }) => {
const { api } = useAppContext();
+ const { protocolId, container } = protocolModel;
const domain = useMemo(
() => protocolModel.domains.find(domain => domain.isNameSuffixMatch('Data')),
[protocolModel.domains]
);
const [filterCriteria, setFilterCriteria] = useState(() => {
- // Initialize the filterCriteria from the existing domain fields
return domain.fields.reduce((result, field) => {
- if (field.filterCriteria) {
- for (const criteria of field.filterCriteria) {
- if (!result[criteria.name]) result[criteria.name] = [];
- result[criteria.name].push(criteria);
- }
- }
+ if (field.filterCriteria) result[field.propertyId] = [...field.filterCriteria];
return result;
}, {} as FilterCriteriaMap);
});
- const load = useCallback(() => {
- const columnNames = domain.fields
- .filter(field => {
- // Note: Maybe this logic should be in the APIWrapper.getFilterCriteriaColumns?
- const dataType = field.dataType.name;
- return field.measure && (dataType === 'double' || dataType === 'int');
- })
- .map(field => field.name)
- .toArray();
- return api.assay.getFilterCriteriaColumns(protocolModel.protocolId, columnNames, protocolModel.container);
- }, [api.assay, domain.fields, protocolModel.container, protocolModel.protocolId]);
- const { loadingState, value: filterCriteriaColumns } = useLoadableState(load);
- const [selectedFieldName, setSelectedFieldName] = useState(() => {
- return domain.fields.find(field => field.propertyId === openTo)?.name;
- });
+ const loader = useMemo(
+ () => fieldLoaderFactory(protocolId, container, domain, api.assay.getFilterCriteriaColumns),
+ [api.assay.getFilterCriteriaColumns, container, domain, protocolId]
+ );
+ const { loadingState, value: filterCriteriaFields } = useLoadableState(loader);
+
+ const [selectedFieldId, setSelectedFieldId] = useState(openTo);
+
+ const onSelect = useCallback(
+ (idx: number) => setSelectedFieldId(filterCriteriaFields[idx].propertyId),
+ [filterCriteriaFields]
+ );
- // The array of all fields, including the fields on the domain, and the fields returned from the
- // FilterCriteriaColumns API
- const allFields = useMemo(() => {
- if (filterCriteriaColumns === undefined) return [];
- return Object.keys(filterCriteriaColumns).reduce((result, key) => {
- // Push the original column
- result.push(domain.fields.find(f => f.name === key).toJS());
- // Concat the loaded columns from the FilterCriteriaColumns API
- return result.concat(filterCriteriaColumns[key]);
- }, [] as IDomainField[]);
- }, [domain.fields, filterCriteriaColumns]);
-
- // The currently selected DomainField
- const currentField = useMemo(() => {
- const rawField = allFields.find(field => field.name === selectedFieldName);
- if (rawField === undefined) return undefined;
- return DomainField.create(rawField);
- }, [allFields, selectedFieldName]);
-
- // The currently selected QueryColumn (needed by FilterExpressionView)
- const currentColumn: QueryColumn = useMemo(() => {
- if (currentField === undefined) return undefined;
- return new QueryColumn({
- fieldKey: currentField.name,
- caption: currentField.name,
- isKeyField: currentField.isPrimaryKey || currentField.isUniqueIdField(),
- });
- }, [currentField]);
- const onSelect = useCallback((idx: number) => setSelectedFieldName(allFields[idx].name), [allFields]);
const onFieldFilterUpdate = useCallback(
(newFilters: Filter.IFilter[]) => {
setFilterCriteria(current => {
- const domainField = allFields.find(field => field.name === selectedFieldName);
+ 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[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(),
+ }));
return {
...current,
- [domainField.name]: newFilters.map(filter => ({
- name: domainField.name,
- op: filter.getFilterType().getURLSuffix(),
- // propertyId: domainField.propertyId,
- propertyId: undefined, // TODO: this results in an error for computed fields
- referencePropertyId: undefined, // TODO: wire this up for reference properties
- value: filter.getValue(),
- })),
+ [sourcePropertyId]: existingValues.concat(newValues),
};
});
},
- [allFields, selectedFieldName]
+ [filterCriteriaFields, selectedFieldId]
);
+
const onConfirm = useCallback(() => onSave(filterCriteria), [filterCriteria, onSave]);
- const loading = isLoading(loadingState);
- const fieldFilters = useMemo(() => {
- if (!selectedFieldName) return undefined;
- const domainField = allFields.find(field => field.name === selectedFieldName);
+ const fieldFilters = useMemo(() => {
+ const filterCriteriaField = filterCriteriaFields?.find(field => field.propertyId === selectedFieldId);
- if (!domainField) return undefined;
+ if (!filterCriteriaField) return undefined;
- const fieldFilterCriteria: FilterCriteria[] = filterCriteria[domainField.name] ?? [];
+ const sourcePropertyId = filterCriteriaField.referencePropertyId ?? filterCriteriaField.propertyId;
+ const filters = filterCriteria[sourcePropertyId].filter(
+ value => value.propertyId === filterCriteriaField.propertyId
+ );
+ return filters.map(fc => Filter.create(fc.name, fc.value, Filter.Types[fc.op.toUpperCase()]));
+ }, [filterCriteriaFields, filterCriteria, selectedFieldId]);
- return fieldFilterCriteria.map(fc => Filter.create(fc.name, fc.value, Filter.Types[fc.op.toUpperCase()]));
- }, [allFields, filterCriteria, selectedFieldName]);
+ const currentColumn: QueryColumn = useMemo(() => {
+ const currentField = filterCriteriaFields?.find(field => field.propertyId === selectedFieldId);
+ if (currentField === undefined) return undefined;
+ return new QueryColumn({
+ fieldKey: currentField.name,
+ caption: currentField.name,
+ isKeyField: currentField.isKeyField,
+ });
+ }, [filterCriteriaFields, selectedFieldId]);
- console.log(filterCriteria);
+ const loading = isLoading(loadingState);
return (
@@ -133,9 +164,9 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
Fields
- {allFields.map((column, index) => (
+ {filterCriteriaFields.map((column, index) => (
{
// Clear the existing values first
let fields = resultsDomain.fields.map(f => f.set('filterCriteria', []) as DomainField).toList();
- Object.keys(filterCriteria).forEach(fieldName => {
- console.log('fieldFilterCriteria:', fieldName, filterCriteria[fieldName]);
- const fieldCriteria = filterCriteria[fieldName];
- let domainFieldIdx = fields.findIndex(d => d.name === fieldName);
+ Object.keys(filterCriteria).forEach(propertyId => {
+ const fieldCriteria = filterCriteria[propertyId];
+ const domainFieldIdx = fields.findIndex(d => d.propertyId === parseInt(propertyId, 10));
if (!domainFieldIdx) {
- // TODO: error prone a user could create a field named my_field which would result in the incorrect
- // prefix. We'd get my instead of my_field, so we'd never find the field index
- const prefix = fieldName.split('_')[0];
- domainFieldIdx = fields.findIndex(d => d.name === prefix);
+ console.warn(`Unable to find domain field with property id ${propertyId}`);
+ return;
}
- if (!domainFieldIdx) return;
-
let domainField = fields.get(domainFieldIdx);
- domainField = domainField.set(
- 'filterCriteria',
- domainField.filterCriteria.concat(fieldCriteria)
- ) as DomainField;
+ domainField = domainField.set('filterCriteria', fieldCriteria) as DomainField;
fields = fields.set(domainFieldIdx, domainField);
});
diff --git a/packages/components/src/internal/components/search/FilterExpressionView.tsx b/packages/components/src/internal/components/search/FilterExpressionView.tsx
index b453c8f1ac..25066726b2 100644
--- a/packages/components/src/internal/components/search/FilterExpressionView.tsx
+++ b/packages/components/src/internal/components/search/FilterExpressionView.tsx
@@ -43,7 +43,7 @@ export const FilterExpressionView: FC = memo(props => {
const filterOptions = getFilterOptionsForType(field, includeAllAncestorFilter);
setFieldFilterOptions(filterOptions);
setActiveFilters(getFilterSelections(fieldFilters, filterOptions));
- }, [field]); // leave fieldFilters out of deps list, fieldFilters is used to init once
+ }, [field]); // eslint-disable-line react-hooks/exhaustive-deps -- fieldFilters is used to init once
const unusedFilterOptions = useCallback(
(thisIndex: number): FieldFilterOption[] => {
From fa55e1b14ecc82aee5a0f5fd007b2bdac64ba2dd Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 11:33:41 -0600
Subject: [PATCH 18/46] request: Add RequesstHandler
---
packages/components/src/internal/request.ts | 24 ++++++++++++++++-----
1 file changed, 19 insertions(+), 5 deletions(-)
diff --git a/packages/components/src/internal/request.ts b/packages/components/src/internal/request.ts
index 4af093ad13..ce043a106d 100644
--- a/packages/components/src/internal/request.ts
+++ b/packages/components/src/internal/request.ts
@@ -3,15 +3,29 @@ import { Ajax, Utils, RequestOptions } from '@labkey/api';
import { handleRequestFailure } from './util/utils';
type Options = Omit;
+type RequestHandler = (request: XMLHttpRequest) => void;
-export function request(options: Options, errorLogMsg = 'Error making ajax request'): Promise {
+/**
+ * This is a light wrapper around Ajax.request that takes the same options, minus success and failure. Instead of
+ * passing success or failure you wrap the call in try/catch and await the call to request e.g.:
+ *
+ * try {
+ * const resp = await request(myOptions);
+ * } catch (error) {
+ * // handle error here
+ * }
+ */
+export function request(
+ options: Options,
+ errorLogMsg = 'Error making ajax request',
+ requestHandler?: RequestHandler
+): Promise {
return new Promise((resolve, reject) => {
- Ajax.request({
+ const xmlHttpRequest = Ajax.request({
...options,
- success: Utils.getCallbackWrapper((res: T) => {
- resolve(res);
- }),
+ success: Utils.getCallbackWrapper((res: T) => resolve(res)),
failure: handleRequestFailure(reject, errorLogMsg),
});
+ requestHandler?.(xmlHttpRequest);
});
}
From 50474a1c86613754e106eb40efc8d518556b96ea Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 14:00:08 -0600
Subject: [PATCH 19/46] Fix styling for FilterCriteriaRenderer
---
packages/components/src/theme/domainproperties.scss | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/packages/components/src/theme/domainproperties.scss b/packages/components/src/theme/domainproperties.scss
index bad07c83a9..f919cd5d6e 100644
--- a/packages/components/src/theme/domainproperties.scss
+++ b/packages/components/src/theme/domainproperties.scss
@@ -950,6 +950,7 @@
height: 34px;
}
}
+
.hit-selection-criteria-input {
display: flex;
gap: 16px;
@@ -958,3 +959,8 @@
.field-modal__container.hit-criteria-modal-body .field-modal__col-content.field-modal__values {
padding: 16px;
}
+
+.hit-criteria-renderer {
+ margin-left: 12px;
+ padding: 0;
+}
From fcbae9bb7333793d39cd8c70d3c9368ee09f3f66 Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 14:02:01 -0600
Subject: [PATCH 20/46] Update filter criteria when field name changes
---
.../components/domainproperties/DomainRow.tsx | 1 +
.../components/domainproperties/actions.ts | 19 +++++++++++++++++++
.../components/domainproperties/constants.ts | 2 --
3 files changed, 20 insertions(+), 2 deletions(-)
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 {
+ // 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);
@@ -820,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/constants.ts b/packages/components/src/internal/components/domainproperties/constants.ts
index 2f95a3cc0c..ed696ba760 100644
--- a/packages/components/src/internal/components/domainproperties/constants.ts
+++ b/packages/components/src/internal/components/domainproperties/constants.ts
@@ -13,8 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { List } from 'immutable';
-
export const DOMAIN_FIELD_PREFIX = 'domainpropertiesrow';
export const DOMAIN_FIELD_NAME = 'name';
export const DOMAIN_FIELD_TYPE = 'type';
From 86f32c978a5762eed195459ae6a4c93fbaf49901 Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 14:16:23 -0600
Subject: [PATCH 21/46] FilterCriteriaModal: make FilterCriteriaMap a JS map
---
.../src/internal/FilterCriteriaModal.tsx | 26 ++++++++--------
.../assay/AssayDesignerPanels.tsx | 5 ++--
.../components/domainproperties/models.tsx | 30 ++++++++++++-------
3 files changed, 34 insertions(+), 27 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index e5b31454ed..5ca8ab90d4 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -84,11 +84,12 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
() => protocolModel.domains.find(domain => domain.isNameSuffixMatch('Data')),
[protocolModel.domains]
);
+
const [filterCriteria, setFilterCriteria] = useState(() => {
return domain.fields.reduce((result, field) => {
- if (field.filterCriteria) result[field.propertyId] = [...field.filterCriteria];
+ if (field.filterCriteria) result.set(field.propertyId, [...field.filterCriteria]);
return result;
- }, {} as FilterCriteriaMap);
+ }, new Map());
});
const loader = useMemo(
() => fieldLoaderFactory(protocolId, container, domain, api.assay.getFilterCriteriaColumns),
@@ -110,9 +111,9 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
// 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[sourcePropertyId].filter(
- value => value.propertyId !== filterCriteriaField.propertyId
- );
+ const existingValues = current
+ .get(sourcePropertyId)
+ .filter(value => value.propertyId !== filterCriteriaField.propertyId);
const newValues = newFilters.map(filter => ({
name: filterCriteriaField.name,
op: filter.getFilterType().getURLSuffix(),
@@ -120,10 +121,9 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
referencePropertyId: filterCriteriaField.referencePropertyId,
value: filter.getValue(),
}));
- return {
- ...current,
- [sourcePropertyId]: existingValues.concat(newValues),
- };
+ const updated = new Map(current);
+ updated.set(sourcePropertyId, existingValues.concat(newValues));
+ return updated;
});
},
[filterCriteriaFields, selectedFieldId]
@@ -137,10 +137,10 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
if (!filterCriteriaField) return undefined;
const sourcePropertyId = filterCriteriaField.referencePropertyId ?? filterCriteriaField.propertyId;
- const filters = filterCriteria[sourcePropertyId].filter(
- value => value.propertyId === filterCriteriaField.propertyId
- );
- return filters.map(fc => Filter.create(fc.name, fc.value, Filter.Types[fc.op.toUpperCase()]));
+ 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(() => {
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
index f0a807a697..0ccc0487ec 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -341,9 +341,8 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
// Clear the existing values first
let fields = resultsDomain.fields.map(f => f.set('filterCriteria', []) as DomainField).toList();
- Object.keys(filterCriteria).forEach(propertyId => {
- const fieldCriteria = filterCriteria[propertyId];
- const domainFieldIdx = fields.findIndex(d => d.propertyId === parseInt(propertyId, 10));
+ filterCriteria.forEach((fieldCriteria, propertyId) => {
+ const domainFieldIdx = fields.findIndex(d => d.propertyId === propertyId);
if (!domainFieldIdx) {
console.warn(`Unable to find domain field with property id ${propertyId}`);
diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx
index 0d6fb09aa6..6d930a7216 100644
--- a/packages/components/src/internal/components/domainproperties/models.tsx
+++ b/packages/components/src/internal/components/domainproperties/models.tsx
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { fromJS, List, Map, Record as ImmutableRecord } from 'immutable';
+import { fromJS, List, Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
import { ActionURL, Domain, getServerContext, Utils } from '@labkey/api';
import React, { ReactNode } from 'react';
@@ -356,8 +356,8 @@ export class DomainDesign
return this.getInvalidFields().size > 0;
}
- getInvalidFields(): Map {
- let invalid = Map();
+ getInvalidFields(): ImmutableMap {
+ let invalid = ImmutableMap();
for (let i = 0; i < this.fields.size; i++) {
const field = this.fields.get(i);
@@ -445,7 +445,7 @@ export class DomainDesign
fieldSerial.selected = field.selected;
fieldSerial.visible = field.visible;
- return Map(
+ return ImmutableMap(
Object.keys(fieldSerial).map(key => {
const rawVal = fieldSerial[key];
const valueType = typeof rawVal;
@@ -895,10 +895,11 @@ export interface FilterCriteria {
name: string;
op: string;
propertyId: number;
- referencePropertyId: number;
+ referencePropertyId?: number;
value: string | number | boolean;
}
-export type FilterCriteriaMap = Record;
+// Note: this is a regular Javascript Map, not an Immutable Map
+export type FilterCriteriaMap = Map;
export class DomainField
extends ImmutableRecord({
@@ -1204,6 +1205,13 @@ export class DomainField
json.scale = UNLIMITED_TEXT_LENGTH;
}
+ // Strip out the propertyIds used on the filterCrtieria if they are for new fields (which are negative indexed)
+ json.filterCriteria = json.filterCriteria.map(fc => ({
+ ...fc,
+ propertyId: fc.propertyId < 0 ? undefined : fc.propertyId,
+ referencePropertyId: fc.referencePropertyId < 0 ? undefined : fc.referencePropertyId,
+ }));
+
// remove non-serializable fields
delete json.dataType;
delete json.lookupQueryValue;
@@ -1903,7 +1911,7 @@ export class DomainException
return this.errors.find(error => !error.get('fieldName') && !error.get('propertyId'))?.get('message');
}
- static clientValidationExceptions(exception: string, fields: Map): DomainException {
+ static clientValidationExceptions(exception: string, fields: ImmutableMap): DomainException {
let fieldErrors = List();
fields.forEach((field, index) => {
@@ -2124,17 +2132,17 @@ export class DomainDetails extends ImmutableRecord({
namePreviews: undefined,
}) {
declare domainDesign: DomainDesign;
- declare options: Map;
+ declare options: ImmutableMap;
declare domainKindName: string;
declare nameReadOnly?: boolean;
declare namePreviews?: string[];
- static create(rawDesign: Map = Map(), domainKindType: string = Domain.KINDS.UNKNOWN): DomainDetails {
+ static create(rawDesign: ImmutableMap = ImmutableMap(), domainKindType: string = Domain.KINDS.UNKNOWN): DomainDetails {
let design;
if (rawDesign) {
const domainDesign = DomainDesign.create(rawDesign.get('domainDesign'));
const domainKindName = rawDesign.get('domainKindName', domainKindType);
- const options = Map(rawDesign.get('options'));
+ const options = ImmutableMap(rawDesign.get('options'));
const nameReadOnly = rawDesign.get('nameReadOnly');
const namePreviews = rawDesign.get('namePreviews');
design = new DomainDetails({ domainDesign, domainKindName, options, nameReadOnly, namePreviews });
@@ -2142,7 +2150,7 @@ export class DomainDetails extends ImmutableRecord({
design = new DomainDetails({
domainDesign: DomainDesign.create(null),
domainKindName: domainKindType,
- options: Map(),
+ options: ImmutableMap(),
});
}
From 00f0d30b4efdab4b81272ab0513fcd23d8959663 Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 16:10:22 -0600
Subject: [PATCH 22/46] Export FilterCriteriaRenderer
---
packages/components/src/index.ts | 2 ++
1 file changed, 2 insertions(+)
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
From 289cc78255e441d08a402c056582eeb8ca8dbee3 Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 17:26:52 -0600
Subject: [PATCH 23/46] FilterCriteriaRenderer: optionally render empty message
---
.../src/internal/FilterCriteriaRenderer.tsx | 26 ++++++++++++++-----
.../assay/AssayPropertiesInput.tsx | 2 +-
.../src/theme/domainproperties.scss | 3 +--
3 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaRenderer.tsx b/packages/components/src/internal/FilterCriteriaRenderer.tsx
index 6b2ba29985..afa11be275 100644
--- a/packages/components/src/internal/FilterCriteriaRenderer.tsx
+++ b/packages/components/src/internal/FilterCriteriaRenderer.tsx
@@ -27,16 +27,28 @@ const FilterCriteriaField: FC = memo(({ field }) => {
interface Props {
fields: DomainField[];
+ renderEmptyMessage?: boolean;
}
-export const FilterCriteriaRenderer: FC = memo(({ fields }) => {
- const fieldsWithCriteria = useMemo(() => fields.filter(field => field.filterCriteria), [fields]);
+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 (
-
- {fieldsWithCriteria.map(field => (
-
- ))}
-
+
+ {showEmptyMessage && (
+
+ No Hit Selection Criteria
+
+ )}
+
+ {fieldsWithCriteria.map(field => (
+
+ ))}
+
+
);
});
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
index 804e1f1184..54a40a9794 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
@@ -746,7 +746,7 @@ export const FilterCriteriaInput: FC = memo(({ model }) => {
-
+
diff --git a/packages/components/src/theme/domainproperties.scss b/packages/components/src/theme/domainproperties.scss
index f919cd5d6e..e12472369d 100644
--- a/packages/components/src/theme/domainproperties.scss
+++ b/packages/components/src/theme/domainproperties.scss
@@ -960,7 +960,6 @@
padding: 16px;
}
-.hit-criteria-renderer {
- margin-left: 12px;
+.hit-criteria-renderer ul {
padding: 0;
}
From 4523a5bdb2c5ae513f011b21d0ccf738fc512879 Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 17:42:47 -0600
Subject: [PATCH 24/46] Fix styling for hit-criteria-renderer
---
packages/components/src/theme/domainproperties.scss | 2 ++
1 file changed, 2 insertions(+)
diff --git a/packages/components/src/theme/domainproperties.scss b/packages/components/src/theme/domainproperties.scss
index e12472369d..d2dfafe40e 100644
--- a/packages/components/src/theme/domainproperties.scss
+++ b/packages/components/src/theme/domainproperties.scss
@@ -962,4 +962,6 @@
.hit-criteria-renderer ul {
padding: 0;
+ margin: 0;
+ margin-left: 15px;
}
From 1ea2733bace6802e97e9904bd5d4c5dd82f17c6f Mon Sep 17 00:00:00 2001
From: alanv
Date: Mon, 16 Dec 2024 17:43:09 -0600
Subject: [PATCH 25/46] Fix styling for FilterCriteriaRenderer
---
packages/components/src/internal/FilterCriteriaRenderer.tsx | 2 +-
packages/components/src/theme/domainproperties.scss | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaRenderer.tsx b/packages/components/src/internal/FilterCriteriaRenderer.tsx
index afa11be275..82778fb2b8 100644
--- a/packages/components/src/internal/FilterCriteriaRenderer.tsx
+++ b/packages/components/src/internal/FilterCriteriaRenderer.tsx
@@ -38,7 +38,7 @@ export const FilterCriteriaRenderer: FC = memo(({ fields, renderEmptyMess
const showEmptyMessage = fieldsWithCriteria.length === 0 && renderEmptyMessage;
return (
-
+
{showEmptyMessage && (
No Hit Selection Criteria
diff --git a/packages/components/src/theme/domainproperties.scss b/packages/components/src/theme/domainproperties.scss
index d2dfafe40e..a88d27c113 100644
--- a/packages/components/src/theme/domainproperties.scss
+++ b/packages/components/src/theme/domainproperties.scss
@@ -960,7 +960,7 @@
padding: 16px;
}
-.hit-criteria-renderer ul {
+.filter-criteria-renderer ul {
padding: 0;
margin: 0;
margin-left: 15px;
From 639e6ede86fe24bcbb698fb42980bea9c1f36f92 Mon Sep 17 00:00:00 2001
From: alanv
Date: Tue, 17 Dec 2024 18:25:18 -0600
Subject: [PATCH 26/46] Fix class names
---
.../domainproperties/assay/AssayPropertiesInput.tsx | 6 +++---
packages/components/src/theme/domainproperties.scss | 4 ++--
packages/components/src/theme/filter.scss | 11 +++++------
3 files changed, 10 insertions(+), 11 deletions(-)
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
index 54a40a9794..be64bcfa8f 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
@@ -739,13 +739,13 @@ export const FilterCriteriaInput: FC = memo(({ model }) => {
return (
-
-
+
+
Edit Criteria
-
diff --git a/packages/components/src/theme/domainproperties.scss b/packages/components/src/theme/domainproperties.scss
index a88d27c113..9aa1fdc90d 100644
--- a/packages/components/src/theme/domainproperties.scss
+++ b/packages/components/src/theme/domainproperties.scss
@@ -951,12 +951,12 @@
}
}
-.hit-selection-criteria-input {
+.filter-criteria-input {
display: flex;
gap: 16px;
}
-.field-modal__container.hit-criteria-modal-body .field-modal__col-content.field-modal__values {
+.field-modal__container.filter-criteria-modal-body .field-modal__col-content.field-modal__values {
padding: 16px;
}
diff --git a/packages/components/src/theme/filter.scss b/packages/components/src/theme/filter.scss
index 3ced587dcc..b74857714f 100644
--- a/packages/components/src/theme/filter.scss
+++ b/packages/components/src/theme/filter.scss
@@ -135,17 +135,16 @@
}
}
- .field-modal__empty-msg {
- color: $gray-light;
- font-size: 16px;
- padding: 10px 10px;
- }
-
.field-value-bool-label {
font-weight: normal;
}
}
+.field-modal__empty-msg {
+ color: $gray-light;
+ font-size: 16px;
+}
+
.filter-expression__input-wrapper {
width: 100%;
margin: 0 0 10px 0;
From 8c31f1f3441a010ec68fddedbc0894b00a95b063 Mon Sep 17 00:00:00 2001
From: alanv
Date: Tue, 17 Dec 2024 18:25:47 -0600
Subject: [PATCH 27/46] FilterCriteriaModal: Add empty messages
Fix issue when there are no fields
---
packages/components/src/internal/FilterCriteriaModal.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index 5ca8ab90d4..bea0dae071 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -154,6 +154,7 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
}, [filterCriteriaFields, selectedFieldId]);
const loading = isLoading(loadingState);
+ const hasFields = filterCriteriaFields !== undefined && filterCriteriaFields.length > 0;
return (
@@ -164,7 +165,8 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
Fields
- {filterCriteriaFields.map((column, index) => (
+ {!hasFields &&
No fields defined yet.
}
+ {filterCriteriaFields?.map((column, index) => (
= memo(({ onClose, onSave, openTo, p
Filter Criteria
+ {!currentColumn &&
Select a field.
}
{currentColumn && (
Date: Tue, 17 Dec 2024 18:26:41 -0600
Subject: [PATCH 28/46] AssayDesignerPanels: Fix issue with saveFilterCriteria
---
.../components/domainproperties/assay/AssayDesignerPanels.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
index 0ccc0487ec..d2cd48c15f 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -344,7 +344,7 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
filterCriteria.forEach((fieldCriteria, propertyId) => {
const domainFieldIdx = fields.findIndex(d => d.propertyId === propertyId);
- if (!domainFieldIdx) {
+ if (domainFieldIdx < 0) {
console.warn(`Unable to find domain field with property id ${propertyId}`);
return;
}
From 2eb323b6725687bcef9c01ae7ebe8260d5685183 Mon Sep 17 00:00:00 2001
From: alanv
Date: Tue, 17 Dec 2024 19:00:31 -0600
Subject: [PATCH 29/46] FilterCriteriaModal: Give reference properties stable
propertyId values
---
packages/components/src/internal/FilterCriteriaModal.tsx | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index bea0dae071..12f6f19e1d 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -27,10 +27,6 @@ type ReferenceFieldFetcher = (
containerPath: string
) => Promise;
-// This propertyIdCounter is strictly for reference properties. Starts at -100 so we don't conflict with new fields
-// which start at -1
-let propertyIdCounter = -100;
-
function fieldLoaderFactory(
protocolId: number,
container: string,
@@ -56,9 +52,9 @@ function fieldLoaderFactory(
isKeyField: sourceField.isPrimaryKey || sourceField.isUniqueIdField(),
});
return result.concat(
- referenceFields[sourceName].map(rawField => ({
+ referenceFields[sourceName].map((rawField, index) => ({
name: rawField.name,
- propertyId: rawField.propertyId === 0 ? propertyIdCounter-- : rawField.propertyId,
+ propertyId: rawField.propertyId === 0 ? sourceField.propertyId * 100 + index : rawField.propertyId,
referencePropertyId: sourceField.propertyId,
isKeyField: false,
}))
From b25223c0be490986d63e1c5c6549af662b2e5144 Mon Sep 17 00:00:00 2001
From: alanv
Date: Tue, 17 Dec 2024 19:00:48 -0600
Subject: [PATCH 30/46] FilterCriteriaModal: filter fields to openTo and
related
---
.../src/internal/FilterCriteriaModal.tsx | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index 12f6f19e1d..683a39bc7b 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -152,6 +152,11 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
const loading = isLoading(loadingState);
const hasFields = filterCriteriaFields !== undefined && filterCriteriaFields.length > 0;
+ // If we're opening the modal to a specific field we only want to show that field and any related fields
+ const fieldsToRender = filterCriteriaFields?.filter(
+ field => openTo === undefined || field.propertyId === openTo || field.referencePropertyId === openTo
+ );
+
return (
{loading && }
@@ -161,13 +166,15 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
Fields
- {!hasFields &&
No fields defined yet.
}
- {filterCriteriaFields?.map((column, index) => (
+ {!hasFields && (
+
No fields defined yet.
+ )}
+ {fieldsToRender.map((field, index) => (
))}
From e661f82115d7df0cd2aefc5305de217b0bedf84f Mon Sep 17 00:00:00 2001
From: alanv
Date: Tue, 17 Dec 2024 19:01:52 -0600
Subject: [PATCH 31/46] DomainField.serialize - Don't change propertyId or
referencePropertyId in filterCriteria
---
.../src/internal/components/domainproperties/models.tsx | 7 -------
1 file changed, 7 deletions(-)
diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx
index 6d930a7216..8efbcf68f5 100644
--- a/packages/components/src/internal/components/domainproperties/models.tsx
+++ b/packages/components/src/internal/components/domainproperties/models.tsx
@@ -1205,13 +1205,6 @@ export class DomainField
json.scale = UNLIMITED_TEXT_LENGTH;
}
- // Strip out the propertyIds used on the filterCrtieria if they are for new fields (which are negative indexed)
- json.filterCriteria = json.filterCriteria.map(fc => ({
- ...fc,
- propertyId: fc.propertyId < 0 ? undefined : fc.propertyId,
- referencePropertyId: fc.referencePropertyId < 0 ? undefined : fc.referencePropertyId,
- }));
-
// remove non-serializable fields
delete json.dataType;
delete json.lookupQueryValue;
From e592956e641be53ba3bb5d24da66461c6e95bdc3 Mon Sep 17 00:00:00 2001
From: alanv
Date: Tue, 17 Dec 2024 19:15:15 -0600
Subject: [PATCH 32/46] AssayPropertiesInput: Disable Edit Criteria button when
plate metadata is not enabled
---
.../assay/AssayPropertiesInput.tsx | 27 ++++++-------------
.../assay/AssayPropertiesPanel.tsx | 2 +-
2 files changed, 9 insertions(+), 20 deletions(-)
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
index be64bcfa8f..7eec40665d 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
@@ -11,34 +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';
-import { FilterCriteriaRenderer } from '../../../FilterCriteriaRenderer';
interface AssayPropertiesInputProps extends DomainFieldLabelProps, PropsWithChildren {
colSize?: number;
@@ -707,7 +699,7 @@ export const SaveScriptDataInput: FC = memo(({ model, onChange }) =>
));
-export const PlateMetadataInput: FC = memo(props => (
+export const PlateMetadataInput: FC = memo(({ model, onChange }) => (
= memo(props => (
}
>
-
+
));
@@ -737,13 +724,15 @@ export const FilterCriteriaInput: FC = memo(({ model }) => {
if (!domain) return null;
+ const disabledMsg = model.plateMetadata ? undefined : 'Plate Metadata must be enabled';
+
return (
-
+
Edit Criteria
-
+
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
index d07a681219..d2f4990575 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesPanel.tsx
@@ -196,7 +196,7 @@ const AssayPropertiesForm: FC
= memo(props => {
)}
{model.allowPlateMetadata && }
-
+ {model.allowPlateMetadata && }
)}
From b6c08fa6a78b05666d2395f9f2fa774916c46392 Mon Sep 17 00:00:00 2001
From: alanv
Date: Tue, 17 Dec 2024 19:15:59 -0600
Subject: [PATCH 33/46] AssayDesignerPanels: Hide field "edit criteria" buttons
when plate metadata is disabled
---
.../components/domainproperties/assay/AssayDesignerPanels.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
index d2cd48c15f..7273e87b8f 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -109,7 +109,7 @@ const AssayDomainForm: FC = memo(props => {
domainKindDisplayName: 'assay design',
hideFilePropertyType,
hideInferFromFile,
- showFilterCriteria: isResultsDomain,
+ showFilterCriteria: isResultsDomain && protocolModel.plateMetadata,
textChoiceLockedForDomain,
};
}, [
@@ -117,6 +117,7 @@ const AssayDomainForm: FC = memo(props => {
domainFormDisplayOptions,
protocolModel.editableResults,
protocolModel.editableRuns,
+ protocolModel.plateMetadata,
protocolModel.providerName,
]);
return (
From b31be712d8df6adf61e46906d0db7ce03a1175d2 Mon Sep 17 00:00:00 2001
From: alanv
Date: Tue, 17 Dec 2024 19:31:53 -0600
Subject: [PATCH 34/46] FilterCriteriaModal.tsx: Fix issue with empty fields
---
packages/components/src/internal/FilterCriteriaModal.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index 683a39bc7b..e9d2cca2dd 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -150,12 +150,12 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
}, [filterCriteriaFields, selectedFieldId]);
const loading = isLoading(loadingState);
- const hasFields = filterCriteriaFields !== undefined && filterCriteriaFields.length > 0;
// If we're opening the modal to a specific field we only want to show that field and any related fields
const fieldsToRender = filterCriteriaFields?.filter(
field => openTo === undefined || field.propertyId === openTo || field.referencePropertyId === openTo
);
+ const hasFields = fieldsToRender !== undefined && fieldsToRender.length > 0;
return (
@@ -169,7 +169,7 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
{!hasFields && (
No fields defined yet.
)}
- {fieldsToRender.map((field, index) => (
+ {fieldsToRender?.map((field, index) => (
Date: Wed, 18 Dec 2024 10:42:11 -0600
Subject: [PATCH 35/46] Bump api-js
---
packages/components/package-lock.json | 8 ++++----
packages/components/package.json | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json
index ef01b048fe..016f7b4ebf 100644
--- a/packages/components/package-lock.json
+++ b/packages/components/package-lock.json
@@ -10,7 +10,7 @@
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@hello-pangea/dnd": "17.0.0",
- "@labkey/api": "1.36.0-fb-auto-hits.0",
+ "@labkey/api": "1.36.0-fb-auto-hits.3",
"@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-fb-auto-hits.0",
- "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.36.0-fb-auto-hits.0.tgz",
- "integrity": "sha512-RCtNg7XXVvYacOw5fHGZi3VpSraUu0HlO6P9aC1fm7XqKY0bkdF57yPxmbjAwvTHzjszCjDcb6kzuE84iT37hA=="
+ "version": "1.36.0-fb-auto-hits.3",
+ "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.36.0-fb-auto-hits.3.tgz",
+ "integrity": "sha512-jzFxoLbyytRRqYNYe1lxAn+CwZYabQlKcvbme0qmvFyKh1BNxFi9nPEGrZtQaoGf2O9zNxf9L2ZACNZKj54oYQ=="
},
"node_modules/@labkey/build": {
"version": "8.3.0",
diff --git a/packages/components/package.json b/packages/components/package.json
index 308163172a..f4832a1c59 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -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-fb-auto-hits.0",
+ "@labkey/api": "1.36.0-fb-auto-hits.3",
"@testing-library/dom": "~10.4.0",
"@testing-library/jest-dom": "~6.6.3",
"@testing-library/react": "~16.0.1",
From f064bab241db348e239cbcfe9d3969817a0724d4 Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 18 Dec 2024 10:58:10 -0600
Subject: [PATCH 36/46] request.ts - Fix types
---
packages/components/src/internal/request.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/components/src/internal/request.ts b/packages/components/src/internal/request.ts
index ce043a106d..95c9a93da0 100644
--- a/packages/components/src/internal/request.ts
+++ b/packages/components/src/internal/request.ts
@@ -1,8 +1,8 @@
-import { Ajax, Utils, RequestOptions } from '@labkey/api';
+import { Ajax, Utils } from '@labkey/api';
import { handleRequestFailure } from './util/utils';
-type Options = Omit;
+type Options = Omit;
type RequestHandler = (request: XMLHttpRequest) => void;
/**
From ccc8319b924b0df8ff65171e36409c98e0e8d092 Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 18 Dec 2024 11:03:05 -0600
Subject: [PATCH 37/46] models.test.ts - Fix tests
---
.../src/internal/components/domainproperties/models.test.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/packages/components/src/internal/components/domainproperties/models.test.ts b/packages/components/src/internal/components/domainproperties/models.test.ts
index 89b3d9f6cf..336424bc72 100644
--- a/packages/components/src/internal/components/domainproperties/models.test.ts
+++ b/packages/components/src/internal/components/domainproperties/models.test.ts
@@ -128,6 +128,7 @@ const gridDataAppPropsOnlyConst = [
propertyValidators: '',
format: '',
fieldIndex: 0,
+ filterCriteria: '',
importAliases: '',
selected: '',
description: '',
@@ -188,6 +189,7 @@ const gridColumnsConst = [
nameCol,
{ index: 'URL', caption: 'URL', sortable: true },
{ index: 'PHI', caption: 'PHI', sortable: true },
+ { index: 'filterCriteria', caption: 'Filter Criteria', sortable: true },
{ index: 'rangeURI', caption: 'Range URI', sortable: true },
{ index: 'required', caption: 'Required', sortable: true },
{ index: 'lockType', caption: 'Lock Type', sortable: true },
From 60291fa162d6f512a1ebca686800e1cf859aefcc Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 18 Dec 2024 12:33:30 -0600
Subject: [PATCH 38/46] Address PR feedback
---
.../src/internal/FilterCriteriaModal.tsx | 18 ++++++------------
.../src/internal/FilterCriteriaRenderer.tsx | 9 +++------
.../DomainRowExpandedOptions.tsx | 4 +---
.../assay/AssayDesignerPanels.tsx | 7 ++++---
.../components/domainproperties/models.tsx | 5 +++++
5 files changed, 19 insertions(+), 24 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index e9d2cca2dd..5dd46bccd2 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -35,16 +35,14 @@ function fieldLoaderFactory(
): FieldLoader {
return async () => {
const sourceFields = domain.fields
- .filter(field => {
- // Note: Maybe this logic should be in the APIWrapper.getFilterCriteriaColumns?
- const dataType = field.dataType.name;
- return field.measure && (dataType === 'double' || dataType === 'int');
- })
+ .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 fetch(protocolId, sourceFields, container);
- return Object.keys(referenceFields).reduce((result, sourceName) => {
+ return Object.keys(referenceFields).reduce((result, sourceName) => {
const sourceField = domain.fields.find(field => field.name === sourceName);
result.push({
name: sourceField.name,
@@ -59,7 +57,7 @@ function fieldLoaderFactory(
isKeyField: false,
}))
);
- }, [] as FilterCriteriaField[]);
+ }, []);
};
}
@@ -76,11 +74,7 @@ interface Props {
export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, protocolModel }) => {
const { api } = useAppContext();
const { protocolId, container } = protocolModel;
- const domain = useMemo(
- () => protocolModel.domains.find(domain => domain.isNameSuffixMatch('Data')),
- [protocolModel.domains]
- );
-
+ 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]);
diff --git a/packages/components/src/internal/FilterCriteriaRenderer.tsx b/packages/components/src/internal/FilterCriteriaRenderer.tsx
index 82778fb2b8..02b8bf4ea7 100644
--- a/packages/components/src/internal/FilterCriteriaRenderer.tsx
+++ b/packages/components/src/internal/FilterCriteriaRenderer.tsx
@@ -4,11 +4,6 @@ import { Filter } from '@labkey/api';
import { DomainField } from './components/domainproperties/models';
-function getFilterDisplaySymbol(op: string) {
- const filterType = Object.values(Filter.Types).find(ft => ft.getURLSuffix() === op);
- return filterType.getDisplaySymbol();
-}
-
interface FieldWithCriteria {
field: DomainField;
}
@@ -18,12 +13,13 @@ const FilterCriteriaField: FC = memo(({ field }) => {
<>
{field.filterCriteria.map(criteria => (
- {criteria.name} {getFilterDisplaySymbol(criteria.op)} {criteria.value}
+ {criteria.name} {Filter.getFilterTypeForURLSuffix(criteria.op).getDisplaySymbol()} {criteria.value}
))}
>
);
});
+FilterCriteriaField.displayName = 'FilterCriteriaField';
interface Props {
fields: DomainField[];
@@ -52,3 +48,4 @@ export const FilterCriteriaRenderer: FC = memo(({ fields, renderEmptyMess
);
});
+FilterCriteriaRenderer.displayName = 'FilterCriteriaRenderer';
diff --git a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
index b8f1104668..7d59c7a062 100644
--- a/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
+++ b/packages/components/src/internal/components/domainproperties/DomainRowExpandedOptions.tsx
@@ -270,9 +270,7 @@ export class DomainRowExpandedOptions extends React.Component
{
domainFormDisplayOptions,
getDomainFields,
} = this.props;
- const dataType = field.dataType.name;
- const isNumber = dataType === 'double' || dataType === 'int';
- const showFilterCriteria = domainFormDisplayOptions.showFilterCriteria && isNumber && field.measure;
+ const showFilterCriteria = domainFormDisplayOptions.showFilterCriteria && field.isFilterCriteriaField();
return (
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
index 7273e87b8f..c0f73b2884 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -38,7 +38,6 @@ import { FilterCriteriaContext } from './FilterCriteriaContext';
const PROPERTIES_PANEL_INDEX = 0;
const DOMAIN_PANEL_INDEX = 1;
-const resultsDomainPredicate = (domain: DomainDesign): boolean => domain.isNameSuffixMatch('Data');
interface AssayDomainFormProps
extends Omit
{
@@ -336,9 +335,11 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
saveFilterCriteria = (filterCriteria: FilterCriteriaMap) => {
this.setState(current => {
const protocolModel = current.protocolModel;
- const resultsIndex = current.protocolModel.domains.findIndex(resultsDomainPredicate);
+ const resultsIndex = current.protocolModel.domains.findIndex((domain: DomainDesign): boolean =>
+ domain.isNameSuffixMatch('Data')
+ );
const domains = current.protocolModel.domains;
- let resultsDomain = domains.find(resultsDomainPredicate);
+ let resultsDomain = domains.get(resultsIndex);
// Clear the existing values first
let fields = resultsDomain.fields.map(f => f.set('filterCriteria', []) as DomainField).toList();
diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx
index 8efbcf68f5..92e0272bba 100644
--- a/packages/components/src/internal/components/domainproperties/models.tsx
+++ b/packages/components/src/internal/components/domainproperties/models.tsx
@@ -1299,6 +1299,11 @@ export class DomainField
return isFieldDeletable(this);
}
+ isFilterCriteriaField(): boolean {
+ const { dataType, measure } = this;
+ return measure && (dataType.name === 'double' || dataType.name === 'int');
+ }
+
static hasRangeValidation(field: DomainField): boolean {
return (
field.dataType === INTEGER_TYPE ||
From 637159560f301b00a850a576b2faf62e6dfcb91a Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 18 Dec 2024 14:11:31 -0600
Subject: [PATCH 39/46] DomainForm: Move toolbar code to separate component
---
.../domainproperties/DomainForm.tsx | 185 +++++++++++-------
1 file changed, 117 insertions(+), 68 deletions(-)
diff --git a/packages/components/src/internal/components/domainproperties/DomainForm.tsx b/packages/components/src/internal/components/domainproperties/DomainForm.tsx
index a975bff7a5..04f7d2974b 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;
@@ -1022,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 =
@@ -1250,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 (
<>
@@ -1287,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 && (
+
)}
From a835870eaa23f8cef84fab4d3fb8d303c380daa5 Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 18 Dec 2024 14:14:24 -0600
Subject: [PATCH 40/46] Add filterCriteriaToStr
---
packages/components/src/internal/FilterCriteriaRenderer.tsx | 6 ++----
.../src/internal/components/domainproperties/models.tsx | 6 +++++-
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaRenderer.tsx b/packages/components/src/internal/FilterCriteriaRenderer.tsx
index 02b8bf4ea7..c91c139cda 100644
--- a/packages/components/src/internal/FilterCriteriaRenderer.tsx
+++ b/packages/components/src/internal/FilterCriteriaRenderer.tsx
@@ -1,8 +1,6 @@
import React, { FC, memo, useMemo } from 'react';
-import { Filter } from '@labkey/api';
-
-import { DomainField } from './components/domainproperties/models';
+import { DomainField, filterCriteriaToStr } from './components/domainproperties/models';
interface FieldWithCriteria {
field: DomainField;
@@ -13,7 +11,7 @@ const FilterCriteriaField: FC = memo(({ field }) => {
<>
{field.filterCriteria.map(criteria => (
- {criteria.name} {Filter.getFilterTypeForURLSuffix(criteria.op).getDisplaySymbol()} {criteria.value}
+ {filterCriteriaToStr(criteria)}
))}
>
diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx
index 92e0272bba..f8b84a4856 100644
--- a/packages/components/src/internal/components/domainproperties/models.tsx
+++ b/packages/components/src/internal/components/domainproperties/models.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { fromJS, List, Map as ImmutableMap, Record as ImmutableRecord } from 'immutable';
-import { ActionURL, Domain, getServerContext, Utils } from '@labkey/api';
+import { ActionURL, Domain, Filter, getServerContext, Utils } from '@labkey/api';
import React, { ReactNode } from 'react';
import { GRID_NAME_INDEX, GRID_SELECTION_INDEX } from '../../constants';
@@ -901,6 +901,10 @@ export interface FilterCriteria {
// Note: this is a regular Javascript Map, not an Immutable Map
export type FilterCriteriaMap = Map;
+export function filterCriteriaToStr(fc: FilterCriteria): string {
+ return `${fc.name} ${Filter.getFilterTypeForURLSuffix(fc.op).getDisplaySymbol()} ${fc.value}`;
+}
+
export class DomainField
extends ImmutableRecord({
conceptURI: undefined,
From aa8152a7b657bb4680cc2ec3f03a047ede9e6183 Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 18 Dec 2024 15:57:43 -0600
Subject: [PATCH 41/46] DomainPropertiesGrid: optionally render Filter Criteria
---
.../domainproperties/DomainForm.tsx | 7 ++--
.../domainproperties/DomainPropertiesGrid.tsx | 26 ++++++------
.../domainproperties/models.test.ts | 38 ++++++++++++++----
.../components/domainproperties/models.tsx | 40 +++++++++++--------
4 files changed, 73 insertions(+), 38 deletions(-)
diff --git a/packages/components/src/internal/components/domainproperties/DomainForm.tsx b/packages/components/src/internal/components/domainproperties/DomainForm.tsx
index 04f7d2974b..14b1faa9d7 100644
--- a/packages/components/src/internal/components/domainproperties/DomainForm.tsx
+++ b/packages/components/src/internal/components/domainproperties/DomainForm.tsx
@@ -1424,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/models.test.ts b/packages/components/src/internal/components/domainproperties/models.test.ts
index 336424bc72..0f8aa6456d 100644
--- a/packages/components/src/internal/components/domainproperties/models.test.ts
+++ b/packages/components/src/internal/components/domainproperties/models.test.ts
@@ -128,7 +128,6 @@ const gridDataAppPropsOnlyConst = [
propertyValidators: '',
format: '',
fieldIndex: 0,
- filterCriteria: '',
importAliases: '',
selected: '',
description: '',
@@ -189,7 +188,6 @@ const gridColumnsConst = [
nameCol,
{ index: 'URL', caption: 'URL', sortable: true },
{ index: 'PHI', caption: 'PHI', sortable: true },
- { index: 'filterCriteria', caption: 'Filter Criteria', sortable: true },
{ index: 'rangeURI', caption: 'Range URI', sortable: true },
{ index: 'required', caption: 'Required', sortable: true },
{ index: 'lockType', caption: 'Lock Type', sortable: true },
@@ -728,31 +726,46 @@ describe('DomainDesign', () => {
});
test('getGridData with ontology', () => {
- const gridData = GRID_DATA.getGridData(false, true);
+ const gridData = GRID_DATA.getGridData(false, true, false);
expect(gridData.toJS()).toStrictEqual(gridDataConstWithOntology);
});
test('getGridData without ontology', () => {
- const gridData = GRID_DATA.getGridData(false, false);
+ const gridData = GRID_DATA.getGridData(false, false, false);
expect(gridData.toJS()).toStrictEqual(gridDataConst);
});
test('getGridData appPropertiesOnly', () => {
- let gridData = GRID_DATA.getGridData(true, true);
+ let gridData = GRID_DATA.getGridData(true, true, false);
expect(gridData.toJS()).toStrictEqual(gridDataAppPropsOnlyConst);
// should be the same with or without the Ontology module in this case
- gridData = GRID_DATA.getGridData(true, false);
+ gridData = GRID_DATA.getGridData(true, false, false);
expect(gridData.toJS()).toStrictEqual(gridDataAppPropsOnlyConst);
});
+ test('getGridData with filterCriteria', () => {
+ const expected = [
+ {
+ ...gridDataAppPropsOnlyConst[0],
+ filterCriteria: '',
+ },
+ ];
+ let gridData = GRID_DATA.getGridData(true, true, true);
+ expect(gridData.toJS()).toStrictEqual(expected);
+
+ // should be the same with or without the Ontology module in this case
+ gridData = GRID_DATA.getGridData(true, false, true);
+ expect(gridData.toJS()).toStrictEqual(expected);
+ });
+
test('getGridColumns', () => {
const gridColumns = DomainDesign.create({
fields: [
{ name: 'a', rangeURI: INTEGER_TYPE.rangeURI },
{ name: 'b', rangeURI: TEXT_TYPE.rangeURI },
],
- }).getGridColumns(jest.fn(), jest.fn(), 'domainKindName', false, false);
+ }).getGridColumns(jest.fn(), jest.fn(), 'domainKindName', false, false, false);
expect(gridColumns.toJS().slice(2)).toStrictEqual(gridColumnsConst.slice(2));
@@ -769,6 +782,17 @@ describe('DomainDesign', () => {
const nameColConstTest = gridColumnsConst[1] as GridColumn;
delete nameColConstTest.cell;
expect(nameColTest).toStrictEqual(nameColConstTest);
+
+ const gridColumnsWithFilterCriteria = DomainDesign.create({
+ fields: [
+ { name: 'a', rangeURI: INTEGER_TYPE.rangeURI },
+ { name: 'b', rangeURI: TEXT_TYPE.rangeURI },
+ ],
+ }).getGridColumns(jest.fn(), jest.fn(), 'domainKindName', false, false, true);
+ const expectedFilterCriteriaColumn = { index: 'filterCriteria', caption: 'Filter Criteria', sortable: true };
+ expect(gridColumnsWithFilterCriteria.toJS().find(c => c.index === 'filterCriteria')).toStrictEqual(
+ expectedFilterCriteriaColumn
+ );
});
test('uniqueConstraintFieldNames in create', () => {
diff --git a/packages/components/src/internal/components/domainproperties/models.tsx b/packages/components/src/internal/components/domainproperties/models.tsx
index f8b84a4856..c7030e6dff 100644
--- a/packages/components/src/internal/components/domainproperties/models.tsx
+++ b/packages/components/src/internal/components/domainproperties/models.tsx
@@ -427,7 +427,7 @@ export class DomainDesign
return mapping;
}
- getGridData(appPropertiesOnly: boolean, hasOntologyModule: boolean): List {
+ getGridData(appPropertiesOnly: boolean, hasOntologyModule: boolean, showFilterCriteria: boolean): List {
return this.fields
.map((field, i) => {
let fieldSerial = DomainField.serialize(field);
@@ -445,6 +445,12 @@ export class DomainDesign
fieldSerial.selected = field.selected;
fieldSerial.visible = field.visible;
+ if (showFilterCriteria) {
+ fieldSerial.filterCriteria = field.filterCriteria.map(filterCriteriaToStr).join('\n');
+ } else {
+ delete fieldSerial.filterCriteria;
+ }
+
return ImmutableMap(
Object.keys(fieldSerial).map(key => {
const rawVal = fieldSerial[key];
@@ -481,7 +487,8 @@ export class DomainDesign
scrollFunction: (i: number) => void,
domainKindName: string,
appPropertiesOnly: boolean,
- hasOntologyModule: boolean
+ hasOntologyModule: boolean,
+ showFilterCriteria: boolean
): List {
const selectionCol = new GridColumn({
index: GRID_SELECTION_INDEX,
@@ -529,18 +536,16 @@ export class DomainDesign
delete columns.name;
columns = removeUnusedProperties(columns);
- if (!hasOntologyModule) {
- columns = removeUnusedOntologyProperties(columns);
- }
- if (appPropertiesOnly) {
- columns = removeNonAppProperties(columns);
- }
- if (domainKindName !== VAR_LIST && domainKindName !== INT_LIST) {
- delete columns.isPrimaryKey;
- }
- if (!(appPropertiesOnly && domainKindName === 'SampleSet')) {
- delete columns.scannable;
- }
+
+ if (!hasOntologyModule) columns = removeUnusedOntologyProperties(columns);
+
+ if (appPropertiesOnly) columns = removeNonAppProperties(columns);
+
+ if (domainKindName !== VAR_LIST && domainKindName !== INT_LIST) delete columns.isPrimaryKey;
+
+ if (!(appPropertiesOnly && domainKindName === 'SampleSet')) delete columns.scannable;
+
+ if (!showFilterCriteria) delete columns.filterCriteria;
const unsortedColumns = List(
Object.keys(columns).map(key => ({ index: key, caption: camelCaseToTitleCase(key), sortable: true }))
@@ -2102,8 +2107,8 @@ export interface IDomainFormDisplayOptions {
isDragDisabled?: boolean;
phiLevelDisabled?: boolean;
retainReservedFields?: boolean;
- showScannableOption?: boolean;
showFilterCriteria?: boolean;
+ showScannableOption?: boolean;
textChoiceLockedForDomain?: boolean;
textChoiceLockedSqlFragment?: string;
}
@@ -2139,7 +2144,10 @@ export class DomainDetails extends ImmutableRecord({
declare nameReadOnly?: boolean;
declare namePreviews?: string[];
- static create(rawDesign: ImmutableMap = ImmutableMap(), domainKindType: string = Domain.KINDS.UNKNOWN): DomainDetails {
+ static create(
+ rawDesign: ImmutableMap = ImmutableMap(),
+ domainKindType: string = Domain.KINDS.UNKNOWN
+ ): DomainDetails {
let design;
if (rawDesign) {
const domainDesign = DomainDesign.create(rawDesign.get('domainDesign'));
From dfa689b4fa4141e33dabf92a953a19c71fc772d0 Mon Sep 17 00:00:00 2001
From: alanv
Date: Wed, 18 Dec 2024 18:07:00 -0600
Subject: [PATCH 42/46] AssayDesignerPanels.tsx: fix issue with header prefix
---
.../domainproperties/assay/AssayDesignerPanels.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
index c0f73b2884..1c76621913 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayDesignerPanels.tsx
@@ -45,6 +45,7 @@ interface AssayDomainFormProps
appDomainHeaders: Map;
domain: DomainDesign;
domainFormDisplayOptions: IDomainFormDisplayOptions;
+ headerPrefix: string;
hideAdvancedProperties?: boolean;
index: number;
onDomainChange: (
@@ -65,6 +66,7 @@ const AssayDomainForm: FC = memo(props => {
domain,
domainFormDisplayOptions,
firstState,
+ headerPrefix,
hideAdvancedProperties,
index,
onDomainChange,
@@ -126,7 +128,7 @@ const AssayDomainForm: FC = memo(props => {
index={domain.domainId || index}
domainIndex={index}
domain={domain}
- headerPrefix={protocolModel?.name}
+ headerPrefix={headerPrefix}
controlledCollapse
initCollapsed={currentPanelIndex !== index + DOMAIN_PANEL_INDEX}
validate={validatePanel === index + DOMAIN_PANEL_INDEX}
@@ -385,6 +387,7 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
appDomainHeaders,
appPropertiesOnly,
hideAdvancedProperties,
+ initModel,
domainFormDisplayOptions,
currentPanelIndex,
validatePanel,
@@ -442,6 +445,7 @@ export class AssayDesignerPanelsImpl extends React.PureComponent {
appDomainHeaders={appDomainHeaders}
domain={domain}
domainFormDisplayOptions={domainFormDisplayOptions}
+ headerPrefix={initModel?.name}
index={i}
key={domain.name}
onDomainChange={this.onDomainChange}
From fedfed0a8c44f2d23de0a6e0d5456055cbf5f178 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 19 Dec 2024 14:32:05 -0600
Subject: [PATCH 43/46] PlateMetadataInput - Only render filter criteria for
valid fields
---
.../domainproperties/assay/AssayPropertiesInput.tsx | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
index 7eec40665d..c1e4310287 100644
--- a/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
+++ b/packages/components/src/internal/components/domainproperties/assay/AssayPropertiesInput.tsx
@@ -715,14 +715,13 @@ export const PlateMetadataInput: FC = memo(({ model, onChange }) =>
export const FilterCriteriaInput: FC = memo(({ model }) => {
const context = useFilterCriteriaContext();
-
- if (!context) return null;
-
- const { openModal } = context;
- const onClick = useCallback(() => openModal(), [openModal]);
+ const onClick = useCallback(() => context.openModal(), [context?.openModal]);
const domain = useMemo(() => model.domains.find(domain => domain.isNameSuffixMatch('Data')), [model.domains]);
+ const fields = useMemo(() => {
+ return domain?.fields.filter(df => df.isFilterCriteriaField()).toArray() ?? [];
+ }, [domain]);
- if (!domain) return null;
+ if (!domain || !context) return null;
const disabledMsg = model.plateMetadata ? undefined : 'Plate Metadata must be enabled';
@@ -735,7 +734,7 @@ export const FilterCriteriaInput: FC = memo(({ model }) => {
-
+
From b761f5d58e68d03ddc9d251e062699653735d859 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 19 Dec 2024 16:59:19 -0600
Subject: [PATCH 44/46] FilterCriteriaModal: fix issues
- Fix issue with openTo looking at wrong fields
- Fix issue with filter types not being properly limited to the field type
---
.../src/internal/FilterCriteriaModal.tsx | 50 +++++++++++--------
.../internal/components/assay/APIWrapper.ts | 8 ++-
.../src/internal/components/assay/models.ts | 4 +-
.../src/internal/useLoadableState.ts | 3 ++
4 files changed, 40 insertions(+), 25 deletions(-)
diff --git a/packages/components/src/internal/FilterCriteriaModal.tsx b/packages/components/src/internal/FilterCriteriaModal.tsx
index 5dd46bccd2..33e383ea5d 100644
--- a/packages/components/src/internal/FilterCriteriaModal.tsx
+++ b/packages/components/src/internal/FilterCriteriaModal.tsx
@@ -15,13 +15,15 @@ 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 ReferenceFieldFetcher = (
+type ReferenceFieldLoader = (
protocolId: number,
columnNames: string[],
containerPath: string
@@ -31,7 +33,7 @@ function fieldLoaderFactory(
protocolId: number,
container: string,
domain: DomainDesign,
- fetch: ReferenceFieldFetcher
+ load: ReferenceFieldLoader
): FieldLoader {
return async () => {
const sourceFields = domain.fields
@@ -40,21 +42,23 @@ function fieldLoaderFactory(
.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 fetch(protocolId, sourceFields, container);
+ 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({
- name: sourceField.name,
propertyId: sourceField.propertyId,
isKeyField: sourceField.isPrimaryKey || sourceField.isUniqueIdField(),
+ jsonType: sourceField.dataType.getJsonType(),
+ name: sourceField.name,
});
return result.concat(
- referenceFields[sourceName].map((rawField, index) => ({
- name: rawField.name,
- propertyId: rawField.propertyId === 0 ? sourceField.propertyId * 100 + index : rawField.propertyId,
- referencePropertyId: sourceField.propertyId,
+ 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,
}))
);
}, []);
@@ -85,14 +89,18 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
() => fieldLoaderFactory(protocolId, container, domain, api.assay.getFilterCriteriaColumns),
[api.assay.getFilterCriteriaColumns, container, domain, protocolId]
);
- const { loadingState, value: filterCriteriaFields } = useLoadableState(loader);
-
+ 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(filterCriteriaFields[idx].propertyId),
- [filterCriteriaFields]
- );
+ const onSelect = useCallback((idx: number) => setSelectedFieldId(fieldsToRender[idx].propertyId), [fieldsToRender]);
const onFieldFilterUpdate = useCallback(
(newFilters: Filter.IFilter[]) => {
@@ -136,25 +144,22 @@ export const FilterCriteriaModal: FC = memo(({ onClose, onSave, openTo, p
const currentColumn: QueryColumn = useMemo(() => {
const currentField = filterCriteriaFields?.find(field => field.propertyId === selectedFieldId);
if (currentField === undefined) return undefined;
+
return new QueryColumn({
- fieldKey: currentField.name,
caption: currentField.name,
+ fieldKey: currentField.name,
isKeyField: currentField.isKeyField,
+ jsonType: currentField.jsonType,
});
}, [filterCriteriaFields, selectedFieldId]);
const loading = isLoading(loadingState);
-
- // If we're opening the modal to a specific field we only want to show that field and any related fields
- const fieldsToRender = filterCriteriaFields?.filter(
- field => openTo === undefined || field.propertyId === openTo || field.referencePropertyId === openTo
- );
const hasFields = fieldsToRender !== undefined && fieldsToRender.length > 0;
return (
{loading && }
- {!loading && (
+ {!loading && !error && (
Fields
@@ -165,7 +170,7 @@ export const FilterCriteriaModal: FC
= memo(({ onClose, onSave, openTo, p
)}
{fieldsToRender?.map((field, index) => (
= memo(({ onClose, onSave, openTo, p
)}
+ {error}
);
});
diff --git a/packages/components/src/internal/components/assay/APIWrapper.ts b/packages/components/src/internal/components/assay/APIWrapper.ts
index 08fa4a0283..dfde6a9059 100644
--- a/packages/components/src/internal/components/assay/APIWrapper.ts
+++ b/packages/components/src/internal/components/assay/APIWrapper.ts
@@ -6,6 +6,8 @@ import { AssayProtocolModel } from '../domainproperties/assay/models';
import { request } from '../../request';
+import { DomainField } from '../domainproperties/models';
+
import {
checkForDuplicateAssayFiles,
clearAssayDefinitionCache,
@@ -41,7 +43,7 @@ export class AssayServerAPIWrapper implements AssayAPIWrapper {
columnNames: string[],
containerPath: string
): Promise => {
- return request(
+ const resp = await request(
{
url: ActionURL.buildURL('assay', 'filterCriteriaColumns.api', containerPath),
method: 'POST',
@@ -49,6 +51,10 @@ export class AssayServerAPIWrapper implements AssayAPIWrapper {
},
'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;
diff --git a/packages/components/src/internal/components/assay/models.ts b/packages/components/src/internal/components/assay/models.ts
index e3b8b97a7a..cfadd30237 100644
--- a/packages/components/src/internal/components/assay/models.ts
+++ b/packages/components/src/internal/components/assay/models.ts
@@ -17,7 +17,7 @@ import { immerable, produce } from 'immer';
import { AssayDefinitionModel } from '../../AssayDefinitionModel';
import { LoadingState } from '../../../public/LoadingState';
-import { IDomainField } from '../domainproperties/models';
+import { DomainField } from '../domainproperties/models';
export class AssayUploadResultModel {
[immerable] = true;
@@ -85,4 +85,4 @@ export class AssayStateModel {
}
}
-export type FilterCriteriaColumns = Record;
+export type FilterCriteriaColumns = Record;
diff --git a/packages/components/src/internal/useLoadableState.ts b/packages/components/src/internal/useLoadableState.ts
index 1fe59aeb5a..d1d11c109a 100644
--- a/packages/components/src/internal/useLoadableState.ts
+++ b/packages/components/src/internal/useLoadableState.ts
@@ -27,6 +27,9 @@ export function useLoadableState(loader: Loader): LoadableState {
const result = await loader();
setValue(result);
} catch (e) {
+ // Note: it's important to log the error here, because if consumers don't use the error object returned here
+ // then you may not know an error happened, so this way we at least have some trace of an issue.
+ console.error(e);
setError(resolveErrorMessage(e));
} finally {
setLoadingState(LoadingState.LOADED);
From 6d59d93246e91674e68e41a871e530dc0fafbab2 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 19 Dec 2024 17:18:17 -0600
Subject: [PATCH 45/46] Bump api-js
---
packages/components/package-lock.json | 8 ++++----
packages/components/package.json | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json
index 016f7b4ebf..1ac0bb6b4f 100644
--- a/packages/components/package-lock.json
+++ b/packages/components/package-lock.json
@@ -10,7 +10,7 @@
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"@hello-pangea/dnd": "17.0.0",
- "@labkey/api": "1.36.0-fb-auto-hits.3",
+ "@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-fb-auto-hits.3",
- "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.36.0-fb-auto-hits.3.tgz",
- "integrity": "sha512-jzFxoLbyytRRqYNYe1lxAn+CwZYabQlKcvbme0qmvFyKh1BNxFi9nPEGrZtQaoGf2O9zNxf9L2ZACNZKj54oYQ=="
+ "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 f4832a1c59..42b6d61b70 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -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-fb-auto-hits.3",
+ "@labkey/api": "1.37.0",
"@testing-library/dom": "~10.4.0",
"@testing-library/jest-dom": "~6.6.3",
"@testing-library/react": "~16.0.1",
From 3885d596d3ca55ee684817a7fbe5139fe5cd3f32 Mon Sep 17 00:00:00 2001
From: alanv
Date: Thu, 19 Dec 2024 17:20:16 -0600
Subject: [PATCH 46/46] Prep for release
---
packages/components/package-lock.json | 4 ++--
packages/components/package.json | 2 +-
packages/components/releaseNotes/components.md | 4 ++--
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json
index 1ac0bb6b4f..5b3df65bac 100644
--- a/packages/components/package-lock.json
+++ b/packages/components/package-lock.json
@@ -1,12 +1,12 @@
{
"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",
diff --git a/packages/components/package.json b/packages/components/package.json
index 42b6d61b70..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": [
diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md
index 6db9af3b92..17a754666c 100644
--- a/packages/components/releaseNotes/components.md
+++ b/packages/components/releaseNotes/components.md
@@ -1,8 +1,8 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages
-### version ?.??.?
-*Released*: ?? December 2024
+### version 6.8.0
+*Released*: 19 December 2024
- Add FilterCriteriaRenderer
- Add FilterCriteriaModal
- Add useLoadableState