Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions packages/components/package-lock.json

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

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
89 changes: 81 additions & 8 deletions packages/components/src/public/QueryModel/GridPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,7 +43,6 @@ class TestButtons extends PureComponent<RequiresModelAndActions> {
beforeAll(() => {
QUERY_INFO = makeQueryInfo(mixturesQueryInfo);
DATA = makeTestData(mixturesQuery);
LABKEY.user = TEST_USER_READER;
});

const CHART_MENU_SELECTOR = '.chart-menu';
Expand All @@ -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);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(<GridPanel actions={actions} model={model} />, {
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(<GridPanel actions={actions} model={model} />, {
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);
Expand Down Expand Up @@ -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({
Expand All @@ -749,7 +822,7 @@ describe('GridTitle', () => {
{
serverContext: {
user: TEST_USER_PROJECT_ADMIN,
container: { path, type },
container,
moduleContext: { query: { isProductFoldersEnabled: true } },
},
}
Expand All @@ -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'));

Expand All @@ -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'));

Expand Down
41 changes: 33 additions & 8 deletions packages/components/src/public/QueryModel/GridPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ButtonsComponentProps> {
advancedExportOptions?: Record<string, any>;
Expand Down Expand Up @@ -459,7 +462,10 @@ export class GridPanel<T = {}> extends PureComponent<Props<T>, 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;
Expand All @@ -469,22 +475,23 @@ export class GridPanel<T = {}> extends PureComponent<Props<T>, 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));
}
});

// 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)
);
}
});
Expand All @@ -502,7 +509,11 @@ export class GridPanel<T = {}> extends PureComponent<Props<T>, 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));
}
Expand All @@ -515,6 +526,19 @@ export class GridPanel<T = {}> extends PureComponent<Props<T>, 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.
Expand Down Expand Up @@ -1220,6 +1244,7 @@ export class GridPanel<T = {}> extends PureComponent<Props<T>, State> {
gridLabel={queryInfo?.schemaQuery?.queryName}
onCancel={this.closeSaveViewModal}
onConfirmSave={this.onSaveView}
{...this.getSaveViewActionValues()}
/>
)}
{showCustomizeViewModal && (
Expand Down
Loading