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 7fb9a9679d..ac10cb6e38 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.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 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); + }); }); 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/internal/components/chart/ChartBuilderModal.tsx b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx index 199e6d3f0e..1201d2dba7 100644 --- a/packages/components/src/internal/components/chart/ChartBuilderModal.tsx +++ b/packages/components/src/internal/components/chart/ChartBuilderModal.tsx @@ -777,11 +777,8 @@ export const ChartBuilderModal: FC = 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); @@ -790,8 +787,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..10cea491a9 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,20 +94,19 @@ export const ChartMenu: FC = props => { buttonClassName="chart-menu-button" disabled={disabled} pullRight - title={ - isLoadingCharts ? ( - - ) : ( - - - Charts - - ) - } + title={} > {chartsError !== undefined && {chartsError}} - {showCreateChart && } + {showCreateChart && ( + + )} {showCreateChartDivider && } @@ -97,7 +114,12 @@ export const ChartMenu: FC = props => { {privateCharts.length > 0 && privateCharts.map(chart => ( - + ))} {privateCharts.length > 0 && publicCharts.length > 0 && } @@ -106,9 +128,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..498e39d33a 100644 --- a/packages/components/src/public/QueryModel/ChartPanel.tsx +++ b/packages/components/src/public/QueryModel/ChartPanel.tsx @@ -1,8 +1,8 @@ -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'; -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'; @@ -17,43 +17,45 @@ 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(); + const divRef = useRef(undefined); // 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. It'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); @@ -61,12 +63,12 @@ export const ChartPanel: FC = memo(({ actions, model, api = DEFAULT_API_W 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, selectedChart.name); + LABKEY_VIS.SVGConverter.convert(svg, type, chart.name); } }, - [selectedChart] + [chart] ); const onExportChartPDF = useCallback(() => { @@ -87,60 +89,63 @@ 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 (chart === undefined) return null; - if (!showChart) return null; + const isSvg = chart.type !== DataViewInfoTypes.RReport; return ( -
+
- {selectedChart.name} + {chart.name} {savedChartModel?.canEdit && isChartBuilderEnabled(moduleContext) && ( )} - - } - noCaret - > - - - -   PDF - - - -   PNG - - - + {isSvg && ( + + } + > + + + +   PDF + + + +   PNG + + + + )}
-
+ {/* Note: we use chart.modified as the key here so the chart reloads when the user edits the chart */} @@ -155,3 +160,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 && ( { }); 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); }); @@ -438,7 +441,7 @@ describe('attributesForURLQueryParams', () => { maxRows: DEFAULT_MAX_ROWS, offset: DEFAULT_OFFSET, schemaQuery: SCHEMA_QUERY, - selectedReportId: undefined, + selectedReportIds: [], sorts: [], }; const model = new QueryModel({ @@ -477,12 +480,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 +530,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 +540,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 +551,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 +585,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 +643,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 +653,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..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 @@ -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..d4fefcbc26 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(), + 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 5dde0e0a45..8799b32682 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; + clearSelectedReports: (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, + clearSelectedReports: this.clearSelectedReports, 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); + } + } + ); + }; + + clearSelectedReports = (id: string): void => { + this.setState( + produce((draft: WritableDraft) => { + const model = draft.queryModels[id]; + model.selectedReportIds = []; }), () => { if (this.state.queryModels[id].bindURL) { 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 {