diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 26bd5b2471..b37df871a0 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.5.1", + "version": "7.6.0-fb-jobTaskFilter.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.5.1", + "version": "7.6.0-fb-jobTaskFilter.3", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 292f9e7eba..8fe5ee8263 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.5.1", + "version": "7.6.0-fb-jobTaskFilter.3", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index ed811ff4de..cc4746db5c 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,14 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.X +*Released*: x December 2025 +- Workflow Automation: Task action to filter samples for selected task + - Added `lockReadOnlyForDelete` prop for workflow filter components to prevent modification of read-only filters used for deletion. + - Introduced `EntityFieldFilter` type (renamed from `FieldFilter`) for improved clarity and consistency across workflow task filters. + - Added new filter action helper functions: `getActionValuesForFilterProps` and `removeFilterValueForFilterProps` to simplify reading and updating filter values. + - Added support for `supportAllValueInQuery` in entity data types so workflow filters can include an "All" option when querying entities. + ### version 7.5.1 *Released*: 24 December 2025 - Add `displaySelectedOptions` prop and respect setting when passing `selectedOptions` to the underlying `SelectInput` diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index bafbf59c72..0549a3b02a 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -346,6 +346,7 @@ import { FindDerivativesButton, FindDerivativesMenuItem, getSampleFinderLocalStorageKey, + getSearchFilterObj, getSearchFilterObjs, SAMPLE_FINDER_SESSION_PREFIX, searchFiltersToJson, @@ -624,6 +625,13 @@ import { GridPanel, GridPanelWithModel } from './public/QueryModel/GridPanel'; import { TabbedGridPanel } from './public/QueryModel/TabbedGridPanel'; import { DetailPanel, DetailPanelWithModel } from './public/QueryModel/DetailPanel'; import { makeTestActions, makeTestQueryModel } from './public/QueryModel/testUtils'; +import { FilterStatus } from './public/QueryModel/FilterStatus'; +import { + FilterAction, + getActionValuesForFilterProps, + removeFilterValueForFilterProps, +} from './public/QueryModel/grid/actions/Filter'; + import { BACKGROUND_IMPORT_MIN_FILE_SIZE, BACKGROUND_IMPORT_MIN_ROW_SIZE, @@ -1287,7 +1295,9 @@ export { FileColumnRenderer, FileInput, FileTree, + FilterAction, FilterCriteriaRenderer, + FilterStatus, FIND_BY_IDS_QUERY_PARAM, FindByIdsModal, FindDerivativesButton, @@ -1315,6 +1325,7 @@ export { generateId, generateNameWithTimestamp, getActionErrorMessage, + getActionValuesForFilterProps, getAltUnitKeys, getAssayDefinitions, getAuditQueries, @@ -1393,6 +1404,7 @@ export { getSampleTypeDetails, getSampleTypesFromTransactionIds, getSchemaQuery, + getSearchFilterObj, getSearchFilterObjs, getSearchScopeFromContainerFilter, getSelected, @@ -1597,6 +1609,7 @@ export { removeColumn, removeColumns, RemoveEntityButton, + removeFilterValueForFilterProps, removeParameters, renderWithAppContext, replaceParameters, @@ -1865,7 +1878,7 @@ export type { StorageActionStatusCounts, } from './internal/components/samples/models'; export type { SearchHit, SearchOptions } from './internal/components/search/actions'; -export type { FieldFilter } from './internal/components/search/models'; +export type { EntityFieldFilter } from './internal/components/search/models'; export type { SecurityAPIWrapper } from './internal/components/security/APIWrapper'; export type { IDataViewInfo } from './internal/DataViewInfo'; export type { BSStyle } from './internal/dropdowns'; @@ -1897,9 +1910,11 @@ export type { QueryParams } from './internal/util/URL'; export type { FileSizeLimitProps } from './public/files/models'; export type { ImportTemplate } from './public/QueryInfo'; export type { EditableDetailPanelProps } from './public/QueryModel/EditableDetailPanel'; +export type { Action, ActionValue } from './public/QueryModel/grid/actions/Action'; export type { QueryConfig } from './public/QueryModel/QueryModel'; export type { QueryModelLoader } from './public/QueryModel/QueryModelLoader'; export type { TabbedGridPanelProps } from './public/QueryModel/TabbedGridPanel'; + // Due to babel-loader & typescript babel plugins we need to export/import types separately. The babel plugins require // the typescript compiler option "isolatedModules", which do not export types from modules, so types must be exported // separately. diff --git a/packages/components/src/internal/components/entities/FindDerivativesButton.test.tsx b/packages/components/src/internal/components/entities/FindDerivativesButton.test.tsx index 97f895a8cb..a5f9c48fff 100644 --- a/packages/components/src/internal/components/entities/FindDerivativesButton.test.tsx +++ b/packages/components/src/internal/components/entities/FindDerivativesButton.test.tsx @@ -10,7 +10,7 @@ import { SchemaQuery } from '../../../public/SchemaQuery'; import { TestTypeDataType, TestTypeDataTypeWithEntityFilter } from '../../../test/data/constants'; -import { FieldFilter } from '../search/models'; +import { EntityFieldFilter } from '../search/models'; import { SCHEMAS } from '../../schemas'; @@ -232,14 +232,14 @@ const anyValueFilter = { fieldCaption: 'textField', filter: Filter.create('textField', null, Filter.Types.HAS_ANY_VALUE), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const stringBetweenFilter = { fieldKey: 'strField', fieldCaption: 'strField', filter: Filter.create('strField', ['1', '5'], Filter.Types.BETWEEN), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const card = { entityDataType: TestTypeDataType, diff --git a/packages/components/src/internal/components/entities/FindDerivativesButton.tsx b/packages/components/src/internal/components/entities/FindDerivativesButton.tsx index ae95958dff..2f79ffd27e 100644 --- a/packages/components/src/internal/components/entities/FindDerivativesButton.tsx +++ b/packages/components/src/internal/components/entities/FindDerivativesButton.tsx @@ -10,7 +10,7 @@ import { QueryColumn } from '../../../public/QueryColumn'; import { QueryInfo } from '../../../public/QueryInfo'; import { isValidFilterField } from '../search/utils'; import { QueryModel } from '../../../public/QueryModel/QueryModel'; -import { FieldFilter } from '../search/models'; +import { EntityFieldFilter } from '../search/models'; import { useAppContext } from '../../AppContext'; import { ResponsiveMenuButton } from '../buttons/ResponsiveMenuButton'; @@ -44,7 +44,7 @@ export function isValidFilterFieldSampleFinder( } // exported for unit test coverage -export const getFieldFilter = (model: QueryModel, filter: Filter.IFilter): FieldFilter => { +export const getFieldFilter = (model: QueryModel, filter: Filter.IFilter): EntityFieldFilter => { const colName = filter.getColumnName(); const column = model.getColumn(colName); @@ -53,7 +53,7 @@ export const getFieldFilter = (model: QueryModel, filter: Filter.IFilter): Field fieldCaption: column?.caption ?? colName, filter, jsonType: column?.isLookup() ? column.displayFieldJsonType : (column?.jsonType ?? 'string'), - } as FieldFilter; + } as EntityFieldFilter; }; // exported for unit test coverage @@ -126,16 +126,15 @@ function filterToJson(filter: Filter.IFilter): string { return encodeURIComponent(filter.getURLParameterName()) + '=' + encodeURIComponent(filter.getURLParameterValue()); } -export function getSearchFilterObjs(filterProps: FilterProps[]): any[] { - const filterPropsObj = []; - - filterProps.forEach(filterProp => { - const filterPropObj = { ...filterProp }; - delete filterPropObj['entityDataType']; - // don't persist the entire entitydatatype +export function getSearchFilterObj(filterProp: FilterProps): any { + const filterPropObj = { ...filterProp }; + delete filterPropObj['entityDataType']; + // don't persist the entire entitydatatype + if (filterProp.entityDataType) filterPropObj['sampleFinderCardType'] = filterProp.entityDataType.sampleFinderCardType; - const filterArrayObjs = []; + const filterArrayObjs = []; + if (filterPropObj.filterArray) { [...filterPropObj.filterArray].forEach(field => { filterArrayObjs.push({ fieldKey: field.fieldKey, @@ -145,8 +144,16 @@ export function getSearchFilterObjs(filterProps: FilterProps[]): any[] { }); }); filterPropObj.filterArray = filterArrayObjs; + } - filterPropsObj.push(filterPropObj); + return filterPropObj; +} + +export function getSearchFilterObjs(filterProps: FilterProps[]): any[] { + const filterPropsObj = []; + + filterProps.forEach(filterProp => { + filterPropsObj.push(getSearchFilterObj(filterProp)); }); return filterPropsObj; diff --git a/packages/components/src/internal/components/entities/models.ts b/packages/components/src/internal/components/entities/models.ts index 7167658821..fbf53a6913 100644 --- a/packages/components/src/internal/components/entities/models.ts +++ b/packages/components/src/internal/components/entities/models.ts @@ -29,7 +29,7 @@ import { SCHEMAS } from '../../schemas'; import { EntityCreationType } from '../samples/models'; import { QueryInfo } from '../../../public/QueryInfo'; import { ViewInfo } from '../../ViewInfo'; -import { FieldFilter } from '../search/models'; +import { EntityFieldFilter } from '../search/models'; export interface EntityInputProps { role: string; @@ -41,7 +41,7 @@ export interface IDerivePayload { materialDefault?: any; materialInputs?: EntityInputProps[]; materialOutputCount?: number; - materialOutputs?: Array<{ [key: string]: any }>; + materialOutputs?: Record[]; targetType: string; } @@ -434,14 +434,14 @@ export interface IEntityTypeDetails extends IEntityDetails { importAliasValues?: string[]; } -export type SampleFinderCardType = 'sampleproperty' | 'sampleparent' | 'dataclassparent' | 'assaydata'; +export type SampleFinderCardType = 'assaydata' | 'dataclassparent' | 'sampleparent' | 'sampleproperty'; export type FolderConfigurableDataType = - | 'SampleType' + | 'AssayDesign' + | 'Container' | 'DashboardSampleType' | 'DataClass' - | 'AssayDesign' - | 'StorageLocation' - | 'Container'; + | 'SampleType' + | 'StorageLocation'; /** * Avoid inline comment or above line comments for properties due to es-lint's limitation on moving comments: @@ -507,6 +507,7 @@ export interface EntityDataType { operationConfirmationActionName: string; operationConfirmationControllerName: string; sampleFinderCardType?: SampleFinderCardType; + supportAllValueInQuery?: boolean; supportHasNoValueInQuery?: boolean; supportsCrossTypeImport?: boolean; typeIcon?: string; @@ -649,7 +650,7 @@ export interface FilterProps { disabled?: boolean; entityDataType: EntityDataType; // the filters to be used in conjunction with the schemaQuery - filterArray?: FieldFilter[]; + filterArray?: EntityFieldFilter[]; index?: number; schemaQuery?: SchemaQuery; selectColumnFieldKey?: string; diff --git a/packages/components/src/internal/components/search/QueryFilterPanel.spec.tsx b/packages/components/src/internal/components/search/QueryFilterPanel.spec.tsx deleted file mode 100644 index 7769a2e291..0000000000 --- a/packages/components/src/internal/components/search/QueryFilterPanel.spec.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; -import { Filter } from '@labkey/api'; - -import { ExtendedMap } from '../../../public/ExtendedMap'; - -import { QueryInfo } from '../../../public/QueryInfo'; -import { ChoicesListItem } from '../base/ChoicesListItem'; -import sampleSetAllFieldTypesQueryInfo from '../../../test/data/sampleSetAllFieldTypes-getQueryDetails.json'; - -import { waitForLifecycle } from '../../test/enzymeTestHelpers'; - -import { getTestAPIWrapper } from '../../APIWrapper'; - -import { AssayResultDataType } from '../entities/constants'; - -import { QueryColumn } from '../../../public/QueryColumn'; - -import { QueryFilterPanel } from './QueryFilterPanel'; -import { FieldFilter } from './models'; - -describe('QueryFilterPanel', () => { - const DEFAULT_PROPS = { - api: getTestAPIWrapper(jest.fn, {}), - filters: {}, - queryInfo: QueryInfo.fromJsonForTests(sampleSetAllFieldTypesQueryInfo, true), - onFilterUpdate: jest.fn, - }; - - function validate( - wrapper: ReactWrapper, - fieldItems: number, - showFilterExpression = false, - showChooseValues = false - ): void { - expect(wrapper.find('.filter-modal__col_fields').hostNodes()).toHaveLength(1); - expect(wrapper.find('.filter-modal__col_filter_exp').hostNodes()).toHaveLength(1); - expect(wrapper.find(ChoicesListItem)).toHaveLength(fieldItems); - - if (showFilterExpression) { - expect(wrapper.find('li[role="presentation"]').at(0).prop('className')).toBe('active'); - } - - if (showChooseValues) { - expect(wrapper.find('li[role="presentation"]').at(1).prop('className')).toBe('active'); - } - } - - test('default props', () => { - const wrapper = mount(); - validate(wrapper, 10); - expect(wrapper.find('.filter-modal__container')).toHaveLength(0); - wrapper.unmount(); - }); - - test('skipDefaultViewCheck', async () => { - const wrapper = mount(); - await waitForLifecycle(wrapper); - validate(wrapper, 28); - wrapper.unmount(); - }); - - test('asRow', () => { - const wrapper = mount(); - validate(wrapper, 10); - expect(wrapper.find('.field-modal__container').hostNodes()).toHaveLength(1); - wrapper.unmount(); - }); - - test('no queryName emptyMsg', () => { - const wrapper = mount(); - validate(wrapper, 0); - expect(wrapper.find('.field-modal__empty-msg').hostNodes()).toHaveLength(1); - expect(wrapper.find('.field-modal__empty-msg').hostNodes().text()).toBe('Select a query'); - wrapper.unmount(); - }); - - test('fullWidth', async () => { - const wrapper = mount(); - validate(wrapper, 10); - expect(wrapper.find('.col-sm-3').exists()).toBe(true); - expect(wrapper.find('.col-sm-6').exists()).toBe(true); - wrapper.setProps({ fullWidth: true }); - await waitForLifecycle(wrapper); - expect(wrapper.find('.col-sm-4').exists()).toBe(true); - expect(wrapper.find('.col-sm-8').exists()).toBe(true); - wrapper.unmount(); - }); - - test('viewName', () => { - const wrapper = mount(); - validate(wrapper, 2); - wrapper.unmount(); - }); - - test('validFilterField', () => { - const wrapper = mount( - field.jsonType === 'string'} /> - ); - validate(wrapper, 6); - wrapper.unmount(); - }); - - test('one field not filterable', () => { - const props = { ...DEFAULT_PROPS }; - let queryInfo = props.queryInfo; - let col: QueryColumn = queryInfo.getDisplayColumns().find(field => field.jsonType === 'string'); - col = col.mutate({ filterable: false }); - const columns = new ExtendedMap(queryInfo.columns); - columns.set(col.fieldKey.toLowerCase(), col); - queryInfo = queryInfo.mutate({ columns }); - props.queryInfo = queryInfo; - const wrapper = mount( - field.jsonType === 'string'} /> - ); - validate(wrapper, 5); - }); - - test('with text activeField', () => { - const wrapper = mount(); - validate(wrapper, 10, false, true); - expect(wrapper.find('.list-group-item.active').text()).toBe('Text'); - expect(wrapper.find('.field-modal__col-sub-title').first().text()).toBe('Find values for Text'); - expect(wrapper.find('.field-modal__empty-msg').hostNodes()).toHaveLength(0); - expect(wrapper.find('a[role="tab"]')).toHaveLength(2); - expect(wrapper.find('.field-modal__field_dot')).toHaveLength(0); - wrapper.unmount(); - }); - - test('with non-text activeField', () => { - const wrapper = mount(); - validate(wrapper, 10, true, false); - expect(wrapper.find('.list-group-item.active').text()).toBe('Integer'); - expect(wrapper.find('.field-modal__col-sub-title').text()).toBe('Find values for Integer'); - expect(wrapper.find('.field-modal__empty-msg').hostNodes()).toHaveLength(0); - expect(wrapper.find('a[role="tab"]')).toHaveLength(1); - wrapper.unmount(); - }); - - test('text activeField with non-equal filter', () => { - const wrapper = mount( - - ); - validate(wrapper, 10, true, false); - expect(wrapper.find('.list-group-item.active').text()).toBe('Text'); - expect(wrapper.find('.field-modal__col-sub-title').first().text()).toBe('Find values for Text'); - expect(wrapper.find('.field-modal__empty-msg').hostNodes()).toHaveLength(0); - expect(wrapper.find('a[role="tab"]')).toHaveLength(2); - expect(wrapper.find('.field-modal__field_dot')).toHaveLength(1); - wrapper.unmount(); - }); - - test('hasNoValueInQuery checkbox, not checked', () => { - const hasNotInQueryFilterLabel = 'Sample Without assay data'; - const wrapper = mount( - - ); - validate(wrapper, 10); - expect(wrapper.find('.filter-modal__fields-col-nodata-msg').hostNodes().text()).toBe(hasNotInQueryFilterLabel); - expect(wrapper.find('.field-modal__col-content-disabled').hostNodes()).toHaveLength(0); - - wrapper.unmount(); - }); - - test('hasNoValueInQuery checkbox, checked', () => { - const wrapper = mount( - - ); - validate(wrapper, 10); - expect(wrapper.find('.filter-modal__fields-col-nodata-msg').hostNodes().text()).toBe( - 'Without data from this type' - ); - expect(wrapper.find('.field-modal__col-content-disabled').hostNodes()).toHaveLength(1); - - wrapper.unmount(); - }); - - test('hasNoValueInQuery checkbox, checked, has active field and filters', () => { - const wrapper = mount( - - ); - expect(wrapper.find('.filter-modal__fields-col-nodata-msg').hostNodes().text()).toBe( - 'Without data from this type' - ); - expect(wrapper.find('.field-modal__col-content-disabled').hostNodes()).toHaveLength(2); - - wrapper.unmount(); - }); -}); diff --git a/packages/components/src/internal/components/search/QueryFilterPanel.test.tsx b/packages/components/src/internal/components/search/QueryFilterPanel.test.tsx new file mode 100644 index 0000000000..1e1887f3e3 --- /dev/null +++ b/packages/components/src/internal/components/search/QueryFilterPanel.test.tsx @@ -0,0 +1,264 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { Filter } from '@labkey/api'; + +import { ExtendedMap } from '../../../public/ExtendedMap'; + +import { QueryInfo } from '../../../public/QueryInfo'; +import sampleSetAllFieldTypesQueryInfo from '../../../test/data/sampleSetAllFieldTypes-getQueryDetails.json'; + +import { getTestAPIWrapper } from '../../APIWrapper'; + +import { AssayResultDataType, SamplePropertyDataType } from '../entities/constants'; + +import { QueryColumn } from '../../../public/QueryColumn'; + +import { QueryFilterPanel } from './QueryFilterPanel'; +import { EntityFieldFilter } from './models'; +import { EntityDataType } from '../entities/models'; + +describe('QueryFilterPanel', () => { + const DEFAULT_PROPS = { + api: getTestAPIWrapper(jest.fn, {}), + filters: {}, + queryInfo: QueryInfo.fromJsonForTests(sampleSetAllFieldTypesQueryInfo, true), + onFilterUpdate: jest.fn, + }; + + const TestAllowAllEntityType = { + ...SamplePropertyDataType, + supportAllValueInQuery: true, + } as EntityDataType; + + function validate(fieldItems: number, showFilterExpression = false, showChooseValues = false): void { + expect(document.querySelectorAll('.filter-modal__col_fields')).toHaveLength(1); + expect(document.querySelectorAll('.filter-modal__col_filter_exp')).toHaveLength(1); + // ChoicesListItem renders as button.list-group-item + expect(document.querySelectorAll('button.list-group-item')).toHaveLength(fieldItems); + + const tabs = document.querySelectorAll('li[role="presentation"]'); + if (showFilterExpression) { + expect((tabs[0] as HTMLElement).className).toBe('active'); + } + if (showChooseValues) { + expect((tabs[1] as HTMLElement).className).toBe('active'); + } + } + + test('default props', () => { + const { unmount } = render(); + validate(10); + expect(document.querySelectorAll('.filter-modal__container')).toHaveLength(0); + unmount(); + }); + + test('skipDefaultViewCheck', async () => { + render(); + await waitFor(() => expect(document.querySelectorAll('button.list-group-item')).toHaveLength(28)); + validate(28); + }); + + test('asRow', () => { + const { unmount } = render(); + validate(10); + expect(document.querySelectorAll('.field-modal__container')).toHaveLength(1); + unmount(); + }); + + test('no queryName emptyMsg', () => { + const { unmount } = render( + + ); + validate(0); + expect(document.querySelectorAll('.field-modal__empty-msg')).toHaveLength(1); + expect(document.querySelector('.field-modal__empty-msg')!.textContent).toBe('Select a query'); + unmount(); + }); + + test('fullWidth', async () => { + const { rerender, unmount } = render(); + validate(10); + expect(document.querySelector('.col-sm-3')).not.toBeNull(); + expect(document.querySelector('.col-sm-6')).not.toBeNull(); + rerender(); + await waitFor(() => expect(document.querySelector('.col-sm-4')).not.toBeNull()); + expect(document.querySelector('.col-sm-8')).not.toBeNull(); + unmount(); + }); + + test('viewName', () => { + const { unmount } = render(); + validate(2); + unmount(); + }); + + test('validFilterField', () => { + const { unmount } = render( + field.jsonType === 'string'} /> + ); + validate(6); + unmount(); + }); + + test('one field not filterable', () => { + const props = { ...DEFAULT_PROPS }; + let queryInfo = props.queryInfo; + let col: QueryColumn = queryInfo.getDisplayColumns().find(field => field.jsonType === 'string'); + col = col.mutate({ filterable: false }); + const columns = new ExtendedMap(queryInfo.columns); + columns.set(col.fieldKey.toLowerCase(), col); + queryInfo = queryInfo.mutate({ columns }); + props.queryInfo = queryInfo; + const { unmount } = render( + field.jsonType === 'string'} /> + ); + validate(5); + unmount(); + }); + + test('with text activeField', () => { + const { unmount } = render(); + validate(10, false, true); + const active = document.querySelector('.list-group-item.active'); + expect(active && active.textContent).toBe('Text'); + expect(document.querySelectorAll('.field-modal__col-sub-title')[0].textContent).toBe('Find values for Text'); + expect(document.querySelectorAll('.field-modal__empty-msg')).toHaveLength(0); + expect(document.querySelectorAll('a[role="tab"]')).toHaveLength(2); + expect(document.querySelectorAll('.field-modal__field_dot')).toHaveLength(0); + unmount(); + }); + + test('with non-text activeField', () => { + const { unmount } = render(); + validate(10, true, false); + const active = document.querySelector('.list-group-item.active'); + expect(active && active.textContent).toBe('Integer'); + expect(document.querySelector('.field-modal__col-sub-title')!.textContent).toBe('Find values for Integer'); + expect(document.querySelectorAll('.field-modal__empty-msg')).toHaveLength(0); + expect(document.querySelectorAll('a[role="tab"]')).toHaveLength(1); + unmount(); + }); + + test('text activeField with non-equal filter', () => { + const { unmount } = render( + + ); + validate(10, true, false); + const active = document.querySelector('.list-group-item.active'); + expect(active && active.textContent).toBe('Text'); + expect(document.querySelectorAll('.field-modal__col-sub-title')[0].textContent).toBe('Find values for Text'); + expect(document.querySelectorAll('.field-modal__empty-msg')).toHaveLength(0); + expect(document.querySelectorAll('a[role="tab"]')).toHaveLength(2); + expect(document.querySelectorAll('.field-modal__field_dot')).toHaveLength(1); + unmount(); + }); + + test('hasNoValueInQuery checkbox, not checked', () => { + const hasNotInQueryFilterLabel = 'Sample Without assay data'; + const { unmount } = render( + + ); + validate(10); + expect(document.querySelector('.filter-modal__fields-col-nodata-msg')!.textContent).toBe( + hasNotInQueryFilterLabel + ); + expect(document.querySelectorAll('.field-modal__col-content-disabled')).toHaveLength(0); + + unmount(); + }); + + test('hasNoValueInQuery checkbox, checked', () => { + const { unmount } = render( + + ); + validate(10); + expect(document.querySelector('.filter-modal__fields-col-nodata-msg')!.textContent).toBe( + 'Without data from this type' + ); + expect(document.querySelectorAll('.field-modal__col-content-disabled')).toHaveLength(1); + + unmount(); + }); + + test('hasNoValueInQuery checkbox, checked, has active field and filters', () => { + const { unmount } = render( + + ); + expect(document.querySelector('.filter-modal__fields-col-nodata-msg')!.textContent).toBe( + 'Without data from this type' + ); + expect(document.querySelectorAll('.field-modal__col-content-disabled')).toHaveLength(2); + + unmount(); + }); + + test('supportAllValueInQuery checkbox, not checked', () => { + const { unmount } = render( + + ); + validate(10); + expect(document.querySelector('.filter-modal__fields-col-any-msg')!.textContent).toBe('All Samples'); + // query by name field-value-nodata-check + expect(document.querySelector('input[name="field-value-allvalues-check"]').getAttribute('checked')).toBe(null); + expect(document.querySelectorAll('.field-modal__col-content-disabled')).toHaveLength(0); + + unmount(); + }); + + test('supportAllValueInQuery checkbox, checked', () => { + const { unmount } = render( + + ); + validate(10); + expect(document.querySelector('.filter-modal__fields-col-any-msg')!.textContent).toBe('All data'); + expect(document.querySelector('input[name="field-value-allvalues-check"]').getAttribute('checked')).toBe(''); + expect(document.querySelectorAll('.field-modal__col-content-disabled')).toHaveLength(0); + + unmount(); + }); +}); diff --git a/packages/components/src/internal/components/search/QueryFilterPanel.tsx b/packages/components/src/internal/components/search/QueryFilterPanel.tsx index 598b5dac55..744c228a47 100644 --- a/packages/components/src/internal/components/search/QueryFilterPanel.tsx +++ b/packages/components/src/internal/components/search/QueryFilterPanel.tsx @@ -21,8 +21,9 @@ import { Tab, Tabs } from '../../Tabs'; import { FilterFacetedSelector } from './FilterFacetedSelector'; import { FilterExpressionView } from './FilterExpressionView'; -import { FieldFilter } from './models'; +import { EntityFieldFilter } from './models'; import { isChooseValuesFilter } from './utils'; +import { SAMPLE_PROPERTY_ALL_SAMPLE_TYPE } from './constants'; enum FieldFilterTabs { ChooseValues = 'ChooseValues', @@ -32,6 +33,7 @@ enum FieldFilterTabs { const DEFAULT_VIEW_NAME = ''; // always use default view for selection, if none provided interface Props { + allInQueryFilterLabel?: string; allowRelativeDateFilter?: boolean; altQueryName?: string; api?: ComponentsAPIWrapper; @@ -41,12 +43,14 @@ interface Props { entityDataType?: EntityDataType; fieldKey?: string; fields?: QueryColumn[]; - filters: Record; + filters: Record; fullWidth?: boolean; + hasAllValuesInQuery?: boolean; hasNotInQueryFilter?: boolean; hasNotInQueryFilterLabel?: string; isAncestor?: boolean; metricFeatureArea?: string; + onAllValuesInQueryChange?: (check: boolean) => void; onFilterUpdate: (field: QueryColumn, newFilters: Filter.IFilter[], index: number) => void; onHasNoValueInQueryChange?: (check: boolean) => void; queryInfo: QueryInfo; @@ -60,6 +64,7 @@ export const QueryFilterPanel: FC = memo(props => { const { allowRelativeDateFilter, hasNotInQueryFilter, + hasAllValuesInQuery, asRow, api = getDefaultAPIWrapper(), queryInfo, @@ -74,7 +79,9 @@ export const QueryFilterPanel: FC = memo(props => { fullWidth, selectDistinctOptions, onHasNoValueInQueryChange, + onAllValuesInQueryChange, hasNotInQueryFilterLabel, + allInQueryFilterLabel, altQueryName, fields, isAncestor, @@ -188,10 +195,10 @@ export const QueryFilterPanel: FC = memo(props => { return activeField?.getDisplayFieldKey(); }, [activeField]); - const currentFieldFilters = useMemo((): FieldFilter[] => { + const currentFieldFilters = useMemo((): EntityFieldFilter[] => { if (!filters || !activeField) return null; - const activeQueryFilters: FieldFilter[] = filters[filterQueryKey]; + const activeQueryFilters: EntityFieldFilter[] = filters[filterQueryKey]; return activeQueryFilters?.filter(filter => filter.fieldKey === activeFieldKey); }, [activeField, filterQueryKey, filters, activeFieldKey]); @@ -242,6 +249,21 @@ export const QueryFilterPanel: FC = memo(props => { {queryName && (
{!queryFields && } + {entityDataType?.supportAllValueInQuery && + altQueryName !== SAMPLE_PROPERTY_ALL_SAMPLE_TYPE.query && ( +
+ onAllValuesInQueryChange(event.target.checked)} + type="checkbox" + /> +
+ {allInQueryFilterLabel ?? 'All data'} +
+
+ )} {entityDataType?.supportHasNoValueInQuery && (
{ LABKEY.container = { @@ -50,14 +50,14 @@ const anyValueFilter = { fieldCaption: 'textField', filter: Filter.create('textField', null, Filter.Types.HAS_ANY_VALUE), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const matchesAllFilter = { fieldKey: 'textField', fieldCaption: 'textField', filter: Filter.create('textField', ['a', 'b'], ANCESTOR_MATCHES_ALL_OF_FILTER_TYPE), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const matchesAllBadFilter = { fieldKey: 'textField', @@ -68,14 +68,14 @@ const matchesAllBadFilter = { ANCESTOR_MATCHES_ALL_OF_FILTER_TYPE ), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const matchesAllEmptyFilter = { fieldKey: 'textField', fieldCaption: 'textField', filter: Filter.create('textField', [], ANCESTOR_MATCHES_ALL_OF_FILTER_TYPE), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const containsOneOfBadFilter = { fieldKey: 'textField', @@ -86,7 +86,7 @@ const containsOneOfBadFilter = { Filter.Types.CONTAINS_ONE_OF ), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const containsNoneOfBadFilter = { fieldKey: 'otherTextField', @@ -97,7 +97,7 @@ const containsNoneOfBadFilter = { Filter.Types.CONTAINS_NONE_OF ), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const equalsNoneOfBadFilter = { fieldKey: 'textField', @@ -108,63 +108,63 @@ const equalsNoneOfBadFilter = { Filter.Types.EQUALS_NONE_OF ), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const intEqFilter = { fieldKey: 'intField', fieldCaption: 'intField', filter: Filter.create('intField', 1), jsonType: 'int', -} as FieldFilter; +} as EntityFieldFilter; const stringBetweenFilter = { fieldKey: 'strField', fieldCaption: 'strField', filter: Filter.create('strField', ['1', '5'], Filter.Types.BETWEEN), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const stringEqualFilter = { fieldKey: 'strField', fieldCaption: 'strField', filter: Filter.create('strField', '2', Filter.Types.EQ), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const emptyStringBetweenFilter = { fieldKey: 'strField', fieldCaption: 'strField', filter: Filter.create('strField', ['', '5'], Filter.Types.BETWEEN), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const emptyStringLessThanFilter = { fieldKey: 'strField', fieldCaption: 'strField', filter: Filter.create('strField', '', Filter.Types.LT), jsonType: 'string', -} as FieldFilter; +} as EntityFieldFilter; const floatBetweenFilter = { fieldKey: 'floatField2', fieldCaption: 'floatField2', filter: Filter.create('floatField2', '1,5', Filter.Types.BETWEEN), jsonType: 'float', -} as FieldFilter; +} as EntityFieldFilter; const badIntFilter = { fieldKey: 'intField', fieldCaption: 'intField', filter: Filter.create('intField', null), jsonType: 'int', -} as FieldFilter; +} as EntityFieldFilter; const badBetweenFilter = { fieldKey: 'doubleField', fieldCaption: 'doubleField', filter: Filter.create('doubleField', '1', Filter.Types.BETWEEN), jsonType: 'float', -} as FieldFilter; +} as EntityFieldFilter; describe('getFilterValuesAsArray', () => { test('array value', () => { diff --git a/packages/components/src/internal/components/search/utils.ts b/packages/components/src/internal/components/search/utils.ts index cf5e9cab4b..43798c0e3f 100644 --- a/packages/components/src/internal/components/search/utils.ts +++ b/packages/components/src/internal/components/search/utils.ts @@ -21,7 +21,7 @@ import { REGISTRY_KEY } from '../../app/constants'; import { makeCommaSeparatedString } from '../../util/utils'; import { SearchCategory, SearchScope } from './constants'; -import { FieldFilter, FieldFilterOption, FilterSelection, SearchResultCardData } from './models'; +import { EntityFieldFilter, FieldFilterOption, FilterSelection, SearchResultCardData } from './models'; export const SAMPLE_FILTER_METRIC_AREA = 'sampleFinder'; @@ -131,7 +131,7 @@ export function getFilterValuesAsArray(filter: Filter.IFilter, blankValue?: stri } export function getFieldFiltersValidationResult( - dataTypeFilters: Record, + dataTypeFilters: Record, queryLabels?: Record, maxMultiValuedValues: number = MAX_MULTI_VALUE_FILTER_VALUES ): string { diff --git a/packages/components/src/public/QueryModel/FilterStatus.test.tsx b/packages/components/src/public/QueryModel/FilterStatus.test.tsx index 04277e728e..60ad2ff1ee 100644 --- a/packages/components/src/public/QueryModel/FilterStatus.test.tsx +++ b/packages/components/src/public/QueryModel/FilterStatus.test.tsx @@ -33,6 +33,14 @@ describe('FilterStatus', () => { value: 'test2', valueObject: Filter.create('A', undefined, Filter.Types.NONBLANK), }; + const filterAction1Locked = { + ...filterAction1, + isReadOnly: 'locked filter', + }; + const filterAction2Locked = { + ...filterAction2, + isReadOnly: 'locked filter', + }; const searchAction = { action: new SearchAction(), value: 'foo', @@ -94,4 +102,34 @@ describe('FilterStatus', () => { await userEvent.click(document.querySelector('.remove-all-filters')); expect(ON_REMOVE_ALL).toHaveBeenCalledTimes(1); }); + + test('multiple locked filter actionValue', async () => { + render(); + validate(2, 2); + expect(document.querySelectorAll('.fa-table')).toHaveLength(0); + expect(document.querySelectorAll('.fa-search')).toHaveLength(0); + expect(document.querySelectorAll('.fa-filter')).toHaveLength(2); + expect(document.querySelectorAll('.filter-status-value')[0].textContent).toBe('test1'); + expect(document.querySelectorAll('.filter-status-value')[1].textContent).toBe('test2'); + expect(document.querySelectorAll('.fa-close')).toHaveLength(0); + expect(document.querySelectorAll('.remove-all-filters')).toHaveLength(1); + }); + + test('multiple locked filter actionValue, cannot remove', async () => { + render( + + ); + expect(document.querySelectorAll('.fa-table')).toHaveLength(0); + expect(document.querySelectorAll('.fa-search')).toHaveLength(0); + expect(document.querySelectorAll('.fa-filter')).toHaveLength(0); + expect(document.querySelectorAll('.fa-lock')).toHaveLength(2); + expect(document.querySelectorAll('.filter-status-value')[0].textContent).toBe('test1'); + expect(document.querySelectorAll('.filter-status-value')[1].textContent).toBe('test2'); + expect(document.querySelectorAll('.fa-close')).toHaveLength(0); + expect(document.querySelectorAll('.remove-all-filters')).toHaveLength(0); + }); }); diff --git a/packages/components/src/public/QueryModel/FilterStatus.tsx b/packages/components/src/public/QueryModel/FilterStatus.tsx index f2e6b81cc0..b4cec32b46 100644 --- a/packages/components/src/public/QueryModel/FilterStatus.tsx +++ b/packages/components/src/public/QueryModel/FilterStatus.tsx @@ -6,14 +6,15 @@ import { filterActionValuesByType } from './grid/utils'; interface Props { actionValues: ActionValue[]; + lockReadOnlyForDelete?: boolean; onClick: (actionValue: ActionValue, event: any) => void; onRemove: (actionValueIndex: number, event: any) => void; onRemoveAll?: () => void; } export const FilterStatus: FC = memo(props => { - const { actionValues, onClick, onRemove, onRemoveAll } = props; - const showRemoveAll = filterActionValuesByType(actionValues, 'filter').length > 1; + const { actionValues, onClick, onRemove, onRemoveAll, lockReadOnlyForDelete } = props; + const showRemoveAll = filterActionValuesByType(actionValues, 'filter', lockReadOnlyForDelete).length > 1; return (
@@ -45,9 +46,10 @@ export const FilterStatus: FC = memo(props => { return ( diff --git a/packages/components/src/public/QueryModel/GridFilterModal.tsx b/packages/components/src/public/QueryModel/GridFilterModal.tsx index 8443f75da5..dcc20b4781 100644 --- a/packages/components/src/public/QueryModel/GridFilterModal.tsx +++ b/packages/components/src/public/QueryModel/GridFilterModal.tsx @@ -2,7 +2,7 @@ import React, { FC, memo, useCallback, useMemo, useState } from 'react'; import { Filter, Query } from '@labkey/api'; import { Modal } from '../../internal/Modal'; -import { FieldFilter } from '../../internal/components/search/models'; +import { EntityFieldFilter } from '../../internal/components/search/models'; import { QueryColumn } from '../QueryColumn'; import { Alert } from '../../internal/components/base/Alert'; @@ -38,12 +38,12 @@ export const GridFilterModal: FC = memo(props => { } = props; const { queryInfo } = model; const [filterError, setFilterError] = useState(undefined); - const [filters, setFilters] = useState( + const [filters, setFilters] = useState( initFilters.map(filter => { return { fieldKey: filter.getColumnName(), filter, - } as FieldFilter; + } as EntityFieldFilter; }) ); @@ -85,7 +85,7 @@ export const GridFilterModal: FC = memo(props => { fieldCaption: field.caption, filter: newFilter, jsonType: field.getDisplayFieldJsonType(), - } as FieldFilter); + } as EntityFieldFilter); }); } @@ -105,8 +105,8 @@ export const GridFilterModal: FC = memo(props => { > {filterError} { expect(onRemove).toHaveBeenCalledTimes(1); }); + test('isReadOnly and lockReadOnlyForDelete', async () => { + const onClick = jest.fn(); + const onRemove = jest.fn(); + render( + + ); + expect(document.querySelectorAll('.filter-status-value')).toHaveLength(1); + expect(document.querySelectorAll('.is-active')).toHaveLength(0); + expect(document.querySelectorAll('.is-disabled')).toHaveLength(1); + expect(document.querySelectorAll('.is-readonly')).toHaveLength(1); + expect(document.querySelectorAll('.read-lock')).toHaveLength(1); + expect(document.querySelectorAll('.symbol')).toHaveLength(0); + expect(document.querySelectorAll('.fa-close')).toHaveLength(0); + expect(document.querySelectorAll('.fa-filter')).toHaveLength(0); + + expect(onClick).toHaveBeenCalledTimes(0); + await userEvent.click(document.querySelector('.filter-status-value span')); + expect(onClick).toHaveBeenCalledTimes(0); + expect(onRemove).toHaveBeenCalledTimes(0); + }); + test('click nonRemovableAction action', async () => { const onClick = jest.fn(); const onRemove = jest.fn(); diff --git a/packages/components/src/public/QueryModel/grid/Value.tsx b/packages/components/src/public/QueryModel/grid/Value.tsx index 17ae0105fc..2a5f45911b 100644 --- a/packages/components/src/public/QueryModel/grid/Value.tsx +++ b/packages/components/src/public/QueryModel/grid/Value.tsx @@ -21,6 +21,7 @@ import { ActionValue } from './actions/Action'; interface ValueProps { actionValue: ActionValue; index: number; + lockReadOnlyForDelete?: boolean; onClick?: (actionValue: ActionValue, event: any) => void; onRemove?: (actionValueIndex: number, event: any) => void; } @@ -80,13 +81,13 @@ export class Value extends React.Component { }; render(): ReactNode { - const { actionValue } = this.props; + const { actionValue, lockReadOnlyForDelete } = this.props; const { action, value, displayValue, isReadOnly, isRemovable } = actionValue; const showRemoveIcon = this.state.isActive && isRemovable !== false && actionValue.action.keyword !== 'view'; const className = classNames(valueClassName, { 'is-active': this.state.isActive, - 'is-disabled': this.state.isDisabled, + 'is-disabled': this.state.isDisabled || (lockReadOnlyForDelete && isReadOnly), 'is-readonly': isReadOnly !== undefined, }); @@ -102,9 +103,10 @@ export class Value extends React.Component { onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave} + title={isReadOnly} > - - {isReadOnly ? : null} + {(!lockReadOnlyForDelete || !isReadOnly) && } + {isReadOnly ? : null} {displayValue ?? value}
); diff --git a/packages/components/src/public/QueryModel/grid/actions/Filter.test.ts b/packages/components/src/public/QueryModel/grid/actions/Filter.test.ts index 3172249a41..2e259e6bd9 100644 --- a/packages/components/src/public/QueryModel/grid/actions/Filter.test.ts +++ b/packages/components/src/public/QueryModel/grid/actions/Filter.test.ts @@ -19,8 +19,10 @@ import { QueryColumn } from '../../../QueryColumn'; import { TIME_RANGE_URI } from '../../../../internal/components/domainproperties/constants'; -import { FilterAction } from './Filter'; +import { FilterAction, removeFilterValueForFilterProps } from './Filter'; import { ActionValue } from './Action'; +import { EntityFieldFilter } from '../../../../internal/components/search/models'; +import { FilterProps } from '../../../../internal/components/entities/models'; describe('FilterAction::actionValueFromFilter', () => { const action = new FilterAction(); @@ -94,3 +96,126 @@ describe('FilterAction::actionValueFromFilter', () => { expect(value.value).toBe('"$BoolField./,$&" = true'); }); }); + +describe('actionValueFromEntityFieldFilter', () => { + const action = new FilterAction(); + + it('returns correct ActionValue for date field with formatted value', () => { + const entityFieldFilter: EntityFieldFilter = { + fieldCaption: 'DateField', + filter: Filter.create('DateField', '10-01-2023', Filter.Types.EQUAL), + jsonType: 'date', + } as EntityFieldFilter; + const value = action.actionValueFromEntityFieldFilter(entityFieldFilter); + expect(value.displayValue).toBe('DateField = 2023-10-01'); + expect(value.value).toBe('"DateField" = 2023-10-01'); + }); + + it('returns correct ActionValue for time field with formatted value', () => { + const entityFieldFilter: EntityFieldFilter = { + fieldCaption: 'TimeField', + filter: Filter.create('TimeField', '12:30:00.123', Filter.Types.EQUAL), + jsonType: 'time', + } as EntityFieldFilter; + const value = action.actionValueFromEntityFieldFilter(entityFieldFilter); + expect(value.displayValue).toBe('TimeField = 12:30'); + expect(value.value).toBe('"TimeField" = 12:30'); + }); + + it('returns correct ActionValue for string field', () => { + const entityFieldFilter: EntityFieldFilter = { + fieldCaption: 'StringField', + filter: Filter.create('StringField', 'testValue', Filter.Types.EQUAL), + } as EntityFieldFilter; + const value = action.actionValueFromEntityFieldFilter(entityFieldFilter); + expect(value.displayValue).toBe('StringField = testValue'); + expect(value.value).toBe('"StringField" = testValue'); + }); + + it('handles missing fieldCaption gracefully', () => { + const entityFieldFilter: EntityFieldFilter = { + filter: Filter.create('UnnamedField', 'value', Filter.Types.EQUAL), + } as EntityFieldFilter; + const value = action.actionValueFromEntityFieldFilter(entityFieldFilter); + expect(value.displayValue).toBe('UnnamedField = value'); + expect(value.value).toBe('"UnnamedField" = value'); + }); + + it('handles null filter value', () => { + const entityFieldFilter: EntityFieldFilter = { + fieldCaption: 'NullField', + filter: Filter.create('NullField', null, Filter.Types.ISBLANK), + } as EntityFieldFilter; + const value = action.actionValueFromEntityFieldFilter(entityFieldFilter); + expect(value.displayValue).toBe('NullField Is Blank'); + expect(value.value).toBe('"NullField" Is Blank null'); + }); +}); + +describe('removeFilterValueForFilterProps', () => { + const filter1 = Filter.create('PropA', 'a'); + const filter2 = Filter.create('PropB', 'b'); + const filter3 = Filter.create('PropB', 'c'); + + const entityFilterProps: FilterProps = { + schemaQuery: { schemaName: 'schema', queryName: 'query' }, + filterArray: [{ filter: filter2 }, { filter: filter1 }, { filter: filter3 }], + } as FilterProps; + const actionValues: ActionValue[] = [ + { + action: new FilterAction(), + displayValue: 'PropA = a', + value: '"PropA" = a', + valueObject: filter1, + }, + { + action: new FilterAction(), + displayValue: 'PropB = b', + value: '"PropB" = b', + valueObject: filter2, + }, + { + action: new FilterAction(), + displayValue: 'PropC = c', + value: '"PropC" = c', + valueObject: filter3, + }, + ]; + + it('removes the filter value when it exists in the filter array', () => { + const updatedFilters = removeFilterValueForFilterProps(entityFilterProps, actionValues, 0); + expect(updatedFilters.length).toBe(2); + expect(updatedFilters[0].filter).toBe(filter2); + expect(updatedFilters[1].filter).toBe(filter3); + }); + + it('does not modify the filter array when the value does not exist', () => { + const nonMatchingFilter = Filter.create('OtherField', 'otherValue', Filter.Types.EQUAL); + const nonMatchingActionValues: ActionValue[] = [ + { + action: new FilterAction(), + displayValue: 'OtherField = otherValue', + value: '"OtherField" = otherValue', + valueObject: nonMatchingFilter, + }, + ]; + const updatedFilters = removeFilterValueForFilterProps(entityFilterProps, nonMatchingActionValues, 0); + expect(updatedFilters.length).toBe(3); + expect(updatedFilters[0].filter).toBe(filter2); + }); + + it('handles an empty filter array gracefully', () => { + const emptyFilterProps: FilterProps = { + schemaQuery: { schemaName: 'schema', queryName: 'query' }, + filterArray: [], + } as FilterProps; + const updatedFilters = removeFilterValueForFilterProps(emptyFilterProps, actionValues, 0); + expect(updatedFilters.length).toBe(0); + }); + + it('does not throw an error when valueIndex is out of bounds', () => { + const updatedFilters = removeFilterValueForFilterProps(entityFilterProps, actionValues, 3); + expect(updatedFilters.length).toBe(3); + expect(updatedFilters[0].filter).toBe(filter2); + }); +}); diff --git a/packages/components/src/public/QueryModel/grid/actions/Filter.ts b/packages/components/src/public/QueryModel/grid/actions/Filter.ts index 2dfcca1c10..2649f98267 100644 --- a/packages/components/src/public/QueryModel/grid/actions/Filter.ts +++ b/packages/components/src/public/QueryModel/grid/actions/Filter.ts @@ -27,6 +27,9 @@ import { QueryColumn } from '../../../QueryColumn'; import { ANCESTOR_MATCHES_ALL_OF_FILTER_TYPE } from '../../../../internal/query/filter'; import { Action, ActionValue } from './Action'; +import { FilterProps } from '../../../../internal/components/entities/models'; +import { EntityFieldFilter } from '../../../../internal/components/search/models'; +import { filtersEqual } from '../../utils'; /** * The following section prepares the SYMBOL_MAP and SUFFIX_MAP to allow any Filter Action instances @@ -146,6 +149,52 @@ function resolveSymbol(filterType: Filter.IFilterType): string { return getURLSuffix(filterType); } +export function getActionValuesForFilterProps( + entityFilterProps: FilterProps, + queryTypeLabel: string, + isReadOnly?: string +): ActionValue[] { + const action = new FilterAction(); + const actionValues: ActionValue[] = []; + if (!entityFilterProps || !entityFilterProps.schemaQuery) return null; + if (!entityFilterProps.filterArray || entityFilterProps.filterArray.length === 0) { + const filterValue = `${queryTypeLabel} = ${entityFilterProps.dataTypeDisplayName}`; + actionValues.push({ + action, + displayValue: filterValue, + isReadOnly, + value: filterValue, + valueObject: null, + }); + } else { + entityFilterProps.filterArray.forEach(filter => { + actionValues.push(action.actionValueFromEntityFieldFilter(filter, isReadOnly)); + }); + } + + return actionValues; +} + +export function removeFilterValueForFilterProps( + entityFilterProps: FilterProps, + actionValues: ActionValue[], + valueIndex: number +): EntityFieldFilter[] { + const value = actionValues[valueIndex]?.valueObject; + if (!value) return entityFilterProps.filterArray; + + if (entityFilterProps.filterArray) { + const viewFilterIndex = entityFilterProps.filterArray.findIndex(f => filtersEqual(f.filter, value)); + const updatedFilterArray = [...entityFilterProps.filterArray]; + if (viewFilterIndex > -1) { + updatedFilterArray.splice(viewFilterIndex, 1); + } + return updatedFilterArray; + } + + return null; +} + export class FilterAction implements Action { iconCls = 'filter'; keyword = 'filter'; @@ -208,19 +257,10 @@ export class FilterAction implements Action { return { displayValue: displayParts.join(' '), inputValue }; } - actionValueFromFilter(filter: Filter.IFilter, column?: QueryColumn, isReadOnly?: string): ActionValue { - const label = column?.shortCaption; + _actionValueFromFilter(filter: Filter.IFilter, value: any, label: string, isReadOnly?: string): ActionValue { const columnName = decodePart(filter.getColumnName()); const filterType = filter.getFilterType(); const operator = resolveSymbol(filter.getFilterType()); - let value = filter.getValue(); - - // Issue 45140: match date display format in grid filter status pill display - if (column?.isTimeColumn) { - value = getColFormattedTimeFilterValue(column, value); - } else if (column?.getDisplayFieldJsonType() === 'date') { - value = getColFormattedDateFilterValue(column, value); - } const { displayValue, inputValue } = this.getDisplayValue(label ?? columnName, filterType, value); @@ -232,4 +272,33 @@ export class FilterAction implements Action { valueObject: filter, }; } + + actionValueFromFilter(filter: Filter.IFilter, column?: QueryColumn, isReadOnly?: string): ActionValue { + const label = column?.shortCaption; + let value = filter.getValue(); + + // Issue 45140: match date display format in grid filter status pill display + if (column?.isTimeColumn) { + value = getColFormattedTimeFilterValue(column, value); + } else if (column?.getDisplayFieldJsonType() === 'date') { + value = getColFormattedDateFilterValue(column, value); + } + + return this._actionValueFromFilter(filter, value, label, isReadOnly); + } + + actionValueFromEntityFieldFilter(entityFieldFilter: EntityFieldFilter, isReadOnly?: string): ActionValue { + const label = entityFieldFilter.fieldCaption; + const filter = entityFieldFilter.filter; + let value = filter.getValue(); + + // use folder level display format since queryColumn is not available + if (entityFieldFilter?.jsonType === 'time') { + value = getColFormattedTimeFilterValue(null, value); + } else if (entityFieldFilter?.jsonType === 'date') { + value = getColFormattedDateFilterValue(null, value); + } + + return this._actionValueFromFilter(filter, value, label, isReadOnly); + } } diff --git a/packages/components/src/public/QueryModel/grid/utils.test.ts b/packages/components/src/public/QueryModel/grid/utils.test.ts index c1a9fc88c5..7f30bccfac 100644 --- a/packages/components/src/public/QueryModel/grid/utils.test.ts +++ b/packages/components/src/public/QueryModel/grid/utils.test.ts @@ -3,10 +3,11 @@ import { Filter } from '@labkey/api'; import { QueryInfo } from '../../QueryInfo'; -import { getSearchValueAction } from './utils'; +import { filterActionValuesByType, getSearchValueAction } from './utils'; import { ChangeType } from './model'; import { FilterAction } from './actions/Filter'; import { SearchAction } from './actions/Search'; +import { ActionValue } from './actions/Action'; const filterAction = { action: new FilterAction( @@ -39,3 +40,48 @@ describe('replaceSearchValue', () => { expect(change.type).toBe(ChangeType.remove); }); }); + +describe('filterActionValuesByType', () => { + const mockActionValues: ActionValue[] = [ + { + action: { keyword: 'search' }, + }, + { + action: { keyword: 'filter' }, + }, + { + action: { keyword: 'filter' }, + isReadOnly: 'locked', + }, + ]; + + it('returns only action values matching the keyword', () => { + const result = filterActionValuesByType(mockActionValues, 'search', false); + expect(result.length).toBe(1); + expect(result.every(av => av.action.keyword === 'search')).toBe(true); + }); + + it('excludes read-only action values when lockReadOnlyForDelete is true', () => { + const result = filterActionValuesByType(mockActionValues, 'search', true); + expect(result.length).toBe(1); + expect(result[0].isReadOnly).toBeUndefined(); + }); + + it('returns an empty array when no action values match the keyword', () => { + const result = filterActionValuesByType(mockActionValues, 'nonexistent', false); + expect(result.length).toBe(0); + }); + + it('returns non-readonly filter action values when lockReadOnlyForDelete is true', () => { + const result = filterActionValuesByType(mockActionValues, 'filter', true); + expect(result.length).toBe(1); + expect(result[0].isReadOnly).toBeUndefined(); + }); + + it('returns all matching action values when lockReadOnlyForDelete is false', () => { + const result = filterActionValuesByType(mockActionValues, 'filter', false); + expect(result.length).toBe(2); + expect(result[0].isReadOnly).toBeUndefined(); + expect(result[1].isReadOnly).toBe('locked'); + }); +}); diff --git a/packages/components/src/public/QueryModel/grid/utils.ts b/packages/components/src/public/QueryModel/grid/utils.ts index 252915e0e8..72e5e809d4 100644 --- a/packages/components/src/public/QueryModel/grid/utils.ts +++ b/packages/components/src/public/QueryModel/grid/utils.ts @@ -65,6 +65,12 @@ export function getSearchValueAction(actionValues: ActionValue[], value: string) return change; } -export function filterActionValuesByType(actionValues: ActionValue[], keyword: string): ActionValue[] { - return actionValues.filter(actionValue => actionValue.action.keyword === keyword); +export function filterActionValuesByType( + actionValues: ActionValue[], + keyword: string, + lockReadOnlyForDelete: boolean +): ActionValue[] { + return actionValues + .filter(actionValue => actionValue.action.keyword === keyword) + .filter(actionValue => !actionValue.isReadOnly || !lockReadOnlyForDelete); } diff --git a/packages/components/src/theme/filter.scss b/packages/components/src/theme/filter.scss index a5d910884e..f86bb2d655 100644 --- a/packages/components/src/theme/filter.scss +++ b/packages/components/src/theme/filter.scss @@ -262,7 +262,7 @@ cursor:pointer; } -.filter-modal__fields-col-nodata-msg { +.filter-modal__fields-col-nodata-msg, .filter-modal__fields-col-any-msg { display: inline-block; margin-left: 20px; padding-right: 5px; diff --git a/packages/components/src/theme/query-model.scss b/packages/components/src/theme/query-model.scss index 63e347dec3..a77767fbd3 100644 --- a/packages/components/src/theme/query-model.scss +++ b/packages/components/src/theme/query-model.scss @@ -134,8 +134,10 @@ } &.is-disabled { - background-color: $grid-action-item-disabled-bg; - border: 1px solid $grid-action-item-disabled-border-color; + opacity: 0.6; + cursor: not-allowed; + background-color: $input-bg-disabled; + border: 1px solid $border-color; color: $grid-action-item-disabled-color; i.symbol { @@ -143,13 +145,6 @@ border-color: $grid-action-item-disabled-border-color; } - &:hover, - &:focus { - background-color: $grid-action-item-disabled-bg; - } - &:active { - background-color: $grid-action-item-disabled-bg; - } } }