diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 5f9d0ff25d..3ab14c238d 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.59.0", + "version": "7.60.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.59.0", + "version": "7.60.0", "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 3d75690624..d1de89b4c0 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.59.0", + "version": "7.60.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/public/QueryModel/GridPanel.test.tsx b/packages/components/src/public/QueryModel/GridPanel.test.tsx index 9bd05cd29e..ba0a216fc1 100644 --- a/packages/components/src/public/QueryModel/GridPanel.test.tsx +++ b/packages/components/src/public/QueryModel/GridPanel.test.tsx @@ -24,9 +24,11 @@ import { GRID_CHECKBOX_OPTIONS } from '../../internal/constants'; import { QueryModel } from './QueryModel'; import { GridPanel, GridTitle } from './GridPanel'; import { makeTestActions, makeTestQueryModel } from './testUtils'; -import { RequiresModelAndActions } from './withQueryModels'; +import { Actions, RequiresModelAndActions } from './withQueryModels'; import { RowsResponse } from './QueryModelLoader'; import { renderWithAppContext } from '../../internal/test/reactTestLibraryHelpers'; +import { Container } from '../../internal/components/base/models/Container'; +import { TEST_FOLDER_CONTAINER, TEST_PROJECT_CONTAINER } from '../../internal/containerFixtures'; const SCHEMA_QUERY = new SchemaQuery('exp.data', 'mixtures'); let QUERY_INFO: QueryInfo; @@ -41,7 +43,6 @@ class TestButtons extends PureComponent { beforeAll(() => { QUERY_INFO = makeQueryInfo(mixturesQueryInfo); DATA = makeTestData(mixturesQuery); - LABKEY.user = TEST_USER_READER; }); const CHART_MENU_SELECTOR = '.chart-menu'; @@ -58,7 +59,7 @@ const CLEAR_ALL_SELECTOR = '.selection-status__clear-all'; const ERROR_SELECTOR = '.grid-panel__grid .alert-danger'; describe('GridPanel', () => { - let actions; + let actions: Actions; beforeEach(() => { actions = makeTestActions(jest.fn); @@ -362,7 +363,7 @@ describe('GridPanel', () => { test('FilterStatus from saved view', () => { // This test ensures that the filter status includes sorts/filters from the saved view - const nameSort = { fieldKey: 'Name', dir: '+' }; + const nameSort = { fieldKey: 'Name', dir: '+' } as QuerySort; const nameFilter = { fieldKey: 'Name', value: 'DMXP', op: 'eq' }; const expirFilter = { fieldKey: 'expirationTime', value: '1', op: 'eq' }; const view = ViewInfo.fromJson({ @@ -393,6 +394,78 @@ describe('GridPanel', () => { expect(filterTags[2].classList).toContain('is-readonly'); }); + test('SaveViewModal lists the filters and sorts that will be saved', async () => { + const view = ViewInfo.fromJson({ + name: ViewInfo.DEFAULT_NAME.toLowerCase(), + filter: [{ fieldKey: 'Name', value: 'DMXP', op: 'eq' }], + sort: [{ fieldKey: 'Name', dir: '+' }], + savable: true, + session: true, + }); + const queryInfo = new QueryInfo({ + columns: QUERY_INFO.columns, + views: new ExtendedMap({ [ViewInfo.DEFAULT_NAME.toLowerCase()]: view }), + }); + const model = makeTestQueryModel(SCHEMA_QUERY, queryInfo, {}, [], 0).mutate({ + filterArray: [Filter.create('expirationTime', '2')], + sorts: [new QuerySort({ fieldKey: 'expirationTime', dir: '-' })], + }); + renderWithAppContext(, { + serverContext: { user: TEST_USER_EDITOR }, + }); + + await userEvent.click(document.querySelector('.view-header .btn-success')); + + const sections = document.querySelectorAll('.save-view-modal__action-values'); + expect(sections).toHaveLength(2); + + // the view's saved filters and the user's ad hoc ones, both without the grid bar's read-only treatment + const filterTags = sections[0].querySelectorAll(FILTER_STATUS_VALUE); + expect(filterTags).toHaveLength(2); + expect(filterTags[0]).toHaveTextContent('Name = DMXP'); + expect(filterTags[1]).toHaveTextContent('Expiration Time = 2'); + expect(document.querySelectorAll('.save-view-modal .is-readonly')).toHaveLength(0); + + const sortTags = sections[1].querySelectorAll(FILTER_STATUS_VALUE); + expect(sortTags).toHaveLength(2); + expect(sortTags[0]).toHaveTextContent('Expiration Time'); + expect(sortTags[0].querySelectorAll('.fa-sort-amount-desc')).toHaveLength(1); + expect(sortTags[0].parentElement).toHaveAttribute('title', 'Sorted descending'); + expect(sortTags[1]).toHaveTextContent('Name'); + expect(sortTags[1].querySelectorAll('.fa-sort-amount-asc')).toHaveLength(1); + expect(sortTags[1].parentElement).toHaveAttribute('title', 'Sorted ascending'); + }); + + // GitHub Issue #696: onSaveView persists filters and sorts whether or not the grid can resolve a column for them, + // so the dialog has to list them even though the filter status bar leaves them out. + test('SaveViewModal lists filters and sorts whose column no longer resolves', async () => { + const view = ViewInfo.fromJson({ + name: ViewInfo.DEFAULT_NAME.toLowerCase(), + filter: [{ fieldKey: 'DeletedField', value: 'x', op: 'eq' }], + sort: [{ fieldKey: 'DeletedField', dir: '+' }], + savable: true, + session: true, + }); + const queryInfo = new QueryInfo({ + columns: QUERY_INFO.columns, + views: new ExtendedMap({ [ViewInfo.DEFAULT_NAME.toLowerCase()]: view }), + }); + const model = makeTestQueryModel(SCHEMA_QUERY, queryInfo, {}, [], 0); + renderWithAppContext(, { + serverContext: { user: TEST_USER_EDITOR }, + }); + + expect(document.querySelectorAll(`${FILTER_STATUS_SELECTOR} ${FILTER_STATUS_VALUE}`)).toHaveLength(0); + + await userEvent.click(document.querySelector('.view-header .btn-success')); + + const sections = document.querySelectorAll('.save-view-modal__action-values'); + expect(sections[0].querySelectorAll(FILTER_STATUS_VALUE)).toHaveLength(1); + expect(sections[0].querySelector(FILTER_STATUS_VALUE)).toHaveTextContent('DeletedField = x'); + expect(sections[1].querySelectorAll(FILTER_STATUS_VALUE)).toHaveLength(1); + expect(sections[1].querySelector(FILTER_STATUS_VALUE)).toHaveTextContent('DeletedField'); + }); + const getCheckbox = (index: number): HTMLInputElement => { // index 0 is header, 1+ is data row const grid = document.querySelector(GRID_SELECTOR); @@ -732,7 +805,7 @@ describe('GridTitle', () => { }); // GitHub Issue #899 - const renderSaveCurrentView = (onSaveView: jest.Mock, path: string, type: string) => { + const renderSaveCurrentView = (onSaveView: jest.Mock, container: Container) => { const viewSchemaQuery = new SchemaQuery('exp.data', 'mixtures', 'noExtraColumn'); const sessionQueryInfo = QUERY_INFO.mutate({ views: QUERY_INFO.views.merge({ @@ -749,7 +822,7 @@ describe('GridTitle', () => { { serverContext: { user: TEST_USER_PROJECT_ADMIN, - container: { path, type }, + container, moduleContext: { query: { isProductFoldersEnabled: true } }, }, } @@ -758,7 +831,7 @@ describe('GridTitle', () => { test('save current view from a subfolder does not inherit', async () => { const onSaveView = jest.fn(); - const { container } = renderSaveCurrentView(onSaveView, '/project/a', 'folder'); + const { container } = renderSaveCurrentView(onSaveView, TEST_FOLDER_CONTAINER); await userEvent.click(container.querySelector('.split-button-dropdown__button')); @@ -768,7 +841,7 @@ describe('GridTitle', () => { test('save current view from the home folder keeps inherit', async () => { const onSaveView = jest.fn(); - const { container } = renderSaveCurrentView(onSaveView, '/project', 'project'); + const { container } = renderSaveCurrentView(onSaveView, TEST_PROJECT_CONTAINER); await userEvent.click(container.querySelector('.split-button-dropdown__button')); diff --git a/packages/components/src/public/QueryModel/GridPanel.tsx b/packages/components/src/public/QueryModel/GridPanel.tsx index 074427bded..d567aeaaa3 100644 --- a/packages/components/src/public/QueryModel/GridPanel.tsx +++ b/packages/components/src/public/QueryModel/GridPanel.tsx @@ -78,7 +78,10 @@ import { SaveViewModal } from './SaveViewModal'; import { CustomizeGridViewModal } from './CustomizeGridViewModal'; import { ManageViewsModal } from './ManageViewsModal'; import { Actions, InjectedQueryModels, RequiresModelAndActions, withQueryModels } from './withQueryModels'; -import { ChartList, ChartPanel } from './ChartPanel'; +import { ChartList } from './ChartPanel'; + +const READONLY_FILTER_TIP = + 'Filter cannot be edited as it is saved with the view. Remove it and add it again to make changes.'; export interface GridPanelProps { advancedExportOptions?: Record; @@ -459,7 +462,10 @@ export class GridPanel extends PureComponent, State> { view: ViewAction; }; - createGridActionValues = (): { actionValues: ActionValue[]; searchActionValues: ActionValue[] } => { + createGridActionValues = ( + includeReadOnlyMessage = true, + includeUnresolvedColumns = false + ): { actionValues: ActionValue[]; searchActionValues: ActionValue[] } => { const { model } = this.props; const { filterArray, sorts } = model; const view = model.currentView; @@ -469,7 +475,7 @@ export class GridPanel extends PureComponent, State> { const _sorts = view ? sorts.concat(view.sorts) : sorts; _sorts.forEach((sort): void => { const column = model.getColumnByFieldKey(sort.fieldKey); - if (column) { + if (column || includeUnresolvedColumns) { actionValues.push(this.gridActions.sort.actionValueFromSort(sort, column?.shortCaption)); } }); @@ -477,14 +483,15 @@ export class GridPanel extends PureComponent, State> { // handle the view's saved filters (which will be shown as read only) if (view && view.filters.length) { view.filters.forEach((filter): void => { + const readOnlyMessage = includeReadOnlyMessage ? READONLY_FILTER_TIP : undefined; const column = model.getColumnByFieldKey(filter.getColumnName()); if (column) { - actionValues.push( - this.gridActions.filter.actionValueFromFilter(filter, column, 'Locked (saved with view)') - ); + actionValues.push(this.gridActions.filter.actionValueFromFilter(filter, column, readOnlyMessage)); } else if (filter.getColumnName() === '*') { + actionValues.push(this.gridActions.search.actionValueFromFilter(filter, readOnlyMessage)); + } else if (includeUnresolvedColumns) { actionValues.push( - this.gridActions.search.actionValueFromFilter(filter, 'Locked (saved with view)') + this.gridActions.filter.actionValueFromFilter(filter, undefined, readOnlyMessage) ); } }); @@ -502,7 +509,11 @@ export class GridPanel extends PureComponent, State> { actionValues.push(this.gridActions.filter.actionValueFromFilter(filter, column)); } else if (filterColName.indexOf('/') > -1 && filterColName.split('/').length === 2) { const lookupCol = model.getColumnByFieldKey(filterColName.split('/')[0]); - if (lookupCol) actionValues.push(this.gridActions.filter.actionValueFromFilter(filter, lookupCol)); + if (lookupCol) { + actionValues.push(this.gridActions.filter.actionValueFromFilter(filter, lookupCol)); + } else if (includeUnresolvedColumns) { + actionValues.push(this.gridActions.filter.actionValueFromFilter(filter)); + } } else { actionValues.push(this.gridActions.filter.actionValueFromFilter(filter)); } @@ -515,6 +526,19 @@ export class GridPanel extends PureComponent, State> { }; }; + // GitHub Issue #696: what onSaveView will persist, so the save modal can show it. No read-only message. Unresolved columns are included. + getSaveViewActionValues = (): { filterActionValues: ActionValue[]; sortActionValues: ActionValue[] } => { + const { actionValues, searchActionValues } = this.createGridActionValues(false, true); + const staticValues = actionValues + .concat(searchActionValues) + .map(actionValue => ({ ...actionValue, isRemovable: false })); + + return { + filterActionValues: staticValues.filter(av => av.action.keyword !== 'sort'), + sortActionValues: staticValues.filter(av => av.action.keyword === 'sort'), + }; + }; + /** * Populates the grid with ActionValues based on the current model state. Requires that the model has a QueryInfo * so we can properly render Column and View labels. @@ -1220,6 +1244,7 @@ export class GridPanel extends PureComponent, State> { gridLabel={queryInfo?.schemaQuery?.queryName} onCancel={this.closeSaveViewModal} onConfirmSave={this.onSaveView} + {...this.getSaveViewActionValues()} /> )} {showCustomizeViewModal && ( diff --git a/packages/components/src/public/QueryModel/SaveViewModal.test.tsx b/packages/components/src/public/QueryModel/SaveViewModal.test.tsx index 3dfa4d92d6..04e5b0767d 100644 --- a/packages/components/src/public/QueryModel/SaveViewModal.test.tsx +++ b/packages/components/src/public/QueryModel/SaveViewModal.test.tsx @@ -10,6 +10,8 @@ import { userEvent } from '@testing-library/user-event'; import { ViewInfo } from '../../internal/ViewInfo'; +import { QuerySort } from '../QuerySort'; + import { TEST_USER_APP_ADMIN, TEST_USER_EDITOR, @@ -19,6 +21,9 @@ import { import { renderWithAppContext } from '../../internal/test/reactTestLibraryHelpers'; +import { FilterAction } from './grid/actions/Filter'; +import { SortAction } from './grid/actions/Sort'; + import { SaveViewModal, ViewNameInput } from './SaveViewModal'; describe('SaveViewModal', () => { @@ -95,7 +100,7 @@ describe('SaveViewModal', () => { expect(document.querySelector('.modal-title').textContent).toBe('Save Grid View'); expect(document.querySelector('.modal-body').textContent).toContain( - 'Columns, sort order, and filters will be saved. Once saved, this view will be available for all Blood Samples grids throughout the application.' + 'Once saved, this view will be available for all Blood Samples grids throughout the application.' ); expect(document.querySelectorAll('input[name="gridViewName"]')).toHaveLength(0); expect(document.querySelector('input[id="defaultView"]').hasAttribute('checked')).toBeTruthy(); @@ -118,7 +123,7 @@ describe('SaveViewModal', () => { expect(document.querySelector('.modal-title').textContent).toBe('Save Grid View'); expect(document.querySelector('.modal-body').textContent).toContain( - 'Columns, sort order, and filters will be saved. Once saved, this view will be available for all Blood Samples grids throughout the application.' + 'Once saved, this view will be available for all Blood Samples grids throughout the application.' ); expect(document.querySelector('input[name="gridViewName"]').getAttribute('value')).toBe('View1'); expect(document.querySelector('input[id="defaultView"]').hasAttribute('checked')).toBeFalsy(); @@ -141,7 +146,7 @@ describe('SaveViewModal', () => { expect(document.querySelector('.modal-title').textContent).toBe('Save Grid View'); expect(document.querySelector('.modal-body').textContent).toContain( - 'Columns, sort order, and filters will be saved. Once saved, this view will be available for all Blood Samples grids throughout the application.' + 'Once saved, this view will be available for all Blood Samples grids throughout the application.' ); expect(document.querySelector('input[name="gridViewName"]').getAttribute('value')).toBe('View1'); expect(document.querySelector('input[id="defaultView"]').hasAttribute('checked')).toBeFalsy(); @@ -164,7 +169,7 @@ describe('SaveViewModal', () => { expect(document.querySelector('.modal-title').textContent).toBe('Save Grid View'); expect(document.querySelector('.modal-body').textContent).toContain( - 'Columns, sort order, and filters will be saved. Once saved, this view will be available for all Blood Samples grids throughout the application.' + 'Once saved, this view will be available for all Blood Samples grids throughout the application.' ); expect(document.querySelector('input[name="gridViewName"]').getAttribute('value')).toBe('View2'); expect(document.querySelectorAll('input[name="setDefaultView"]').length).toEqual(0); @@ -186,7 +191,7 @@ describe('SaveViewModal', () => { expect(document.querySelector('.modal-title').textContent).toBe('Save Grid View'); expect(document.querySelector('.modal-body').textContent).toContain( - 'Columns, sort order, and filters will be saved. Once saved, this view will be available for all Blood Samples grids throughout the application.' + 'Once saved, this view will be available for all Blood Samples grids throughout the application.' ); expect(document.querySelector('input[name="gridViewName"]').getAttribute('value')).toBe('View2'); expect(document.querySelectorAll('input[name="setDefaultView"]')).toHaveLength(0); @@ -194,6 +199,78 @@ describe('SaveViewModal', () => { expect(document.querySelectorAll('input[name="setShared"]')).toHaveLength(0); }); + test('no filters or sorts', () => { + renderWithAppContext(, { + serverContext: { user: TEST_USER_EDITOR, moduleContext }, + }); + + const sections = document.querySelectorAll('.save-view-modal__action-values'); + expect(sections).toHaveLength(2); + expect(sections[0].textContent).toBe('Filters included in viewNo filters applied'); + expect(sections[1].textContent).toBe('Sort order for this viewNo sort applied'); + expect(document.querySelectorAll('.filter-status-value')).toHaveLength(0); + }); + + test('filters and sorts to be saved', () => { + renderWithAppContext( + , + { serverContext: { user: TEST_USER_EDITOR, moduleContext } } + ); + + const values = document.querySelectorAll('.filter-status-value'); + expect(values).toHaveLength(2); + expect(values[0].textContent).toBe('Status = Available'); + expect(values[1].textContent).toBe('Sample ID'); + expect(values[1].querySelectorAll('.fa-sort-amount-asc')).toHaveLength(1); + expect(values[1].parentElement.getAttribute('title')).toBe('Sorted ascending'); + // display only: no remove affordance on hover + expect(document.querySelectorAll('.fa-close')).toHaveLength(0); + }); + + test('descending sort', () => { + renderWithAppContext( + , + { serverContext: { user: TEST_USER_EDITOR, moduleContext } } + ); + + const sortTag = document.querySelector('.filter-status-value'); + expect(sortTag.textContent).toBe('Sample ID'); + expect(sortTag.querySelectorAll('.fa-sort-amount-desc')).toHaveLength(1); + expect(sortTag.parentElement.getAttribute('title')).toBe('Sorted descending'); + }); + test('session view uses the shadowed view inherit flag', () => { renderWithAppContext(, { serverContext: { diff --git a/packages/components/src/public/QueryModel/SaveViewModal.tsx b/packages/components/src/public/QueryModel/SaveViewModal.tsx index a03d7f4f47..13db72ef15 100644 --- a/packages/components/src/public/QueryModel/SaveViewModal.tsx +++ b/packages/components/src/public/QueryModel/SaveViewModal.tsx @@ -2,7 +2,7 @@ * Copyright (c) 2022-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -import React, { ChangeEvent, FC, memo, useCallback, useEffect, useState } from 'react'; +import React, { ChangeEvent, FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; import { PermissionTypes } from '@labkey/api'; @@ -17,6 +17,9 @@ import { canInheritGridView, isProductFoldersEnabled, userCanEditSharedViews } f import { useServerContext } from '../../internal/components/base/ServerContext'; import { ViewInfo } from '../../internal/ViewInfo'; +import { ActionValue } from './grid/actions/Action'; +import { Value } from './grid/Value'; + const MAX_VIEW_NAME_LENGTH = 200; const RESERVED_VIEW_NAMES = [ @@ -116,15 +119,46 @@ export const ViewNameInput: FC = memo(props => { ); }); +// The sort pills' only cue for direction is their icon, so give it a tooltip +const ICON_TITLES = { + 'sort-amount-asc': 'Sorted ascending', + 'sort-amount-desc': 'Sorted descending', +}; + +interface SavedActionValuesProps { + actionValues: ActionValue[]; + emptyText: string; + label: string; +} + +// GitHub Issue #696: show the filters and sorts a save will include the view; display only pills +const SavedActionValues: FC = memo(({ actionValues, emptyText, label }) => ( +
+
{label}
+ {actionValues.length === 0 ? ( +
{emptyText}
+ ) : ( + actionValues.map((actionValue, index) => ( + + + + )) + )} +
+)); +SavedActionValues.displayName = 'SavedActionValues'; + interface Props { currentView: ViewInfo; + filterActionValues?: ActionValue[]; gridLabel: string; onCancel: () => void; onConfirmSave: (viewName, canInherit, replace, shared) => Promise; + sortActionValues?: ActionValue[]; } export const SaveViewModal: FC = memo(props => { - const { onConfirmSave, currentView, onCancel, gridLabel } = props; + const { onConfirmSave, currentView, filterActionValues, onCancel, gridLabel, sortActionValues } = props; const { container, moduleContext, user } = useServerContext(); const [viewName, setViewName] = useState( @@ -174,12 +208,25 @@ export const SaveViewModal: FC = memo(props => { setNameError(false); }, []); + const sortValues = useMemo( + () => + sortActionValues?.map(actionValue => ({ + ...actionValue, + action: { + ...actionValue.action, + iconCls: actionValue.valueObject?.dir === '-' ? 'sort-amount-desc' : 'sort-amount-asc', + }, + })) ?? [], + [sortActionValues] + ); + const toggleInherit = useCallback((evt: ChangeEvent) => setCanInherit(evt.target.checked), []); const toggleShared = useCallback((evt: ChangeEvent) => setIsShared(evt.target.checked), []); return ( = memo(props => {
- Columns, sort order, and filters will be saved. Once saved, this view will be available for all{' '} - {gridLabel} grids throughout the application. + Once saved, this view will be available for all {gridLabel} grids throughout the application.
@@ -265,6 +311,16 @@ export const SaveViewModal: FC = memo(props => {
)} + +
Learn more about custom grid views in LabKey.
diff --git a/packages/components/src/public/QueryModel/grid/Value.test.tsx b/packages/components/src/public/QueryModel/grid/Value.test.tsx index e34e9bf12a..f655bb3fc6 100644 --- a/packages/components/src/public/QueryModel/grid/Value.test.tsx +++ b/packages/components/src/public/QueryModel/grid/Value.test.tsx @@ -3,54 +3,35 @@ * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import React from 'react'; -import { List } from 'immutable'; import { Filter } from '@labkey/api'; -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import { QueryInfo } from '../../QueryInfo'; - import { Value } from './Value'; import { FilterAction } from './actions/Filter'; import { ViewAction } from './actions/View'; const filterAction = { - action: new FilterAction( - 'query', - () => List(), - () => new QueryInfo({}) - ), + action: new FilterAction(), value: 'test', - valueObject: Filter.create('A', 'test', Filter.Types.EQUAL), + valueObject: Filter.create('A', 'test'), }; const readOnlyAction = { - action: new FilterAction( - 'query', - () => List(), - () => new QueryInfo({}) - ), + action: new FilterAction(), value: 'test', - valueObject: Filter.create('A', 'test', Filter.Types.EQUAL), + valueObject: Filter.create('A', 'test'), isReadOnly: 'Filter is read only', }; const nonRemovableAction = { - action: new FilterAction( - 'query', - () => List(), - () => new QueryInfo({}) - ), + action: new FilterAction(), value: 'test', - valueObject: Filter.create('A', 'test', Filter.Types.EQUAL), + valueObject: Filter.create('A', 'test'), isRemovable: false, }; const viewAction = { - action: new ViewAction( - 'query', - () => List(), - () => new QueryInfo({}) - ), + action: new ViewAction(), value: 'view', }; @@ -154,6 +135,24 @@ describe('Value', () => { validate(false, true, true); }); + test('isReadOnly shows tooltip on hover', async () => { + render(); + expect(document.querySelectorAll('.lk-popover')).toHaveLength(0); + + await userEvent.hover(document.querySelector('.filter-status-value')); + await waitFor(() => expect(document.querySelector('.lk-popover').textContent).toBe('Filter is read only')); + + await userEvent.unhover(document.querySelector('.filter-status-value')); + await waitFor(() => expect(document.querySelectorAll('.lk-popover')).toHaveLength(0)); + }); + + test('no tooltip on hover when not isReadOnly', async () => { + render(); + await userEvent.hover(document.querySelector('.filter-status-value')); + await waitFor(() => expect(document.querySelectorAll('.is-active')).toHaveLength(1)); + expect(document.querySelectorAll('.lk-popover')).toHaveLength(0); + }); + test('do not showRemoveIcon for view action', async () => { render(); validate(false, false, false); diff --git a/packages/components/src/public/QueryModel/grid/Value.tsx b/packages/components/src/public/QueryModel/grid/Value.tsx index 1f53f962aa..a05b7a1977 100644 --- a/packages/components/src/public/QueryModel/grid/Value.tsx +++ b/packages/components/src/public/QueryModel/grid/Value.tsx @@ -2,12 +2,14 @@ * Copyright (c) 2018-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -import React, { FC, memo, useCallback, useState } from 'react'; +import React, { FC, memo, useCallback, useMemo, useState } from 'react'; import classNames from 'classnames'; -import { useEnterEscape } from '../../useEnterEscape'; - import { ActionValue } from './actions/Action'; +import { useEnterEscape } from '../../useEnterEscape'; +import { OverlayTrigger } from '../../../internal/OverlayTrigger'; +import { Popover } from '../../../internal/Popover'; +import { generateId } from '../../../internal/util/utils'; interface ValueProps { actionValue: ActionValue; @@ -22,6 +24,7 @@ export const valueClassName = 'filter-status-value'; export const Value: FC = memo(({ actionValue, index, lockReadOnlyForDelete, onClick, onRemove }) => { const [isActive, setIsActive] = useState(false); const { action, value, displayValue, isReadOnly, isRemovable } = actionValue; + const popoverId = useMemo(() => generateId('filter-status-value-popover-'), []); const onIconClick = useCallback( (event: React.MouseEvent): void => { @@ -79,7 +82,7 @@ export const Value: FC = memo(({ actionValue, index, lockReadOnlyFor showRemoveIcon ? 'fa-close' : action.iconCls ? 'fa-' + action.iconCls : '' ); - return ( + const content = (
= memo(({ actionValue, index, lockReadOnlyFor onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} tabIndex={0} - title={isReadOnly} > {(!lockReadOnlyForDelete || !isReadOnly) && } {isReadOnly ? : null} {displayValue ?? value}
); + + if (!!isReadOnly) { + return ( + + {isReadOnly} + + } + > + {content} + + ); + } + + return content; }); Value.displayName = 'Value'; diff --git a/packages/components/src/theme/query-model.scss b/packages/components/src/theme/query-model.scss index 4ff7e0da2b..03d89a6d56 100644 --- a/packages/components/src/theme/query-model.scss +++ b/packages/components/src/theme/query-model.scss @@ -99,57 +99,66 @@ height: 34px; } -.grid-panel__filter-status { - .filter-status-value { - display: inline-block; - white-space: pre; - max-width: 400px; - overflow: hidden; - text-overflow: ellipsis; - cursor: pointer; - line-height: 16px; - margin-right: 8px; - padding: 8px 8px 8px 32px; +// Shared by the grid's filter status bar and the save view modal, which both render chips. +@mixin filter-status-value { + display: inline-block; + white-space: pre; + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + line-height: 16px; + margin-right: 8px; + padding: 8px 8px 8px 32px; + color: $grid-action-item-color; + background-color: $grid-action-item-bg; + border-radius: 2px; + border: 1px solid $grid-action-item-border-color; + + i.read-lock { + padding-right: 6px; + vertical-align: middle; + } + + i.symbol { color: $grid-action-item-color; - background-color: $grid-action-item-bg; - border-radius: 2px; - border: 1px solid $grid-action-item-border-color; + width: 23px; + height: 14px; + margin-right: 8px; + margin-left: -25px; + border-right: solid 1px $grid-action-item-border-color; + } - i.read-lock { - padding-right: 6px; - vertical-align: middle; - } + &:hover, + &:focus { + background-color: $grid-action-item-hover-bg; + } + &:active { + background-color: $grid-action-item-border-color; + } + + &.is-disabled, &.is-readonly { + cursor: not-allowed; + background-color: $grid-action-item-disabled-bg; + border: 1px solid $grid-action-item-disabled-border-color; + color: $grid-action-item-disabled-color; i.symbol { - color: $grid-action-item-color; - width: 23px; - height: 14px; - margin-right: 8px; - margin-left: -25px; - border-right: solid 1px $grid-action-item-border-color; + cursor: pointer; + color: $grid-action-item-disabled-color; + border-color: $grid-action-item-disabled-color; } &:hover, &:focus { - background-color: $grid-action-item-hover-bg; - } - &:active { - background-color: $grid-action-item-border-color; + background-color: $grid-action-item-disabled-hover-bg; } + } +} - &.is-disabled { - opacity: 0.6; - cursor: not-allowed; - background-color: $input-bg-disabled; - border: 1px solid $border-color; - color: $grid-action-item-disabled-color; - - i.symbol { - color: $grid-action-item-disabled-color; - border-color: $grid-action-item-disabled-border-color; - } - - } +.grid-panel__filter-status { + .filter-status-value { + @include filter-status-value; } .remove-all-filters { @@ -161,6 +170,33 @@ } } +.save-view-modal { + .save-view-modal__action-values { + margin-top: 15px; + + .bold-text { + margin-bottom: 5px; + } + + .filter-status-value { + @include filter-status-value; + + // display only in this modal, so drop the grid bar's hover/click affordances + cursor: default; + + &:hover, + &:focus, + &:active { + background-color: $grid-action-item-bg; + } + } + } + + .save-view-modal__no-action-values { + color: $text-muted; + } +} + // Issue 45139: grid header menu is clipped by the bounding container instead of overflowing it .grid-panel .grid-header-cell .dropdown-menu { position: fixed; diff --git a/packages/components/src/theme/variables.scss b/packages/components/src/theme/variables.scss index 7e99c2528e..68e01b9c79 100644 --- a/packages/components/src/theme/variables.scss +++ b/packages/components/src/theme/variables.scss @@ -86,9 +86,10 @@ $grid-action-item-color: $alert-info-text; $grid-action-item-bg: $alert-info-bg; $grid-action-item-border-color: $alert-info-border; $grid-action-item-hover-bg: color.adjust($grid-action-item-bg, $lightness: 5%); -$grid-action-item-disabled-color: #333; -$grid-action-item-disabled-bg: #FCFCFC; -$grid-action-item-disabled-border-color: color.adjust($grid-action-item-disabled-bg, $lightness: 10%); +$grid-action-item-disabled-color: $text-muted; +$grid-action-item-disabled-bg: $gray-lighter; +$grid-action-item-disabled-border-color: $border-color; +$grid-action-item-disabled-hover-bg: color.adjust($grid-action-item-disabled-bg, $lightness: 5%); //-- Z-index master list $zindex-navbar: 1000 !default;