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": "6.64.3",
"version": "6.65.0",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
7 changes: 7 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version 6.65.0
*Released*: 20 October 2025
- QueryModel: remove selectedReportId, add selectedReportIds
- withQueryModels: update selectReport, add clearSelectedReports
- ChartMenu: support multiple report selections
- Add ChartList

### version 6.64.3
*Released*: 13 October 2025
- Search: escape all quotes in search terms
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,20 @@ describe('ChartBuilderMenuItem', () => {
};

test('default props', async () => {
renderWithAppContext(<ChartBuilderMenuItem actions={actions} model={model} />, {
serverContext: {
user: TEST_USER_EDITOR,
},
});
renderWithAppContext(
<ChartBuilderMenuItem
actions={actions}
disabledMessage=""
maxCharts={5}
model={model}
selectedReportIds={[]}
/>,
{
serverContext: {
user: TEST_USER_EDITOR,
},
}
);
const menuItems = document.querySelectorAll('.lk-menu-item a');
expect(menuItems).toHaveLength(1);
expect(document.querySelector('.chart-menu-label').textContent).toBe('Create Chart');
Expand All @@ -59,4 +68,28 @@ describe('ChartBuilderMenuItem', () => {
await userEvent.click(menuItems[0]);
expect(document.querySelectorAll('.chart-builder-modal')).toHaveLength(1);
});

test('max charts', async () => {
renderWithAppContext(
<ChartBuilderMenuItem
actions={actions}
disabledMessage="too many charts"
maxCharts={5}
model={model}
selectedReportIds={['db:1', 'db:2', 'db:3', 'db:4', 'db:5', 'db:6']}
/>,
{
serverContext: {
user: TEST_USER_EDITOR,
},
}
);
const menuItems = document.querySelectorAll('.lk-menu-item.disabled');
expect(menuItems).toHaveLength(1);
expect(document.querySelector('.chart-menu-label').textContent).toBe('Create Chart');
expect(document.querySelectorAll('.chart-builder-modal')).toHaveLength(0);

await userEvent.click(menuItems[0]);
expect(document.querySelectorAll('.chart-builder-modal')).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import React, { FC, memo, useCallback, useState } from 'react';

import { MenuItem } from '../../dropdowns';
import { RequiresModelAndActions } from '../../../public/QueryModel/withQueryModels';

import { useNotificationsContext } from '../notifications/NotificationsContext';

import { ChartBuilderModal } from './ChartBuilderModal';
import { DisableableMenuItem } from '../samples/DisableableMenuItem';

export const ChartBuilderMenuItem: FC<RequiresModelAndActions> = memo(({ actions, model }) => {
interface Props extends RequiresModelAndActions {
disabledMessage: string;
maxCharts: number;
selectedReportIds: string[];
}

export const ChartBuilderMenuItem: FC<Props> = memo(props => {
const { actions, disabledMessage, maxCharts, model, selectedReportIds } = props;
const [showModal, setShowModal] = useState<boolean>(false);
const { createNotification } = useNotificationsContext();

Expand All @@ -24,13 +31,14 @@ export const ChartBuilderMenuItem: FC<RequiresModelAndActions> = memo(({ actions
},
[createNotification]
);
const disabled = selectedReportIds.length >= maxCharts;

return (
<>
<MenuItem onClick={onShowModal}>
<DisableableMenuItem disabled={disabled} disabledMessage={disabledMessage} onClick={onShowModal}>
<i className="fa fa-plus-circle" />
<span className="chart-menu-label">Create Chart</span>
</MenuItem>
</DisableableMenuItem>
{showModal && <ChartBuilderModal actions={actions} model={model} onHide={onHideModal} />}
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -777,11 +777,8 @@ export const ChartBuilderModal: FC<ChartBuilderModalProps> = memo(({ actions, mo
const response = await saveChart(_reportConfig);
setSaving(false);
onHide(`Successfully ${savedChartModel ? 'updated' : 'created'} chart: ${_reportConfig.name}.`);

// clear the selected report, if we are saving/updating it, so that it will refresh in ChartPanel.tsx
await actions.selectReport(model.id, undefined);
await actions.loadCharts(model.id);
actions.selectReport(model.id, response.reportId);
actions.loadCharts(model.id);
actions.selectReport(model.id, response.reportId, true);
} catch (e) {
setError(e.exception ?? e);
setSaving(false);
Expand All @@ -790,8 +787,8 @@ export const ChartBuilderModal: FC<ChartBuilderModalProps> = memo(({ actions, mo

const afterDelete = useCallback(async () => {
onHide('Successfully deleted chart: ' + savedChartModel.name + '.');
await actions.selectReport(model.id, undefined);
await actions.loadCharts(model.id);
actions.selectReport(model.id, savedChartModel.reportId, false);
actions.loadCharts(model.id);
}, [actions, model.id, onHide, savedChartModel]);

const onCancel = useCallback(() => {
Expand Down
23 changes: 21 additions & 2 deletions packages/components/src/public/QueryModel/ChartMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { LABKEY_VIS } from '../../internal/constants';

import { makeTestActions, makeTestQueryModel } from './testUtils';
import { ChartMenu, ChartMenuItem } from './ChartMenu';
import { userEvent } from '@testing-library/user-event';

LABKEY_VIS = {
GenericChartHelper: {
Expand All @@ -25,22 +26,40 @@ LABKEY_VIS = {
describe('ChartMenuItem', () => {
test('use chart icon', () => {
const chart = { name: 'TestChart', icon: 'icon.png', iconCls: 'fa-icon' } as DataViewInfo;
render(<ChartMenuItem chart={chart} showChart={jest.fn()} />);
render(<ChartMenuItem chart={chart} selectChart={jest.fn()} selectedReportIds={[]} />);

expect(document.querySelector('.chart-menu-label').textContent).toBe('TestChart');
expect(document.querySelectorAll('img')).toHaveLength(0);
expect(document.querySelectorAll('.chart-menu-icon')).toHaveLength(1);
expect(document.querySelectorAll('.fa-icon')).toHaveLength(1);
expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-square-o');
});

test('use svg img', () => {
const chart = { name: 'TestChart', icon: 'icon.svg', iconCls: 'fa-icon' } as DataViewInfo;
render(<ChartMenuItem chart={chart} showChart={jest.fn()} />);
render(<ChartMenuItem chart={chart} selectChart={jest.fn()} selectedReportIds={[]} />);

expect(document.querySelector('.chart-menu-label').textContent).toBe('TestChart');
expect(document.querySelectorAll('img')).toHaveLength(1);
expect(document.querySelectorAll('.chart-menu-icon')).toHaveLength(0);
expect(document.querySelectorAll('.fa-icon')).toHaveLength(0);
expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-square-o');
});

test('selectChart', async () => {
const selectChart = jest.fn();
const chart = { name: 'TestChart', icon: 'icon.png', iconCls: 'fa-icon', reportId: 'db:12' } as DataViewInfo;
const { rerender } = render(<ChartMenuItem chart={chart} selectChart={selectChart} selectedReportIds={[]} />);

expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-square-o');
await userEvent.click(document.querySelector('a'));
expect(selectChart).toHaveBeenCalledWith('db:12', true);

rerender(<ChartMenuItem chart={chart} selectChart={selectChart} selectedReportIds={['db:12']} />);

expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-check-square');
await userEvent.click(document.querySelector('a'));
expect(selectChart).toHaveBeenCalledWith('db:12', false);
});
});

Expand Down
112 changes: 70 additions & 42 deletions packages/components/src/public/QueryModel/ChartMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import React, { FC, useCallback, useEffect } from 'react';

import React, { FC, memo, useCallback, useEffect, useMemo } from 'react';
import { PermissionTypes } from '@labkey/api';
import classNames from 'classnames';

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

import { blurActiveElement } from '../../internal/util/utils';

import { DropdownButton, MenuDivider, MenuHeader, MenuItem } from '../../internal/dropdowns';

import { useServerContext } from '../../internal/components/base/ServerContext';
Expand All @@ -16,29 +14,57 @@ import { ChartBuilderMenuItem } from '../../internal/components/chart/ChartBuild
import { hasPermissions } from '../../internal/components/base/models/User';

import { RequiresModelAndActions } from './withQueryModels';
import { DisableableMenuItem } from '../../internal/components/samples/DisableableMenuItem';

const MAX_CHARTS = 5;
const DISABLED_MESSAGE = `Only ${MAX_CHARTS} charts can be shown at once.`;

interface ChartMenuItemProps {
chart: DataViewInfo;
showChart: (chart: DataViewInfo) => void;
selectChart: (reportId: string, selected: boolean) => void;
selectedReportIds: string[];
}

export const ChartMenuItem: FC<ChartMenuItemProps> = ({ chart, showChart }) => {
const onClick = useCallback(() => showChart(chart), [showChart, chart]);
export const ChartMenuItem: FC<ChartMenuItemProps> = ({ chart, selectChart, selectedReportIds }) => {
const { reportId } = chart;
const selected = useMemo(() => selectedReportIds.includes(reportId), [reportId, selectedReportIds]);
const onClick = useCallback(() => selectChart(reportId, !selected), [reportId, selectChart, selected]);
const useSVG = chart.icon?.indexOf('.svg') > -1;
const className = classNames('chart-menu-checkbox', 'fa', {
'fa-check-square': selected,
'fa-square-o': !selected,
});
const disabled = !selected && selectedReportIds.length >= MAX_CHARTS;

return (
<MenuItem onClick={onClick}>
{useSVG && <img src={chart.icon} width={16} alt={chart.icon} />}
<DisableableMenuItem disabled={disabled} disabledMessage={DISABLED_MESSAGE} onClick={onClick}>
<span className={className} />
{useSVG && <img alt={chart.icon} src={chart.icon} width={16} />}
{!useSVG && <i className={`chart-menu-icon ${chart.iconCls ?? ''}`} />}
<span className="chart-menu-label">{chart.name}</span>
</MenuItem>
</DisableableMenuItem>
);
};
ChartMenuItem.displayName = 'ChartMenuItem';

interface ChartMenuTitleProps {
isLoading: boolean;
}
export const ChartMenuTitle: FC<ChartMenuTitleProps> = memo(({ isLoading }) => {
if (isLoading) return <span className="fa fa-spinner fa-pulse" />;
return (
<span>
<span className="fa fa-area-chart" />
<span> Charts</span>
</span>
);
});
ChartMenuTitle.displayName = 'ChartMenuTitle';

export const ChartMenu: FC<RequiresModelAndActions> = props => {
const { model, actions } = props;
export const ChartMenu: FC<RequiresModelAndActions> = memo(({ actions, model }) => {
const { moduleContext, user } = useServerContext();
const { charts, chartsError, hasCharts, isLoading, isLoadingCharts, rowsError, queryInfoError } = model;
const { charts, chartsError, hasCharts, isLoading, isLoadingCharts, rowsError, selectedReportIds, queryInfoError } =
model;
const viewCharts = charts?.filter(chart => chart.viewName === model.schemaQuery.viewName) ?? []; // filter chart menu based on selected view
const privateCharts = hasCharts ? viewCharts.filter(chart => !chart.shared) : [];
const publicCharts = hasCharts ? viewCharts.filter(chart => chart.shared) : [];
Expand All @@ -49,55 +75,51 @@ export const ChartMenu: FC<RequiresModelAndActions> = props => {
const hasError = queryInfoError !== undefined || rowsError !== undefined;
const disabled = isLoading || isLoadingCharts || hasError || (noCharts && !showCreateChart);

useEffect(
() => {
actions.loadCharts(model.id);
},
[
/* on mount */
]
);
useEffect(() => {
actions.loadCharts(model.id);
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- only desired on mount

const chartClicked = useCallback(
(chart: DataViewInfo): void => {
blurActiveElement();
actions.selectReport(model.id, chart.reportId);
const selectChart = useCallback(
(reportId: string, selected: boolean): void => {
actions.selectReport(model.id, reportId, selected);
},
[actions, model]
);

if (noCharts && !showCreateChart) {
return null;
}
if (noCharts && !showCreateChart) return null;

return (
<div className="chart-menu">
<DropdownButton
buttonClassName="chart-menu-button"
disabled={disabled}
pullRight
title={
isLoadingCharts ? (
<span className="fa fa-spinner fa-pulse" />
) : (
<span>
<span className="fa fa-area-chart" />
<span> Charts</span>
</span>
)
}
title={<ChartMenuTitle isLoading={isLoadingCharts} />}
>
{chartsError !== undefined && <MenuItem>{chartsError}</MenuItem>}

{showCreateChart && <ChartBuilderMenuItem actions={actions} model={model} />}
{showCreateChart && (
<ChartBuilderMenuItem
actions={actions}
disabledMessage={DISABLED_MESSAGE}
maxCharts={MAX_CHARTS}
model={model}
selectedReportIds={selectedReportIds}
/>
)}

{showCreateChartDivider && <MenuDivider />}

{privateCharts.length > 0 && <MenuHeader text="Your Charts" />}

{privateCharts.length > 0 &&
privateCharts.map(chart => (
<ChartMenuItem key={chart.reportId} chart={chart} showChart={chartClicked} />
<ChartMenuItem
chart={chart}
key={chart.reportId}
selectChart={selectChart}
selectedReportIds={selectedReportIds}
/>
))}

{privateCharts.length > 0 && publicCharts.length > 0 && <MenuDivider />}
Expand All @@ -106,9 +128,15 @@ export const ChartMenu: FC<RequiresModelAndActions> = props => {

{publicCharts.length > 0 &&
publicCharts.map(chart => (
<ChartMenuItem key={chart.reportId} chart={chart} showChart={chartClicked} />
<ChartMenuItem
chart={chart}
key={chart.reportId}
selectChart={selectChart}
selectedReportIds={selectedReportIds}
/>
))}
</DropdownButton>
</div>
);
};
});
ChartMenu.displayName = 'ChartMenu';
Loading