Skip to content
Open
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.58.5",
"version": "7.58.6-fb-saveView899.1",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
5 changes: 5 additions & 0 deletions packages/components/src/internal/ViewInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ describe('ViewInfo', () => {
});
expect(ViewInfo.serialize(view).fields).toBeUndefined();
expect(ViewInfo.serialize(view).columns).toStrictEqual([{ name: 'col1', key: 'col1', fieldKey: 'col1' }]);

// GitHub Issue #899: saveQueryViews reads containerPath as an explicit save target
view = ViewInfo.fromJson({ name: 'test', containerPath: '/home', shadowed: { name: 'test' } });
expect(ViewInfo.serialize(view).containerPath).toBeUndefined();
expect(ViewInfo.serialize(view).shadowed).toBeUndefined();
});

test('isVisible', () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/components/src/internal/ViewInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface ViewInfoJson {
// aggregates: any[];
// analyticsProviders: any[];
columns?: ViewInfoColumn[];
containerPath?: string;
default?: boolean;
// deletable: boolean;
// editable: boolean;
Expand All @@ -55,6 +56,7 @@ export interface ViewInfoJson {
savable?: boolean;
saved?: boolean;
session?: boolean;
shadowed?: ViewInfoJson;
shared?: boolean;
sort?: QuerySortJson[];
}
Expand All @@ -71,6 +73,7 @@ const VIEW_INFO_DEFAULTS = {
savable: false,
saved: false,
session: false,
shadowed: undefined,
shared: false,
sorts: [],
};
Expand All @@ -92,6 +95,7 @@ export class ViewInfo {
declare savable: boolean;
declare saved: boolean;
declare session: boolean;
declare shadowed?: ViewInfoJson; // The saved view a session view is overlaying; only present when session is true
declare shared: boolean;
declare sorts: QuerySort[];

Expand Down Expand Up @@ -132,6 +136,8 @@ export class ViewInfo {
const json = rest as unknown as ViewInfoJson;

delete json.fields; // Issue 53324: not needed for serialization and takes up space
delete json.shadowed; // read-only server-supplied detail, and takes up space
delete json.containerPath; // GitHub Issue #899: saveQueryViews treats containerPath as an explicit save target
json.columns = [...columns];
json.default = isDefault;

Expand Down
25 changes: 25 additions & 0 deletions packages/components/src/internal/app/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
import {
addAssaysSectionConfig,
addSourcesSectionConfig,
canInheritGridView,
freezerManagerIsCurrentApp,
getCurrentAppProperties,
getMenuSectionConfigs,
Expand Down Expand Up @@ -998,6 +999,30 @@ describe('utils', () => {
).toBeFalsy();
});

// GitHub Issue #899
test('canInheritGridView', () => {
const HOME = new Container({ type: 'project', path: 'project' });
const SUBFOLDER = new Container({ type: 'folder', path: 'project/a' });
const FOLDERS_ON = { query: { isProductFoldersEnabled: true } };
const FOLDERS_OFF = { query: { isProductFoldersEnabled: false } };

expect(canInheritGridView(TEST_USER_EDITOR, HOME, FOLDERS_ON)).toBeTruthy();
expect(canInheritGridView(TEST_USER_APP_ADMIN, HOME, FOLDERS_ON)).toBeTruthy();
expect(canInheritGridView(TEST_USER_FOLDER_ADMIN, HOME, FOLDERS_ON)).toBeTruthy();

// an inherited view lives in the home folder, so a subfolder save must shadow it rather than target it
expect(canInheritGridView(TEST_USER_APP_ADMIN, SUBFOLDER, FOLDERS_ON)).toBeFalsy();

// without product folders every container is the app home folder
expect(canInheritGridView(TEST_USER_APP_ADMIN, SUBFOLDER, FOLDERS_OFF)).toBeTruthy();
expect(canInheritGridView(TEST_USER_APP_ADMIN, HOME, FOLDERS_OFF)).toBeTruthy();

// the save actions reject inherit outright without EditSharedView
expect(canInheritGridView(TEST_USER_READER, HOME, FOLDERS_ON)).toBeFalsy();
expect(canInheritGridView(TEST_USER_AUTHOR, HOME, FOLDERS_ON)).toBeFalsy();
expect(canInheritGridView(TEST_USER_GUEST, HOME, FOLDERS_ON)).toBeFalsy();
expect(canInheritGridView(TEST_USER_READER, SUBFOLDER, FOLDERS_OFF)).toBeFalsy();
});

test('getPrimaryAppProperties', () => {
__setController('project');
Expand Down
6 changes: 6 additions & 0 deletions packages/components/src/internal/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ export function isAppHomeFolder(container?: Partial<Container>, moduleContext?:
return isTopFolder || (isSubFolder && !isProductFoldersEnabled(moduleContext));
}

// GitHub Issue #899: outside the app home folder an inherited view lives in the parent containerPath, so saving it
// with inherit would target that parent instead of shadowing it locally.
export function canInheritGridView(user: User, container?: Partial<Container>, moduleContext?: ModuleContext): boolean {
return userCanEditSharedViews(user) && isAppHomeFolder(container, moduleContext);
}

export function getAppHomeFolderPath(container?: Partial<Container>, moduleContext?: ModuleContext): string {
const currentContainer: Partial<Container> = container ?? getServerContext().container;
return isAppHomeFolder(currentContainer, moduleContext) ? currentContainer.path : currentContainer.parentPath;
Expand Down
44 changes: 44 additions & 0 deletions packages/components/src/public/QueryModel/GridPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,50 @@ describe('GridTitle', () => {
validate(container, 'Default View', true, true, false, true);
});

// GitHub Issue #899
const renderSaveCurrentView = (onSaveView: jest.Mock, path: string, type: string) => {
const viewSchemaQuery = new SchemaQuery('exp.data', 'mixtures', 'noExtraColumn');
const sessionQueryInfo = QUERY_INFO.mutate({
views: QUERY_INFO.views.merge({
noextracolumn: QUERY_INFO.views.get('noextracolumn').mutate({ inherit: true, session: true }),
}),
});
return renderWithAppContext(
<GridTitle
{...GRID_TITLE_PROPS}
allowViewCustomization
model={makeTestQueryModel(viewSchemaQuery, sessionQueryInfo)}
onSaveView={onSaveView}
/>,
{
serverContext: {
user: TEST_USER_PROJECT_ADMIN,
container: { path, type },
moduleContext: { query: { isProductFoldersEnabled: true } },
},
}
);
};

test('save current view from a subfolder does not inherit', async () => {
const onSaveView = jest.fn();
const { container } = renderSaveCurrentView(onSaveView, '/project/a', 'folder');

await userEvent.click(container.querySelector('.split-button-dropdown__button'));

// the inherited view lives in the home folder, so a subfolder save must shadow it rather than target it
expect(onSaveView).toHaveBeenCalledWith(true, false);
});

test('save current view from the home folder keeps inherit', async () => {
const onSaveView = jest.fn();
const { container } = renderSaveCurrentView(onSaveView, '/project', 'project');

await userEvent.click(container.querySelector('.split-button-dropdown__button'));

expect(onSaveView).toHaveBeenCalledWith(true, true);
});

test('hidden view, not edited, no title', () => {
const viewSchemaQuery = new SchemaQuery('exp.data', 'mixtures', 'noExtraColumn');
const sessionQueryInfo = QUERY_INFO.mutate({
Expand Down
22 changes: 15 additions & 7 deletions packages/components/src/public/QueryModel/GridPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { Grid } from '../../internal/components/base/Grid';

import { Alert } from '../../internal/components/base/Alert';

import { userCanEditSharedViews } from '../../internal/app/utils';
import { canInheritGridView, userCanEditSharedViews } from '../../internal/app/utils';

import { User } from '../../internal/components/base/models/User';

Expand Down Expand Up @@ -286,7 +286,7 @@ interface GridTitleProps {
model: QueryModel;
onRevertView?: () => void;
onSaveNewView?: () => void;
onSaveView?: (canSaveShared) => void;
onSaveView?: (canSaveShared: boolean, canInherit: boolean) => void;
title?: string;
view?: ViewInfo;
}
Expand All @@ -306,7 +306,7 @@ export const GridTitle: FC<GridTitleProps> = memo(props => {
} = props;
const { viewName } = model;
const [errorMsg, setErrorMsg] = useState<string>();
const { user } = useServerContext();
const { container, moduleContext, user } = useServerContext();

const currentView = view ?? model.currentView;
let displayTitle = title;
Expand Down Expand Up @@ -336,8 +336,11 @@ export const GridTitle: FC<GridTitleProps> = memo(props => {
}, [model, onRevertView, actions, allowSelections]);

const _onSaveCurrentView = useCallback((): void => {
onSaveView(userCanEditSharedViews(user as User));
}, [onSaveView, user]);
onSaveView(
userCanEditSharedViews(user as User),
canInheritGridView(user as User, container, moduleContext)
);
}, [container, moduleContext, onSaveView, user]);

if (!displayTitle && (!allowViewCustomization || (!isEdited && !isUpdated))) {
return null;
Expand Down Expand Up @@ -801,15 +804,20 @@ export class GridPanel<T = {}> extends PureComponent<Props<T>, State> {
});
};

onSaveCurrentView = async (canSaveShared: boolean): Promise<void> => {
onSaveCurrentView = async (canSaveShared: boolean, canInherit: boolean): Promise<void> => {
const { model } = this.props;
const { queryInfo, viewName } = model;
const view = queryInfo?.getView(viewName, true);

let currentView = view;
try {
if (view.session) currentView = await getGridView(queryInfo.schemaQuery, viewName, true);
await this.onSaveView(viewName, currentView?.inherit, true, currentView.shared && canSaveShared);
await this.onSaveView(
viewName,
currentView?.inherit && canInherit,
true,
currentView.shared && canSaveShared
);
} catch (errorMsg) {
this.setState({ errorMsg });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { waitFor } from '@testing-library/dom';
import { userEvent } from '@testing-library/user-event';

import { ViewInfo } from '../../internal/ViewInfo';

Expand Down Expand Up @@ -65,6 +66,18 @@ const SHARED_VIEW = ViewInfo.fromJson({
shared: true,
});

// as the server reports it from a subfolder: inheritable and owned by the home folder
const INHERITED_VIEW = ViewInfo.fromJson({
containerPath: '/project',
default: false,
inherit: true,
label: 'View 4',
name: 'View4',
shared: true,
});

const FOLDERS_ON = { query: { isProductFoldersEnabled: true } };

describe('ViewLabel', () => {
test('default view', () => {
const { container } = render(<ViewLabel view={SYSTEM_DEFAULT_VIEW} />);
Expand Down Expand Up @@ -245,6 +258,73 @@ describe('ManageViewsModal', () => {
expect(rows[0].querySelector('.gray-text').textContent).toBe('Revert');
});

// GitHub Issue #899
test('make default from a subfolder does not inherit', async () => {
const api = getQueryAPI([INHERITED_VIEW]);
renderWithAppContext(
<ManageViewsModal currentView={INHERITED_VIEW} onDone={jest.fn()} schemaQuery={null} />,
{
appContext: { api },
serverContext: {
user: TEST_USER_PROJECT_ADMIN,
container: { path: '/project/a', type: 'folder' },
moduleContext: FOLDERS_ON,
},
}
);
await waitFor(() => {
expect(document.querySelector('#setDefault-0')).not.toBeNull();
});

await userEvent.click(document.querySelector('#setDefault-0'));

// inherit must be false: the view lives in the home folder, so inheriting would promote it there instead
await waitFor(() => {
expect(api.query.saveGridView).toHaveBeenCalledWith(
null,
undefined,
expect.anything(),
true,
false,
false,
true
);
});
});

// GitHub Issue #899
test('make default from the home folder keeps inherit', async () => {
const api = getQueryAPI([INHERITED_VIEW]);
renderWithAppContext(
<ManageViewsModal currentView={INHERITED_VIEW} onDone={jest.fn()} schemaQuery={null} />,
{
appContext: { api },
serverContext: {
user: TEST_USER_PROJECT_ADMIN,
container: { path: '/project', type: 'project' },
moduleContext: FOLDERS_ON,
},
}
);
await waitFor(() => {
expect(document.querySelector('#setDefault-0')).not.toBeNull();
});

await userEvent.click(document.querySelector('#setDefault-0'));

await waitFor(() => {
expect(api.query.saveGridView).toHaveBeenCalledWith(
null,
undefined,
expect.anything(),
true,
false,
true,
true
);
});
});

test('multiple saved views: no admin permission', async () => {
renderWithAppContext(<ManageViewsModal currentView={null} onDone={jest.fn()} schemaQuery={null} />, {
appContext: { api: getQueryAPI([MY_DEFAULT_VIEW, VIEW_1, SESSION_VIEW, SHARED_VIEW]) },
Expand Down
Loading