From 8c627880bf0ea3a9375c4147d2c3607854bcaf84 Mon Sep 17 00:00:00 2001 From: alanv Date: Thu, 9 Oct 2025 13:22:25 -0500 Subject: [PATCH 01/10] QueryModel/withQueryModels: support multiple chart selections --- .../src/public/QueryModel/QueryModel.test.ts | 30 ++++++++++++------- .../src/public/QueryModel/QueryModel.ts | 20 ++++++------- .../src/public/QueryModel/testUtils.ts | 1 + .../src/public/QueryModel/withQueryModels.tsx | 26 ++++++++++++++-- 4 files changed, 53 insertions(+), 24 deletions(-) diff --git a/packages/components/src/public/QueryModel/QueryModel.test.ts b/packages/components/src/public/QueryModel/QueryModel.test.ts index 7af4f9a8b7..f4bc44eab5 100644 --- a/packages/components/src/public/QueryModel/QueryModel.test.ts +++ b/packages/components/src/public/QueryModel/QueryModel.test.ts @@ -438,7 +438,7 @@ describe('attributesForURLQueryParams', () => { maxRows: DEFAULT_MAX_ROWS, offset: DEFAULT_OFFSET, schemaQuery: SCHEMA_QUERY, - selectedReportId: undefined, + selectedReportIds: [], sorts: [], }; const model = new QueryModel({ @@ -477,12 +477,12 @@ describe('attributesForURLQueryParams', () => { // reportId should be honored searchParams = new URLSearchParams({ - 'query.reportId': 'db:99', + 'query.selectedReportIds': 'db:99', }); values = model.attributesForURLQueryParams(searchParams); expect(values).toEqual({ ...defaultExpected, - selectedReportId: 'db:99', + selectedReportIds: ['db:99'], }); // custom views should alter schemaQuery @@ -527,7 +527,7 @@ describe('attributesForURLQueryParams', () => { 'query.otherCol~neq=': '1', 'query.p': '3', 'query.pageSize': '100', - 'query.reportId': 'db:99', + 'query.selectedReportIds': 'db:99;db:100', 'query.sort': '-testCol,otherCol', 'query.view': 'custom view', }); @@ -537,7 +537,7 @@ describe('attributesForURLQueryParams', () => { maxRows: 100, offset: 200, schemaQuery: new SchemaQuery(SCHEMA_QUERY.schemaName, SCHEMA_QUERY.queryName, 'custom view'), - selectedReportId: 'db:99', + selectedReportIds: ['db:99', 'db:100'], sorts: expectedSorts, }); }); @@ -548,10 +548,10 @@ describe('attributesForURLQueryParams', () => { maxRows: 10, offset: 60, schemaQuery: new SchemaQuery(SCHEMA_QUERY.schemaName, SCHEMA_QUERY.queryName, 'existing custom view'), - selectedReportId: 'db:900', + selectedReportIds: ['db:900'], sorts: [new QuerySort({ dir: '-', fieldKey: 'existingCol' })], }; - const model = new QueryModel({ ...defaultExpected }).mutate({ selectedReportId: 'db:900' }); + const model = new QueryModel({ ...defaultExpected }).mutate({ selectedReportIds: ['db:900'] }); let searchParams = new URLSearchParams({}); let values = model.attributesForURLQueryParams(searchParams, true); @@ -582,12 +582,20 @@ describe('attributesForURLQueryParams', () => { // reportId should be honored searchParams = new URLSearchParams({ - 'query.reportId': 'db:99', + 'query.selectedReportIds': 'db:99', }); values = model.attributesForURLQueryParams(searchParams, true); expect(values).toEqual({ ...defaultExpected, - selectedReportId: 'db:99', + selectedReportIds: ['db:99'], + }); + searchParams = new URLSearchParams({ + 'query.selectedReportIds': 'db:99;db:100', + }); + values = model.attributesForURLQueryParams(searchParams, true); + expect(values).toEqual({ + ...defaultExpected, + selectedReportIds: ['db:99', 'db:100'], }); // custom views should alter schemaQuery @@ -632,7 +640,7 @@ describe('attributesForURLQueryParams', () => { 'query.otherCol~neq=': '1', 'query.p': '3', 'query.pageSize': '100', - 'query.reportId': 'db:99', + 'query.selectedReportIds': 'db:99', 'query.sort': '-testCol,otherCol', 'query.view': 'custom view', }); @@ -642,7 +650,7 @@ describe('attributesForURLQueryParams', () => { maxRows: 100, offset: 200, schemaQuery: new SchemaQuery(SCHEMA_QUERY.schemaName, SCHEMA_QUERY.queryName, 'custom view'), - selectedReportId: 'db:99', + selectedReportIds: ['db:99'], sorts: expectedSorts, }); }); diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index a72dc98839..9aef698efa 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -432,7 +432,7 @@ export class QueryModel { /** * ReportId, from the URL, to be used for showing a chart via the [[ChartMenu]]. */ - readonly selectedReportId: string; + readonly selectedReportIds: string[]; /** * [[SelectionPivot]] object that describes the current selection pivot row for shift-select behavior. */ @@ -527,7 +527,7 @@ export class QueryModel { this.rows = undefined; this.rowCount = undefined; this.rowsLoadingState = LoadingState.INITIALIZED; - this.selectedReportId = undefined; + this.selectedReportIds = []; this.selectionPivot = undefined; this.selections = undefined; this.selectionsError = undefined; @@ -814,7 +814,7 @@ export class QueryModel { * true. */ get urlQueryParams(): Record { - const { currentPage, urlPrefix, filterArray, maxRows, selectedReportId, sorts, viewName } = this; + const { currentPage, urlPrefix, filterArray, maxRows, selectedReportIds, sorts, viewName } = this; const filters = filterArray.filter(f => f.getColumnName() !== '*'); const searches = filterArray .filter(f => f.getColumnName() === '*') @@ -843,8 +843,8 @@ export class QueryModel { modelParams[`${urlPrefix}.q`] = searches; } - if (selectedReportId) { - modelParams[`${urlPrefix}.reportId`] = selectedReportId; + if (selectedReportIds.length > 0) { + modelParams[`${urlPrefix}.selectedReportIds`] = selectedReportIds.join(';'); } filters.forEach((filter): void => { @@ -1215,7 +1215,7 @@ export class QueryModel { if (isNaN(maxRows)) maxRows = DEFAULT_MAX_ROWS; let offset = offsetFromString(maxRows, searchParams.get(`${prefix}.p`)) ?? DEFAULT_OFFSET; let schemaQuery = new SchemaQuery(this.schemaName, this.queryName, viewName); - let selectedReportId = searchParams.get(`${prefix}.reportId`) ?? undefined; + let selectedReportIds = searchParams.get(`${prefix}.selectedReportIds`)?.split(';') ?? []; let sorts = querySortsFromString(searchParams.get(`${prefix}.sort`)) ?? []; // If useExistingValues is true we'll assume any value not present on the URL can be overridden by the current @@ -1237,8 +1237,8 @@ export class QueryModel { schemaQuery = this.schemaQuery; } - if (selectedReportId === undefined && this.selectedReportId) { - selectedReportId = this.selectedReportId; + if (selectedReportIds.length === 0 && this.selectedReportIds.length > 0) { + selectedReportIds = this.selectedReportIds; } if (sorts.length === 0 && this.sorts.length > 0) { @@ -1246,7 +1246,7 @@ export class QueryModel { } } - return { filterArray, maxRows, offset, schemaQuery, selectedReportId, sorts }; + return { filterArray, maxRows, offset, schemaQuery, selectedReportIds, sorts }; } /** @@ -1263,7 +1263,7 @@ export class QueryModel { type QueryModelURLState = Pick< QueryModel, - 'filterArray' | 'maxRows' | 'offset' | 'schemaQuery' | 'selectedReportId' | 'sorts' + 'filterArray' | 'maxRows' | 'offset' | 'schemaQuery' | 'selectedReportIds' | 'sorts' >; type QueryModelSettings = Partial>; const LOCAL_STORAGE_PREFIX = 'QUERY_MODEL_SETTINGS'; diff --git a/packages/components/src/public/QueryModel/testUtils.ts b/packages/components/src/public/QueryModel/testUtils.ts index 77e578f6bf..ee8ed141a1 100644 --- a/packages/components/src/public/QueryModel/testUtils.ts +++ b/packages/components/src/public/QueryModel/testUtils.ts @@ -61,6 +61,7 @@ export const makeTestActions = (mockFn = (): any => () => {}, overrides: Partial const defaultActions: Actions = { addModel: mockFn(), addMessage: mockFn(), + clearSelectedReportIds: mockFn(), clearSelections: mockFn(), loadModel: mockFn(), loadAllModels: mockFn(), diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index 5dde0e0a45..4668919f6e 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -110,6 +110,7 @@ export type ModelChange = AddChange | DeleteChange | UpdateChange; export interface Actions { addMessage: (id: string, message: GridMessage, duration?: number) => void; addModel: (queryConfig: QueryConfig, load?: boolean, loadSelections?: boolean) => void; + clearSelectedReportIds: (id: string) => void; clearSelections: (id: string) => void; loadAllModels: (loadSelections?: boolean, reloadTotalCount?: boolean) => void; loadCharts: (id: string) => void; @@ -124,7 +125,7 @@ export interface Actions { resetTotalCountState: () => void; selectAllRows: (id: string) => void; selectPage: (id: string, checked: boolean) => void; - selectReport: (id: string, reportId: string) => void; + selectReport: (id: string, reportId: string, selected: boolean) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any selectRow: (id: string, checked: boolean, row: Record, useSelectionPivot?: boolean) => void; setFilters: (id: string, filters: Filter.IFilter[], loadSelections?: boolean) => void; @@ -318,6 +319,7 @@ export function withQueryModels( this.actions = { addModel: this.addModel, addMessage: this.addMessage, + clearSelectedReportIds: this.clearSelectedReportIds, clearSelections: this.clearSelections, loadModel: this.loadModel, loadAllModels: this.loadAllModels, @@ -685,11 +687,29 @@ export function withQueryModels( this.setSelections(id, checked, this.state.queryModels[id].orderedRows); }; - selectReport = (id: string, reportId: string): void => { + selectReport = (id: string, reportId: string, selected: boolean): void => { this.setState( produce((draft: WritableDraft) => { const model = draft.queryModels[id]; - model.selectedReportId = reportId; + if (selected && !model.selectedReportIds.includes(reportId)) { + model.selectedReportIds.push(reportId); + } else if (!selected) { + model.selectedReportIds = model.selectedReportIds.filter(id => id !== reportId); + } + }), + () => { + if (this.state.queryModels[id].bindURL) { + this.bindURL(id); + } + } + ); + }; + + clearSelectedReportIds = (id: string): void => { + this.setState( + produce((draft: WritableDraft) => { + const model = draft.queryModels[id]; + model.selectedReportIds = []; }), () => { if (this.state.queryModels[id].bindURL) { From 4b5eed78184c25d1a7fe1bb5980986bafd80e4aa Mon Sep 17 00:00:00 2001 From: alanv Date: Thu, 9 Oct 2025 13:26:38 -0500 Subject: [PATCH 02/10] Render multiple charts --- .../components/releaseNotes/components.md | 7 ++ .../components/chart/ChartBuilderModal.tsx | 12 +-- .../src/public/QueryModel/ChartMenu.test.tsx | 23 +++- .../src/public/QueryModel/ChartMenu.tsx | 102 +++++++++++------- .../src/public/QueryModel/ChartPanel.tsx | 63 +++++++---- .../src/public/QueryModel/GridPanel.tsx | 8 +- .../src/public/QueryModel/testUtils.ts | 2 +- .../src/public/QueryModel/withQueryModels.tsx | 6 +- packages/components/src/theme/charts.scss | 14 ++- 9 files changed, 152 insertions(+), 85 deletions(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 7fb9a9679d..3f4a438cc7 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,13 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 6.xx.x +*Released*: ? 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 diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 199e6d3f0e..72eef25b64 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -774,14 +774,10 @@ export const ChartBuilderModal: FC = memo(({ actions, mo setSaving(true); setError(undefined); try { - const response = await saveChart(_reportConfig); + 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); } catch (e) { setError(e.exception ?? e); setSaving(false); @@ -790,8 +786,8 @@ export const ChartBuilderModal: FC = 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(() => { diff --git a/packages/components/src/public/QueryModel/ChartMenu.test.tsx b/packages/components/src/public/QueryModel/ChartMenu.test.tsx index cd3f0fa99f..439bc027ba 100644 --- a/packages/components/src/public/QueryModel/ChartMenu.test.tsx +++ b/packages/components/src/public/QueryModel/ChartMenu.test.tsx @@ -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: { @@ -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(); + render(); 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(); + render(); 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(); + + expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-square-o'); + await userEvent.click(document.querySelector('a')); + expect(selectChart).toHaveBeenCalledWith('db:12', true); + + rerender(); + + expect(document.querySelector('.chart-menu-checkbox')).toHaveClass('fa-check-square'); + await userEvent.click(document.querySelector('a')); + expect(selectChart).toHaveBeenCalledWith('db:12', false); }); }); diff --git a/packages/components/src/public/QueryModel/ChartMenu.tsx b/packages/components/src/public/QueryModel/ChartMenu.tsx index e75ca42d7b..dba2d07ae0 100644 --- a/packages/components/src/public/QueryModel/ChartMenu.tsx +++ b/packages/components/src/public/QueryModel/ChartMenu.tsx @@ -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'; @@ -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 = ({ chart, showChart }) => { - const onClick = useCallback(() => showChart(chart), [showChart, chart]); +export const ChartMenuItem: FC = ({ 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 ( - - {useSVG && {chart.icon}} + + + {useSVG && {chart.icon}} {!useSVG && } {chart.name} - + ); }; +ChartMenuItem.displayName = 'ChartMenuItem'; + +interface ChartMenuTitleProps { + isLoading: boolean; +} +export const ChartMenuTitle: FC = memo(({ isLoading }) => { + if (isLoading) return ; + return ( + + + Charts + + ); +}); +ChartMenuTitle.displayName = 'ChartMenuTitle'; -export const ChartMenu: FC = props => { - const { model, actions } = props; +export const ChartMenu: FC = 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) : []; @@ -49,26 +75,18 @@ export const ChartMenu: FC = 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 (
@@ -76,16 +94,7 @@ export const ChartMenu: FC = props => { buttonClassName="chart-menu-button" disabled={disabled} pullRight - title={ - isLoadingCharts ? ( - - ) : ( - - - Charts - - ) - } + title={} > {chartsError !== undefined && {chartsError}} @@ -97,7 +106,12 @@ export const ChartMenu: FC = props => { {privateCharts.length > 0 && privateCharts.map(chart => ( - + ))} {privateCharts.length > 0 && publicCharts.length > 0 && } @@ -106,9 +120,15 @@ export const ChartMenu: FC = props => { {publicCharts.length > 0 && publicCharts.map(chart => ( - + ))}
); -}; +}); +ChartMenu.displayName = 'ChartMenu'; diff --git a/packages/components/src/public/QueryModel/ChartPanel.tsx b/packages/components/src/public/QueryModel/ChartPanel.tsx index 3a0d46b1ae..0dad77c91e 100644 --- a/packages/components/src/public/QueryModel/ChartPanel.tsx +++ b/packages/components/src/public/QueryModel/ChartPanel.tsx @@ -17,10 +17,11 @@ import { RequiresModelAndActions } from './withQueryModels'; interface Props extends RequiresModelAndActions { api?: ChartAPIWrapper; + reportId: string; // Maybe pass the chart } -export const ChartPanel: FC = memo(({ actions, model, api = DEFAULT_API_WRAPPER }) => { - const { charts, containerPath, id, queryInfo, selectedReportId } = model; +export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, model, reportId }) => { + const { charts, containerPath, id } = model; const [savedChartModel, setSavedChartModel] = useState(undefined); const [showEditModal, setShowEditModal] = useState(false); const { moduleContext } = useServerContext(); @@ -28,32 +29,32 @@ export const ChartPanel: FC = memo(({ actions, model, api = DEFAULT_API_W // useNotificationsContext will not always be available depending on if the app wraps the NotificationsContext.Provider let _createNotification; try { + // ESLint incorrectly complains that useNotificationsContext is called conditionally. Itt's always called, even + // though it's in a try/catch. + // eslint-disable-next-line react-hooks/rules-of-hooks _createNotification = useNotificationsContext().createNotification; - } catch (e) { + } catch { // this is expected for LKS usages, so don't throw or console.error } - const selectedChart = useMemo( - () => charts?.find(chart => chart.reportId === selectedReportId), - [selectedReportId, charts] - ); + const chart = useMemo(() => charts?.find(chart => chart.reportId === reportId), [charts, reportId]); useEffect(() => { (async () => { setSavedChartModel(undefined); // only allowing edit of generic charts in the apps at this time - if (selectedChart && GENERIC_CHART_REPORTS.indexOf(selectedChart.type) > -1) { + if (chart && GENERIC_CHART_REPORTS.indexOf(chart.type) > -1) { try { - const savedChartModel_ = await api.fetchGenericChart(selectedChart.reportId); + const savedChartModel_ = await api.fetchGenericChart(chart.reportId); setSavedChartModel(savedChartModel_); } catch (e) { // no-op as we are only using this to determine if we can edit the chart } } })(); - }, [api, selectedChart]); + }, [api, chart]); - const clearChart = useCallback(() => actions.selectReport(id, undefined), [actions, id]); + const clearChart = useCallback(() => actions.selectReport(id, reportId, false), [actions, id, reportId]); const onShowEditChart = useCallback(() => { setShowEditModal(true); @@ -63,10 +64,10 @@ export const ChartPanel: FC = memo(({ actions, model, api = DEFAULT_API_W (type: string) => { const svg = document.querySelector('.chart-panel svg'); if (svg) { - LABKEY_VIS.SVGConverter.convert(svg, type, selectedChart.name); + LABKEY_VIS.SVGConverter.convert(svg, type, chart.name); } }, - [selectedChart] + [chart] ); const onExportChartPDF = useCallback(() => { @@ -87,24 +88,21 @@ export const ChartPanel: FC = memo(({ actions, model, api = DEFAULT_API_W [_createNotification] ); - // If we don't have a queryInfo we can't get filters off the model, so we can't render the chart - const showChart = queryInfo !== undefined && selectedChart !== undefined; - - if (!showChart) return null; + if (chart === undefined) return null; return (
- {selectedChart.name} + {chart.name} {savedChartModel?.canEdit && isChartBuilderEnabled(moduleContext) && ( @@ -113,8 +111,8 @@ export const ChartPanel: FC = memo(({ actions, model, api = DEFAULT_API_W } noCaret + title={} > @@ -130,17 +128,19 @@ export const ChartPanel: FC = memo(({ actions, model, api = DEFAULT_API_W
-
+ {/* Note: we use chart.modified as the key here so the chart reloads when the user edits the chart */} @@ -155,3 +155,20 @@ export const ChartPanel: FC = memo(({ actions, model, api = DEFAULT_API_W
); }); +ChartPanel.displayName = 'ChartPanel'; + +export const ChartList: FC = memo(({ actions, model }) => { + const { queryInfo, selectedReportIds } = model; + + // If we don't have a queryInfo we can't get filters off the model, so we can't render any charts + if (queryInfo === undefined || selectedReportIds.length === 0) return null; + + return ( +
+ {model.selectedReportIds.map(reportId => ( + + ))} +
+ ); +}); +ChartList.displayName = 'ChartList'; diff --git a/packages/components/src/public/QueryModel/GridPanel.tsx b/packages/components/src/public/QueryModel/GridPanel.tsx index ac80e6f2a1..57745ea7d6 100644 --- a/packages/components/src/public/QueryModel/GridPanel.tsx +++ b/packages/components/src/public/QueryModel/GridPanel.tsx @@ -74,7 +74,7 @@ import { SaveViewModal } from './SaveViewModal'; import { CustomizeGridViewModal } from './CustomizeGridViewModal'; import { ManageViewsModal } from './ManageViewsModal'; import { Actions, InjectedQueryModels, RequiresModelAndActions, withQueryModels } from './withQueryModels'; -import { ChartPanel } from './ChartPanel'; +import { ChartList, ChartPanel } from './ChartPanel'; export interface GridPanelProps { advancedExportOptions?: Record; @@ -947,8 +947,8 @@ export class GridPanel extends PureComponent, State> { } // since the grid ChartMenu filters to charts for a given view, when the view changes clear the selectedReportId - if (model.selectedReportId) { - actions.selectReport(model.id, undefined); + if (model.selectedReportIds.length > 0) { + actions.clearSelectedReports(model.id); } }; @@ -1133,7 +1133,7 @@ export class GridPanel extends PureComponent, State> {
- {!gridIsLoading && } + {!gridIsLoading && } {showButtonBar && ( () => {}, overrides: Partial const defaultActions: Actions = { addModel: mockFn(), addMessage: mockFn(), - clearSelectedReportIds: mockFn(), + clearSelectedReports: mockFn(), clearSelections: mockFn(), loadModel: mockFn(), loadAllModels: mockFn(), diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index 4668919f6e..8799b32682 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -110,7 +110,7 @@ export type ModelChange = AddChange | DeleteChange | UpdateChange; export interface Actions { addMessage: (id: string, message: GridMessage, duration?: number) => void; addModel: (queryConfig: QueryConfig, load?: boolean, loadSelections?: boolean) => void; - clearSelectedReportIds: (id: string) => void; + clearSelectedReports: (id: string) => void; clearSelections: (id: string) => void; loadAllModels: (loadSelections?: boolean, reloadTotalCount?: boolean) => void; loadCharts: (id: string) => void; @@ -319,7 +319,7 @@ export function withQueryModels( this.actions = { addModel: this.addModel, addMessage: this.addMessage, - clearSelectedReportIds: this.clearSelectedReportIds, + clearSelectedReports: this.clearSelectedReports, clearSelections: this.clearSelections, loadModel: this.loadModel, loadAllModels: this.loadAllModels, @@ -705,7 +705,7 @@ export function withQueryModels( ); }; - clearSelectedReportIds = (id: string): void => { + clearSelectedReports = (id: string): void => { this.setState( produce((draft: WritableDraft) => { const model = draft.queryModels[id]; diff --git a/packages/components/src/theme/charts.scss b/packages/components/src/theme/charts.scss index a97d61314e..e9a4965ac7 100644 --- a/packages/components/src/theme/charts.scss +++ b/packages/components/src/theme/charts.scss @@ -118,6 +118,17 @@ .report-list__chart-preview .chart-body { min-height: 300px; } +.chart-menu-checkbox { + margin-right: 8px; +} +.chart-menu-checkbox.fa-check-square { + color: $grid-action-item-color; +} +.chart-list { + border: 1px solid #ddd; + border-radius: 8px; + margin-bottom: 16px; +} .chart-panel__heading { display: flex; } @@ -127,10 +138,7 @@ vertical-align: middle; } .chart-panel { - margin-bottom: 16px; min-height: 100px; - border: 1px solid #ddd; - border-radius: 8px; padding: 15px; } .chart-panel__hide-icon { From b556fb2e3256fead8a326c9a1b845a77fb554c28 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 13 Oct 2025 12:14:15 -0500 Subject: [PATCH 03/10] locationHasQueryParamSettings: Fix report ID detection --- .../components/src/public/QueryModel/QueryModel.test.ts | 7 +++++-- packages/components/src/public/QueryModel/QueryModel.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/components/src/public/QueryModel/QueryModel.test.ts b/packages/components/src/public/QueryModel/QueryModel.test.ts index f4bc44eab5..e6c827646b 100644 --- a/packages/components/src/public/QueryModel/QueryModel.test.ts +++ b/packages/components/src/public/QueryModel/QueryModel.test.ts @@ -404,7 +404,8 @@ describe('locationHasQueryParamSettings', () => { }); test('with matching queryParams', () => { - expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.reportId': '1' }))).toBe(true); + expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.selectedReportIds': '1' }))).toBe(true); + expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.selectedReportIds': '1;2' }))).toBe(true); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.view': '1' }))).toBe(true); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.q': '1' }))).toBe(true); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.sort': '1' }))).toBe(true); @@ -414,7 +415,7 @@ describe('locationHasQueryParamSettings', () => { }); test('with mismatched prefix', () => { - expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.reportId': '1' }))).toBe(false); + expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.selectedReportIds': '1' }))).toBe(false); expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.view': '1' }))).toBe(false); expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.q': '1' }))).toBe(false); expect(locationHasQueryParamSettings('bogus', new URLSearchParams({ 'test.sort': '1' }))).toBe(false); @@ -426,6 +427,8 @@ describe('locationHasQueryParamSettings', () => { test('with mismatched queryParams', () => { expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.reportid': '1' }))).toBe(false); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.reportIdd': '1' }))).toBe(false); + expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.selectedreportids': '1' }))).toBe(false); + expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.selectedReportIdss': '1' }))).toBe(false); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.bogus': '1' }))).toBe(false); expect(locationHasQueryParamSettings('test', new URLSearchParams({ 'test.col~eq': '1' }))).toBe(true); }); diff --git a/packages/components/src/public/QueryModel/QueryModel.ts b/packages/components/src/public/QueryModel/QueryModel.ts index 9aef698efa..f324b8e915 100644 --- a/packages/components/src/public/QueryModel/QueryModel.ts +++ b/packages/components/src/public/QueryModel/QueryModel.ts @@ -83,8 +83,8 @@ function searchFiltersFromString(searchStr: string): Filter.IFilter[] { */ export function locationHasQueryParamSettings(prefix: string, searchParams?: URLSearchParams): boolean { if (searchParams === undefined) return false; - // Report - if (searchParams.get(`${prefix}.reportId`) !== null) return true; + // Reports + if (searchParams.get(`${prefix}.selectedReportIds`) !== null) return true; // View if (searchParams.get(`${prefix}.view`) !== null) return true; // Search Filters From 7013346b442fcfa512e687269f0b5df4db67522d Mon Sep 17 00:00:00 2001 From: alanv Date: Wed, 15 Oct 2025 16:14:48 -0500 Subject: [PATCH 04/10] Address PR feedback --- .../src/internal/components/chart/ChartBuilderModal.tsx | 3 ++- packages/components/src/public/QueryModel/ChartMenu.tsx | 2 +- packages/components/src/public/QueryModel/ChartPanel.tsx | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 72eef25b64..1201d2dba7 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -774,10 +774,11 @@ export const ChartBuilderModal: FC = memo(({ actions, mo setSaving(true); setError(undefined); try { - await saveChart(_reportConfig); + const response = await saveChart(_reportConfig); setSaving(false); onHide(`Successfully ${savedChartModel ? 'updated' : 'created'} chart: ${_reportConfig.name}.`); actions.loadCharts(model.id); + actions.selectReport(model.id, response.reportId, true); } catch (e) { setError(e.exception ?? e); setSaving(false); diff --git a/packages/components/src/public/QueryModel/ChartMenu.tsx b/packages/components/src/public/QueryModel/ChartMenu.tsx index dba2d07ae0..0fd1c2665c 100644 --- a/packages/components/src/public/QueryModel/ChartMenu.tsx +++ b/packages/components/src/public/QueryModel/ChartMenu.tsx @@ -17,7 +17,7 @@ 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`; +const DISABLED_MESSAGE = `Only ${MAX_CHARTS} charts can be shown at once.`; interface ChartMenuItemProps { chart: DataViewInfo; diff --git a/packages/components/src/public/QueryModel/ChartPanel.tsx b/packages/components/src/public/QueryModel/ChartPanel.tsx index 0dad77c91e..533c634393 100644 --- a/packages/components/src/public/QueryModel/ChartPanel.tsx +++ b/packages/components/src/public/QueryModel/ChartPanel.tsx @@ -29,7 +29,7 @@ export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, // useNotificationsContext will not always be available depending on if the app wraps the NotificationsContext.Provider let _createNotification; try { - // ESLint incorrectly complains that useNotificationsContext is called conditionally. Itt's always called, even + // ESLint incorrectly complains that useNotificationsContext is called conditionally. It's always called, even // though it's in a try/catch. // eslint-disable-next-line react-hooks/rules-of-hooks _createNotification = useNotificationsContext().createNotification; From e631272c6d1aec66167f35331b9ffd15d069cf35 Mon Sep 17 00:00:00 2001 From: alanv Date: Wed, 15 Oct 2025 16:29:32 -0500 Subject: [PATCH 05/10] Fix issue with SVG export always targeting the first svg --- packages/components/src/public/QueryModel/ChartPanel.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/components/src/public/QueryModel/ChartPanel.tsx b/packages/components/src/public/QueryModel/ChartPanel.tsx index 533c634393..723473d4cc 100644 --- a/packages/components/src/public/QueryModel/ChartPanel.tsx +++ b/packages/components/src/public/QueryModel/ChartPanel.tsx @@ -1,4 +1,4 @@ -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Chart } from '../../internal/components/chart/Chart'; @@ -25,6 +25,7 @@ export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, const [savedChartModel, setSavedChartModel] = useState(undefined); const [showEditModal, setShowEditModal] = useState(false); const { moduleContext } = useServerContext(); + const divRef = useRef(undefined); // useNotificationsContext will not always be available depending on if the app wraps the NotificationsContext.Provider let _createNotification; @@ -62,7 +63,7 @@ export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, const onExportChart = useCallback( (type: string) => { - const svg = document.querySelector('.chart-panel svg'); + const svg = divRef.current.querySelector('.chart-panel svg'); if (svg) { LABKEY_VIS.SVGConverter.convert(svg, type, chart.name); } @@ -91,7 +92,7 @@ export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, if (chart === undefined) return null; return ( -
+
{chart.name} From 7ce68255097c52ec8631219161e6895d1f1ebfb7 Mon Sep 17 00:00:00 2001 From: alanv Date: Wed, 15 Oct 2025 16:32:26 -0500 Subject: [PATCH 06/10] ChartPanel: only enable export for SVG charts --- .../src/public/QueryModel/ChartPanel.tsx | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/components/src/public/QueryModel/ChartPanel.tsx b/packages/components/src/public/QueryModel/ChartPanel.tsx index 723473d4cc..498e39d33a 100644 --- a/packages/components/src/public/QueryModel/ChartPanel.tsx +++ b/packages/components/src/public/QueryModel/ChartPanel.tsx @@ -2,7 +2,7 @@ import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } fr import { Chart } from '../../internal/components/chart/Chart'; -import { GENERIC_CHART_REPORTS, LABKEY_VIS } from '../../internal/constants'; +import { DataViewInfoTypes, GENERIC_CHART_REPORTS, LABKEY_VIS } from '../../internal/constants'; import { ChartAPIWrapper, DEFAULT_API_WRAPPER } from '../../internal/components/chart/api'; import { GenericChartModel } from '../../internal/components/chart/models'; import { ChartBuilderModal } from '../../internal/components/chart/ChartBuilderModal'; @@ -91,6 +91,8 @@ export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, if (chart === undefined) return null; + const isSvg = chart.type !== DataViewInfoTypes.RReport; + return (
@@ -109,23 +111,25 @@ export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, )} - - } - > - - - -   PDF - - - -   PNG - - - + {isSvg && ( + + } + > + + + +   PDF + + + +   PNG + + + + )}
From 810b74a1da009c59c9f9afe6c143553e328605e6 Mon Sep 17 00:00:00 2001 From: alanv Date: Thu, 16 Oct 2025 12:29:26 -0500 Subject: [PATCH 07/10] ChartMenu: Don't let user create more charts if max charts are selected --- .../components/chart/ChartBuilderMenuItem.tsx | 16 ++++++++++++---- .../src/public/QueryModel/ChartMenu.tsx | 10 +++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx b/packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx index cdae890589..c86e7d7ef1 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderMenuItem.tsx @@ -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 = memo(({ actions, model }) => { +interface Props extends RequiresModelAndActions { + disabledMessage: string; + maxCharts: number; + selectedReportIds: string[]; +} + +export const ChartBuilderMenuItem: FC = memo(props => { + const { actions, disabledMessage, maxCharts, model, selectedReportIds } = props; const [showModal, setShowModal] = useState(false); const { createNotification } = useNotificationsContext(); @@ -24,13 +31,14 @@ export const ChartBuilderMenuItem: FC = memo(({ actions }, [createNotification] ); + const disabled = selectedReportIds.length >= maxCharts; return ( <> - + Create Chart - + {showModal && } ); diff --git a/packages/components/src/public/QueryModel/ChartMenu.tsx b/packages/components/src/public/QueryModel/ChartMenu.tsx index 0fd1c2665c..10cea491a9 100644 --- a/packages/components/src/public/QueryModel/ChartMenu.tsx +++ b/packages/components/src/public/QueryModel/ChartMenu.tsx @@ -98,7 +98,15 @@ export const ChartMenu: FC = memo(({ actions, model }) > {chartsError !== undefined && {chartsError}} - {showCreateChart && } + {showCreateChart && ( + + )} {showCreateChartDivider && } From ec004e62fdd6c68be5917a3fd5d10244ea07d951 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Oct 2025 15:25:24 -0500 Subject: [PATCH 08/10] Prep for release --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- packages/components/releaseNotes/components.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 9dcfae3c08..ab40298ba9 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.64.3", + "version": "6.65.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.64.3", + "version": "6.65.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 d843b88f24..625513d8ca 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -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": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 3f4a438cc7..7ef97bf15f 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,7 +1,7 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages -### version 6.xx.x +### version 6.65.0 *Released*: ? October 2025 - QueryModel: remove selectedReportId, add selectedReportIds - withQueryModels: update selectReport, add clearSelectedReports From c8d349699726d3f719751ddf4a5c067ef8e8426c Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Oct 2025 15:26:12 -0500 Subject: [PATCH 09/10] Fix date --- packages/components/releaseNotes/components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 7ef97bf15f..ac10cb6e38 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -2,7 +2,7 @@ Components, models, actions, and utility functions for LabKey applications and pages ### version 6.65.0 -*Released*: ? October 2025 +*Released*: 20 October 2025 - QueryModel: remove selectedReportId, add selectedReportIds - withQueryModels: update selectReport, add clearSelectedReports - ChartMenu: support multiple report selections From 75d7885714efbb1047c4a30e07e80632a66e3abc Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 20 Oct 2025 15:37:21 -0500 Subject: [PATCH 10/10] Fix ChartBuilderMenuItem tests --- .../chart/ChartBuilderMenuItem.test.tsx | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/components/src/internal/components/chart/ChartBuilderMenuItem.test.tsx b/packages/components/src/internal/components/chart/ChartBuilderMenuItem.test.tsx index 509e093f05..42b7f95f7b 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderMenuItem.test.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderMenuItem.test.tsx @@ -46,11 +46,20 @@ describe('ChartBuilderMenuItem', () => { }; test('default props', async () => { - renderWithAppContext(, { - serverContext: { - user: TEST_USER_EDITOR, - }, - }); + renderWithAppContext( + , + { + 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'); @@ -59,4 +68,28 @@ describe('ChartBuilderMenuItem', () => { await userEvent.click(menuItems[0]); expect(document.querySelectorAll('.chart-builder-modal')).toHaveLength(1); }); + + test('max charts', async () => { + renderWithAppContext( + , + { + 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); + }); });