Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
4b668b5
Fix unique key warnings in domain designer
labkey-alan Nov 18, 2024
c026642
CLean up filter panel styles
labkey-alan Nov 21, 2024
07bc2b0
Add HitCriteriaRenderer
labkey-alan Nov 21, 2024
27275cc
Add HitCriteriaModal and useLoadableState
labkey-alan Nov 21, 2024
2db3dd8
Render HitCriteria
labkey-alan Nov 21, 2024
34a69e1
Update release notes
labkey-alan Nov 21, 2024
6cbdf4d
FilterExpressionView: memoize callbacks
labkey-alan Nov 21, 2024
12c29dd
AssayDesignerPanels: Fix issue with appDomainHeaders, fix tests
labkey-alan Nov 25, 2024
8a36111
Cleanup
labkey-alan Nov 25, 2024
477abce
Prevent serialization of "hitCriteria" (stop gap)
labkey-nicka Dec 6, 2024
ec826a8
Bump @labkey/api
labkey-alan Dec 11, 2024
27f15a3
Add request util
labkey-alan Dec 11, 2024
db6e8cc
Add getFilterCriteriaColumns to assay API wrapper
labkey-alan Dec 12, 2024
3673d93
Update AssayDesigner to assume filterCriteria is on DomainFields
labkey-alan Dec 12, 2024
51cde56
Remove protocolModel from FilterCriteriaContext
labkey-alan Dec 12, 2024
c5525a5
Remove hack
labkey-alan Dec 12, 2024
647dc82
FilterCriteriaModal - use propertyIds
labkey-alan Dec 16, 2024
fa55e1b
request: Add RequesstHandler
labkey-alan Dec 16, 2024
50474a1
Fix styling for FilterCriteriaRenderer
labkey-alan Dec 16, 2024
fcbae9b
Update filter criteria when field name changes
labkey-alan Dec 16, 2024
86f32c9
FilterCriteriaModal: make FilterCriteriaMap a JS map
labkey-alan Dec 16, 2024
00f0d30
Export FilterCriteriaRenderer
labkey-alan Dec 16, 2024
289cc78
FilterCriteriaRenderer: optionally render empty message
labkey-alan Dec 16, 2024
4523a5b
Fix styling for hit-criteria-renderer
labkey-alan Dec 16, 2024
1ea2733
Fix styling for FilterCriteriaRenderer
labkey-alan Dec 16, 2024
639e6ed
Fix class names
labkey-alan Dec 18, 2024
8c31f1f
FilterCriteriaModal: Add empty messages
labkey-alan Dec 18, 2024
da1d015
AssayDesignerPanels: Fix issue with saveFilterCriteria
labkey-alan Dec 18, 2024
2eb323b
FilterCriteriaModal: Give reference properties stable propertyId values
labkey-alan Dec 18, 2024
b25223c
FilterCriteriaModal: filter fields to openTo and related
labkey-alan Dec 18, 2024
e661f82
DomainField.serialize - Don't change propertyId or referencePropertyI…
labkey-alan Dec 18, 2024
e592956
AssayPropertiesInput: Disable Edit Criteria button when plate metadat…
labkey-alan Dec 18, 2024
b6c08fa
AssayDesignerPanels: Hide field "edit criteria" buttons when plate me…
labkey-alan Dec 18, 2024
b31be71
FilterCriteriaModal.tsx: Fix issue with empty fields
labkey-alan Dec 18, 2024
3fe7883
Bump api-js
labkey-alan Dec 18, 2024
f064bab
request.ts - Fix types
labkey-alan Dec 18, 2024
ccc8319
models.test.ts - Fix tests
labkey-alan Dec 18, 2024
60291fa
Address PR feedback
labkey-alan Dec 18, 2024
6371595
DomainForm: Move toolbar code to separate component
labkey-alan Dec 18, 2024
a835870
Add filterCriteriaToStr
labkey-alan Dec 18, 2024
aa8152a
DomainPropertiesGrid: optionally render Filter Criteria
labkey-alan Dec 18, 2024
dfa689b
AssayDesignerPanels.tsx: fix issue with header prefix
labkey-alan Dec 19, 2024
fedfed0
PlateMetadataInput - Only render filter criteria for valid fields
labkey-alan Dec 19, 2024
b761f5d
FilterCriteriaModal: fix issues
labkey-alan Dec 19, 2024
6d59d93
Bump api-js
labkey-alan Dec 19, 2024
3885d59
Prep for release
labkey-alan Dec 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/components/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/components/package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
201 changes: 201 additions & 0 deletions packages/components/src/internal/FilterCriteriaModal.tsx
Original file line number Diff line number Diff line change
@@ -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<FilterCriteria, 'op' | 'value'>;
interface FilterCriteriaField extends BaseFilterCriteriaField {
isKeyField: boolean;
jsonType: string;
}
type FieldLoader = () => Promise<FilterCriteriaField[]>;
type ReferenceFieldLoader = (
protocolId: number,
columnNames: string[],
containerPath: string
) => Promise<FilterCriteriaColumns>;

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<FilterCriteriaField[]>((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<Props> = memo(({ onClose, onSave, openTo, protocolModel }) => {
const { api } = useAppContext();
const { protocolId, container } = protocolModel;
const domain = useMemo(() => protocolModel.getDomainByNameSuffix('Data'), [protocolModel]);
const [filterCriteria, setFilterCriteria] = useState<FilterCriteriaMap>(() => {
return domain.fields.reduce((result, field) => {
if (field.filterCriteria) result.set(field.propertyId, [...field.filterCriteria]);
return result;
}, new Map<number, FilterCriteria[]>());
});
const loader = useMemo(
() => fieldLoaderFactory(protocolId, container, domain, api.assay.getFilterCriteriaColumns),
[api.assay.getFilterCriteriaColumns, container, domain, protocolId]
);
const { error, loadingState, value: filterCriteriaFields } = useLoadableState<FilterCriteriaField[]>(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<number>(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 (
<Modal bsSize="lg" title="Hit Selection Criteria" onCancel={onClose} onConfirm={onConfirm} confirmText="Apply">
{loading && <LoadingSpinner />}
{!loading && !error && (
<div className="filter-criteria-modal-body field-modal__container row">
<div className="col-sm-4 field-modal__col">
<div className="field-modal__col-title">Fields</div>
<div className="field-modal__col-content">
<div className="list-group">
{!hasFields && (
<div className="field-modal__empty-msg padding">No fields defined yet.</div>
)}
{fieldsToRender?.map((field, index) => (
<ChoicesListItem
active={fieldsToRender[index].propertyId === selectedFieldId}
index={index}
key={field.name}
label={field.name}
onSelect={onSelect}
/>
))}
</div>
</div>
</div>
<div className="col-sm-8 field-modal__col">
<div className="field-modal__col-title">Filter Criteria</div>
<div className="field-modal__col-content field-modal__values">
{!currentColumn && <div className="field-modal__empty-msg">Select a field.</div>}
{currentColumn && (
<FilterExpressionView
field={currentColumn}
fieldFilters={fieldFilters}
onFieldFilterUpdate={onFieldFilterUpdate}
/>
)}
</div>
</div>
</div>
)}
<Alert>{error}</Alert>
</Modal>
);
});
49 changes: 49 additions & 0 deletions packages/components/src/internal/FilterCriteriaRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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<FieldWithCriteria> = memo(({ field }) => {
return (
<>
{field.filterCriteria.map(criteria => (
<li className="hit-criteria-renderer__field-value" key={criteria.name + criteria.op + criteria.value}>
{filterCriteriaToStr(criteria)}
</li>
))}
</>
);
});
FilterCriteriaField.displayName = 'FilterCriteriaField';

interface Props {
fields: DomainField[];
renderEmptyMessage?: boolean;
}

export const FilterCriteriaRenderer: FC<Props> = memo(({ fields, renderEmptyMessage = true }) => {
const fieldsWithCriteria = useMemo(
() => fields.filter(field => field.filterCriteria && field.filterCriteria.length > 0),
[fields]
);
const showEmptyMessage = fieldsWithCriteria.length === 0 && renderEmptyMessage;

return (
<div className="filter-criteria-renderer">
{showEmptyMessage && (
<div className="gray-text">
<em>No Hit Selection Criteria</em>
</div>
)}
<ul>
{fieldsWithCriteria.map(field => (
<FilterCriteriaField field={field} key={field.propertyId} />
))}
</ul>
</div>
);
});
FilterCriteriaRenderer.displayName = 'FilterCriteriaRenderer';
Loading