Skip to content

Commit c902e3a

Browse files
committed
Merge branch 'develop' into fb_chartErrorBars
2 parents 7dfae7f + 8c96b7f commit c902e3a

16 files changed

Lines changed: 299 additions & 141 deletions

packages/components/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@labkey/components",
3-
"version": "6.64.3-chartErrorBars.4",
3+
"version": "6.65.1",
44
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
55
"sideEffects": false,
66
"files": [

packages/components/releaseNotes/components.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ Components, models, actions, and utility functions for LabKey applications and p
1010
- support for error bar radio options as separate overlay or to be included in axis options overlay
1111
- Update ChartBuilderModal to pass down aggregate and error bar options to ChartConfig
1212

13+
### version 6.65.1
14+
*Released*: 20 October 2025
15+
- SelectionStatus null check for rowCount
16+
17+
### version 6.65.0
18+
*Released*: 20 October 2025
19+
- QueryModel: remove selectedReportId, add selectedReportIds
20+
- withQueryModels: update selectReport, add clearSelectedReports
21+
- ChartMenu: support multiple report selections
22+
- Add ChartList
23+
1324
### version 6.64.3
1425
*Released*: 13 October 2025
1526
- Search: escape all quotes in search terms

packages/components/src/internal/components/chart/ChartBuilderMenuItem.test.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,20 @@ describe('ChartBuilderMenuItem', () => {
4646
};
4747

4848
test('default props', async () => {
49-
renderWithAppContext(<ChartBuilderMenuItem actions={actions} model={model} />, {
50-
serverContext: {
51-
user: TEST_USER_EDITOR,
52-
},
53-
});
49+
renderWithAppContext(
50+
<ChartBuilderMenuItem
51+
actions={actions}
52+
disabledMessage=""
53+
maxCharts={5}
54+
model={model}
55+
selectedReportIds={[]}
56+
/>,
57+
{
58+
serverContext: {
59+
user: TEST_USER_EDITOR,
60+
},
61+
}
62+
);
5463
const menuItems = document.querySelectorAll('.lk-menu-item a');
5564
expect(menuItems).toHaveLength(1);
5665
expect(document.querySelector('.chart-menu-label').textContent).toBe('Create Chart');
@@ -59,4 +68,28 @@ describe('ChartBuilderMenuItem', () => {
5968
await userEvent.click(menuItems[0]);
6069
expect(document.querySelectorAll('.chart-builder-modal')).toHaveLength(1);
6170
});
71+
72+
test('max charts', async () => {
73+
renderWithAppContext(
74+
<ChartBuilderMenuItem
75+
actions={actions}
76+
disabledMessage="too many charts"
77+
maxCharts={5}
78+
model={model}
79+
selectedReportIds={['db:1', 'db:2', 'db:3', 'db:4', 'db:5', 'db:6']}
80+
/>,
81+
{
82+
serverContext: {
83+
user: TEST_USER_EDITOR,
84+
},
85+
}
86+
);
87+
const menuItems = document.querySelectorAll('.lk-menu-item.disabled');
88+
expect(menuItems).toHaveLength(1);
89+
expect(document.querySelector('.chart-menu-label').textContent).toBe('Create Chart');
90+
expect(document.querySelectorAll('.chart-builder-modal')).toHaveLength(0);
91+
92+
await userEvent.click(menuItems[0]);
93+
expect(document.querySelectorAll('.chart-builder-modal')).toHaveLength(0);
94+
});
6295
});

packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import React, { FC, memo, useCallback, useState } from 'react';
22

3-
import { MenuItem } from '../../dropdowns';
43
import { RequiresModelAndActions } from '../../../public/QueryModel/withQueryModels';
54

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

87
import { ChartBuilderModal } from './ChartBuilderModal';
8+
import { DisableableMenuItem } from '../samples/DisableableMenuItem';
99

10-
export const ChartBuilderMenuItem: FC<RequiresModelAndActions> = memo(({ actions, model }) => {
10+
interface Props extends RequiresModelAndActions {
11+
disabledMessage: string;
12+
maxCharts: number;
13+
selectedReportIds: string[];
14+
}
15+
16+
export const ChartBuilderMenuItem: FC<Props> = memo(props => {
17+
const { actions, disabledMessage, maxCharts, model, selectedReportIds } = props;
1118
const [showModal, setShowModal] = useState<boolean>(false);
1219
const { createNotification } = useNotificationsContext();
1320

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

2836
return (
2937
<>
30-
<MenuItem onClick={onShowModal}>
38+
<DisableableMenuItem disabled={disabled} disabledMessage={disabledMessage} onClick={onShowModal}>
3139
<i className="fa fa-plus-circle" />
3240
<span className="chart-menu-label">Create Chart</span>
33-
</MenuItem>
41+
</DisableableMenuItem>
3442
{showModal && <ChartBuilderModal actions={actions} model={model} onHide={onHideModal} />}
3543
</>
3644
);

packages/components/src/internal/components/chart/ChartBuilderModal.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -729,11 +729,8 @@ export const ChartBuilderModal: FC<ChartBuilderModalProps> = memo(({ actions, mo
729729
const response = await saveChart(_reportConfig);
730730
setSaving(false);
731731
onHide(`Successfully ${savedChartModel ? 'updated' : 'created'} chart: ${_reportConfig.name}.`);
732-
733-
// clear the selected report, if we are saving/updating it, so that it will refresh in ChartPanel.tsx
734-
await actions.selectReport(model.id, undefined);
735-
await actions.loadCharts(model.id);
736-
actions.selectReport(model.id, response.reportId);
732+
actions.loadCharts(model.id);
733+
actions.selectReport(model.id, response.reportId, true);
737734
} catch (e) {
738735
setError(e.exception ?? e);
739736
setSaving(false);
@@ -742,8 +739,8 @@ export const ChartBuilderModal: FC<ChartBuilderModalProps> = memo(({ actions, mo
742739

743740
const afterDelete = useCallback(async () => {
744741
onHide('Successfully deleted chart: ' + savedChartModel.name + '.');
745-
await actions.selectReport(model.id, undefined);
746-
await actions.loadCharts(model.id);
742+
actions.selectReport(model.id, savedChartModel.reportId, false);
743+
actions.loadCharts(model.id);
747744
}, [actions, model.id, onHide, savedChartModel]);
748745

749746
const onCancel = useCallback(() => {

packages/components/src/public/QueryModel/ChartMenu.test.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { LABKEY_VIS } from '../../internal/constants';
1515

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

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

3031
expect(document.querySelector('.chart-menu-label').textContent).toBe('TestChart');
3132
expect(document.querySelectorAll('img')).toHaveLength(0);
3233
expect(document.querySelectorAll('.chart-menu-icon')).toHaveLength(1);
3334
expect(document.querySelectorAll('.fa-icon')).toHaveLength(1);
35+
expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-square-o');
3436
});
3537

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

4042
expect(document.querySelector('.chart-menu-label').textContent).toBe('TestChart');
4143
expect(document.querySelectorAll('img')).toHaveLength(1);
4244
expect(document.querySelectorAll('.chart-menu-icon')).toHaveLength(0);
4345
expect(document.querySelectorAll('.fa-icon')).toHaveLength(0);
46+
expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-square-o');
47+
});
48+
49+
test('selectChart', async () => {
50+
const selectChart = jest.fn();
51+
const chart = { name: 'TestChart', icon: 'icon.png', iconCls: 'fa-icon', reportId: 'db:12' } as DataViewInfo;
52+
const { rerender } = render(<ChartMenuItem chart={chart} selectChart={selectChart} selectedReportIds={[]} />);
53+
54+
expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-square-o');
55+
await userEvent.click(document.querySelector('a'));
56+
expect(selectChart).toHaveBeenCalledWith('db:12', true);
57+
58+
rerender(<ChartMenuItem chart={chart} selectChart={selectChart} selectedReportIds={['db:12']} />);
59+
60+
expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-check-square');
61+
await userEvent.click(document.querySelector('a'));
62+
expect(selectChart).toHaveBeenCalledWith('db:12', false);
4463
});
4564
});
4665

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import React, { FC, useCallback, useEffect } from 'react';
2-
1+
import React, { FC, memo, useCallback, useEffect, useMemo } from 'react';
32
import { PermissionTypes } from '@labkey/api';
3+
import classNames from 'classnames';
44

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

7-
import { blurActiveElement } from '../../internal/util/utils';
8-
97
import { DropdownButton, MenuDivider, MenuHeader, MenuItem } from '../../internal/dropdowns';
108

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

1816
import { RequiresModelAndActions } from './withQueryModels';
17+
import { DisableableMenuItem } from '../../internal/components/samples/DisableableMenuItem';
18+
19+
const MAX_CHARTS = 5;
20+
const DISABLED_MESSAGE = `Only ${MAX_CHARTS} charts can be shown at once.`;
1921

2022
interface ChartMenuItemProps {
2123
chart: DataViewInfo;
22-
showChart: (chart: DataViewInfo) => void;
24+
selectChart: (reportId: string, selected: boolean) => void;
25+
selectedReportIds: string[];
2326
}
2427

25-
export const ChartMenuItem: FC<ChartMenuItemProps> = ({ chart, showChart }) => {
26-
const onClick = useCallback(() => showChart(chart), [showChart, chart]);
28+
export const ChartMenuItem: FC<ChartMenuItemProps> = ({ chart, selectChart, selectedReportIds }) => {
29+
const { reportId } = chart;
30+
const selected = useMemo(() => selectedReportIds.includes(reportId), [reportId, selectedReportIds]);
31+
const onClick = useCallback(() => selectChart(reportId, !selected), [reportId, selectChart, selected]);
2732
const useSVG = chart.icon?.indexOf('.svg') > -1;
33+
const className = classNames('chart-menu-checkbox', 'fa', {
34+
'fa-check-square': selected,
35+
'fa-square-o': !selected,
36+
});
37+
const disabled = !selected && selectedReportIds.length >= MAX_CHARTS;
2838

2939
return (
30-
<MenuItem onClick={onClick}>
31-
{useSVG && <img src={chart.icon} width={16} alt={chart.icon} />}
40+
<DisableableMenuItem disabled={disabled} disabledMessage={DISABLED_MESSAGE} onClick={onClick}>
41+
<span className={className} />
42+
{useSVG && <img alt={chart.icon} src={chart.icon} width={16} />}
3243
{!useSVG && <i className={`chart-menu-icon ${chart.iconCls ?? ''}`} />}
3344
<span className="chart-menu-label">{chart.name}</span>
34-
</MenuItem>
45+
</DisableableMenuItem>
3546
);
3647
};
48+
ChartMenuItem.displayName = 'ChartMenuItem';
49+
50+
interface ChartMenuTitleProps {
51+
isLoading: boolean;
52+
}
53+
export const ChartMenuTitle: FC<ChartMenuTitleProps> = memo(({ isLoading }) => {
54+
if (isLoading) return <span className="fa fa-spinner fa-pulse" />;
55+
return (
56+
<span>
57+
<span className="fa fa-area-chart" />
58+
<span> Charts</span>
59+
</span>
60+
);
61+
});
62+
ChartMenuTitle.displayName = 'ChartMenuTitle';
3763

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

52-
useEffect(
53-
() => {
54-
actions.loadCharts(model.id);
55-
},
56-
[
57-
/* on mount */
58-
]
59-
);
78+
useEffect(() => {
79+
actions.loadCharts(model.id);
80+
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- only desired on mount
6081

61-
const chartClicked = useCallback(
62-
(chart: DataViewInfo): void => {
63-
blurActiveElement();
64-
actions.selectReport(model.id, chart.reportId);
82+
const selectChart = useCallback(
83+
(reportId: string, selected: boolean): void => {
84+
actions.selectReport(model.id, reportId, selected);
6585
},
6686
[actions, model]
6787
);
6888

69-
if (noCharts && !showCreateChart) {
70-
return null;
71-
}
89+
if (noCharts && !showCreateChart) return null;
7290

7391
return (
7492
<div className="chart-menu">
7593
<DropdownButton
7694
buttonClassName="chart-menu-button"
7795
disabled={disabled}
7896
pullRight
79-
title={
80-
isLoadingCharts ? (
81-
<span className="fa fa-spinner fa-pulse" />
82-
) : (
83-
<span>
84-
<span className="fa fa-area-chart" />
85-
<span> Charts</span>
86-
</span>
87-
)
88-
}
97+
title={<ChartMenuTitle isLoading={isLoadingCharts} />}
8998
>
9099
{chartsError !== undefined && <MenuItem>{chartsError}</MenuItem>}
91100

92-
{showCreateChart && <ChartBuilderMenuItem actions={actions} model={model} />}
101+
{showCreateChart && (
102+
<ChartBuilderMenuItem
103+
actions={actions}
104+
disabledMessage={DISABLED_MESSAGE}
105+
maxCharts={MAX_CHARTS}
106+
model={model}
107+
selectedReportIds={selectedReportIds}
108+
/>
109+
)}
93110

94111
{showCreateChartDivider && <MenuDivider />}
95112

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

98115
{privateCharts.length > 0 &&
99116
privateCharts.map(chart => (
100-
<ChartMenuItem key={chart.reportId} chart={chart} showChart={chartClicked} />
117+
<ChartMenuItem
118+
chart={chart}
119+
key={chart.reportId}
120+
selectChart={selectChart}
121+
selectedReportIds={selectedReportIds}
122+
/>
101123
))}
102124

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

107129
{publicCharts.length > 0 &&
108130
publicCharts.map(chart => (
109-
<ChartMenuItem key={chart.reportId} chart={chart} showChart={chartClicked} />
131+
<ChartMenuItem
132+
chart={chart}
133+
key={chart.reportId}
134+
selectChart={selectChart}
135+
selectedReportIds={selectedReportIds}
136+
/>
110137
))}
111138
</DropdownButton>
112139
</div>
113140
);
114-
};
141+
});
142+
ChartMenu.displayName = 'ChartMenu';

0 commit comments

Comments
 (0)