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 ( +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + ); +}); 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; + + {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.

    } - > -