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