diff --git a/web/src/api/buster_rest/dashboards/helpers/addAndRemoveMetricsToDashboard.test.ts b/web/src/api/buster_rest/dashboards/helpers/addAndRemoveMetricsToDashboard.test.ts new file mode 100644 index 000000000..e0cb3a31b --- /dev/null +++ b/web/src/api/buster_rest/dashboards/helpers/addAndRemoveMetricsToDashboard.test.ts @@ -0,0 +1,118 @@ +import { addAndRemoveMetricsToDashboard } from './addAndRemoveMetricsToDashboard'; +import type { BusterDashboard } from '@/api/asset_interfaces/dashboard'; + +describe('addAndRemoveMetricsToDashboard', () => { + const createMockConfig = (metricIds: string[]): BusterDashboard['config'] => ({ + rows: [ + { + id: 'row-1', + items: metricIds.map((id) => ({ id })), + columnSizes: Array(metricIds.length).fill(12 / metricIds.length), + rowHeight: 320 + } + ] + }); + + it('should return existing config when no changes are needed', () => { + const existingConfig = createMockConfig(['metric-1', 'metric-2']); + const result = addAndRemoveMetricsToDashboard(['metric-1', 'metric-2'], existingConfig); + expect(result).toEqual(existingConfig); + // Verify order is maintained + expect(result.rows?.[0].items.map((item) => item.id)).toEqual(['metric-1', 'metric-2']); + }); + + it('should add new metrics when they dont exist in the dashboard', () => { + const existingConfig = createMockConfig(['metric-1']); + const result = addAndRemoveMetricsToDashboard( + ['metric-1', 'metric-2', 'metric-3'], + existingConfig + ); + + // Verify metrics were added in the correct order + const resultMetricIds = result.rows?.[0].items.map((item) => item.id); + expect(result.rows?.[0].items.map((item) => item.id)).toEqual(['metric-1']); + expect(result.rows?.[1].items.map((item) => item.id)).toEqual(['metric-2', 'metric-3']); + }); + + it('should remove metrics that are not in the provided array while maintaining order', () => { + const existingConfig = createMockConfig(['metric-1', 'metric-2', 'metric-3']); + const result = addAndRemoveMetricsToDashboard(['metric-1', 'metric-3'], existingConfig); + + // Verify metric was removed while maintaining order of remaining metrics + const resultMetricIds = result.rows?.[0].items.map((item) => item.id); + expect(resultMetricIds).toEqual(['metric-1', 'metric-3']); + expect(resultMetricIds?.length).toBe(2); + }); + + it('should handle both adding and removing metrics simultaneously while maintaining order', () => { + const existingConfig = createMockConfig(['metric-1', 'metric-2', 'metric-3']); + const result = addAndRemoveMetricsToDashboard( + ['metric-1', 'metric-4', 'metric-5'], + existingConfig + ); + + // Verify correct metrics were added and removed in the right order + expect(result.rows?.[0].items.map((item) => item.id)).toEqual(['metric-1']); // Order should match input array + expect(result.rows?.[1].items.map((item) => item.id)).toEqual(['metric-4', 'metric-5']); // Order should match input array + }); + + it('should handle empty input array by removing all metrics', () => { + const existingConfig = createMockConfig(['metric-1', 'metric-2']); + const result = addAndRemoveMetricsToDashboard([], existingConfig); + + // Verify all metrics were removed + expect(result.rows?.length).toBe(0); + }); + + it('should handle empty existing config by adding all metrics in order', () => { + const emptyConfig: BusterDashboard['config'] = { rows: [] }; + const result = addAndRemoveMetricsToDashboard(['metric-1', 'metric-2'], emptyConfig); + + // Verify all metrics were added in the correct order + const resultMetricIds = result.rows?.[0].items.map((item) => item.id); + expect(resultMetricIds).toEqual(['metric-1', 'metric-2']); + }); + + it('should maintain correct column sizes and order when removing metrics', () => { + const existingConfig = createMockConfig(['metric-1', 'metric-2', 'metric-3']); + const result = addAndRemoveMetricsToDashboard(['metric-1', 'metric-3'], existingConfig); + + // Verify column sizes are updated correctly while maintaining order + expect(result.rows?.[0].columnSizes).toEqual([6, 6]); // 12/2 = 6 for each column + expect(result.rows?.[0].items.map((item) => item.id)).toEqual(['metric-1', 'metric-3']); + }); + + it('should handle multiple rows and maintain order within each row', () => { + const existingConfig: BusterDashboard['config'] = { + rows: [ + { + id: 'row-1', + items: [{ id: 'metric-1' }, { id: 'metric-2' }], + columnSizes: [6, 6], + rowHeight: 320 + }, + { + id: 'row-2', + items: [{ id: 'metric-3' }, { id: 'metric-4' }], + columnSizes: [6, 6], + rowHeight: 320 + } + ] + }; + + const result = addAndRemoveMetricsToDashboard( + ['metric-1', 'metric-3', 'metric-5'], + existingConfig + ); + + // Verify order is maintained in each row after modifications + expect(result.rows?.[0].items.map((item) => item.id)).toEqual(['metric-1']); + expect(result.rows?.[0].columnSizes).toEqual([12]); + + expect(result.rows?.[1].items.map((item) => item.id)).toEqual(['metric-3']); + expect(result.rows?.[1].columnSizes).toEqual([12]); + + expect(result.rows?.[2].items.map((item) => item.id)).toEqual(['metric-5']); + expect(result.rows?.[2].columnSizes).toEqual([12]); + }); +}); diff --git a/web/src/api/buster_rest/dashboards/helpers/addAndRemoveMetricsToDashboard.ts b/web/src/api/buster_rest/dashboards/helpers/addAndRemoveMetricsToDashboard.ts new file mode 100644 index 000000000..df5d98412 --- /dev/null +++ b/web/src/api/buster_rest/dashboards/helpers/addAndRemoveMetricsToDashboard.ts @@ -0,0 +1,36 @@ +import type { BusterDashboard } from '@/api/asset_interfaces/dashboard'; +import { addMetricToDashboardConfig } from './addMetricToDashboard'; +import { removeMetricFromDashboardConfig } from './removeMetricFromDashboard'; + +export const addAndRemoveMetricsToDashboard = ( + metricIds: string[], + existingConfig: BusterDashboard['config'] +): BusterDashboard['config'] => { + // Get all existing metric IDs from the dashboard + const existingMetricIds = new Set( + existingConfig.rows?.flatMap((row) => row.items.map((item) => item.id)) || [] + ); + + // Determine which metrics to add and remove + const metricsToAdd = metricIds.filter((id) => !existingMetricIds.has(id)); + const metricsToRemove = Array.from(existingMetricIds).filter((id) => !metricIds.includes(id)); + + // If no changes needed, return existing config + if (metricsToAdd.length === 0 && metricsToRemove.length === 0) { + return existingConfig; + } + + // First remove metrics if any + const configAfterRemoval = + metricsToRemove.length > 0 + ? removeMetricFromDashboardConfig(metricsToRemove, existingConfig) + : existingConfig; + + // Then add new metrics if any + const finalConfig = + metricsToAdd.length > 0 + ? addMetricToDashboardConfig(metricsToAdd, configAfterRemoval) + : configAfterRemoval; + + return finalConfig; +}; diff --git a/web/src/api/buster_rest/dashboards/queryRequests.ts b/web/src/api/buster_rest/dashboards/queryRequests.ts index 8f77b7044..e99436151 100644 --- a/web/src/api/buster_rest/dashboards/queryRequests.ts +++ b/web/src/api/buster_rest/dashboards/queryRequests.ts @@ -26,6 +26,7 @@ import { } from '../collections/queryRequests'; import { collectionQueryKeys } from '@/api/query_keys/collection'; import { addMetricToDashboardConfig, removeMetricFromDashboardConfig } from './helpers'; +import { addAndRemoveMetricsToDashboard } from './helpers/addAndRemoveMetricsToDashboard'; export const useGetDashboardsList = ( params: Omit @@ -129,6 +130,7 @@ export const useUpdateDashboardConfig = () => { const newConfig = create(previousConfig!, (draft) => { Object.assign(draft, newDashboard); }); + console.log('update', newConfig); return mutateAsync({ id: newDashboard.id, config: newConfig @@ -330,29 +332,75 @@ export const useUpdateDashboardShare = () => { }); }; -/** - * Hook for adding metrics to a dashboard. This function also supports removing metrics via the addMetricToDashboardConfig - */ -export const useAddMetricsToDashboard = () => { +const useEnsureDashboardConfig = () => { const queryClient = useQueryClient(); const prefetchDashboard = useGetDashboardAndInitializeMetrics(); const { openErrorMessage } = useBusterNotifications(); + const method = useMemoizedFn(async (dashboardId: string) => { + const options = dashboardQueryKeys.dashboardGetDashboard(dashboardId); + let dashboardResponse = queryClient.getQueryData(options.queryKey); + if (!dashboardResponse) { + const res = await prefetchDashboard(dashboardId).catch((e) => { + openErrorMessage('Failed to save metrics to dashboard. Dashboard not found'); + return null; + }); + if (res) { + queryClient.setQueryData(options.queryKey, res); + dashboardResponse = res; + } + } + + return dashboardResponse; + }); + + return method; +}; + +export const useAddAndRemoveMetricsFromDashboard = () => { + const queryClient = useQueryClient(); + const { openErrorMessage } = useBusterNotifications(); + const ensureDashboardConfig = useEnsureDashboardConfig(); + const addMetricToDashboard = useMemoizedFn( async ({ metricIds, dashboardId }: { metricIds: string[]; dashboardId: string }) => { - const options = dashboardQueryKeys.dashboardGetDashboard(dashboardId); - let dashboardResponse = queryClient.getQueryData(options.queryKey); - if (!dashboardResponse) { - const res = await prefetchDashboard(dashboardId).catch((e) => { - openErrorMessage('Failed to save metrics to dashboard. Dashboard not found'); - return null; + const dashboardResponse = await ensureDashboardConfig(dashboardId); + + if (dashboardResponse) { + const newConfig = addAndRemoveMetricsToDashboard( + metricIds, + dashboardResponse.dashboard.config + ); + console.log('add/remove', newConfig); + return dashboardsUpdateDashboard({ + id: dashboardId, + config: newConfig }); - if (res) { - queryClient.setQueryData(options.queryKey, res); - dashboardResponse = res; - } } + openErrorMessage('Failed to save metrics to dashboard'); + } + ); + + return useMutation({ + mutationFn: addMetricToDashboard, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: dashboardQueryKeys.dashboardGetDashboard(variables.dashboardId).queryKey + }); + } + }); +}; + +export const useAddMetricsToDashboard = () => { + const queryClient = useQueryClient(); + const { openErrorMessage } = useBusterNotifications(); + const ensureDashboardConfig = useEnsureDashboardConfig(); + + const addMetricToDashboard = useMemoizedFn( + async ({ metricIds, dashboardId }: { metricIds: string[]; dashboardId: string }) => { + const dashboardResponse = await ensureDashboardConfig(dashboardId); + if (dashboardResponse) { const newConfig = addMetricToDashboardConfig(metricIds, dashboardResponse.dashboard.config); return dashboardsUpdateDashboard({ @@ -378,7 +426,8 @@ export const useAddMetricsToDashboard = () => { export const useRemoveMetricsFromDashboard = () => { const { openConfirmModal, openErrorMessage } = useBusterNotifications(); const queryClient = useQueryClient(); - const prefetchDashboard = useGetDashboardAndInitializeMetrics(); + const ensureDashboardConfig = useEnsureDashboardConfig(); + const removeMetricFromDashboard = useMemoizedFn( async ({ metricIds, @@ -390,18 +439,7 @@ export const useRemoveMetricsFromDashboard = () => { useConfirmModal?: boolean; }) => { const method = async () => { - const options = dashboardQueryKeys.dashboardGetDashboard(dashboardId); - let dashboardResponse = queryClient.getQueryData(options.queryKey); - if (!dashboardResponse) { - const res = await prefetchDashboard(dashboardId).catch((e) => { - openErrorMessage('Failed to remove metrics from dashboard. Dashboard not found'); - return null; - }); - if (res) { - queryClient.setQueryData(options.queryKey, res); - dashboardResponse = res; - } - } + const dashboardResponse = await ensureDashboardConfig(dashboardId); if (dashboardResponse) { const newConfig = removeMetricFromDashboardConfig( diff --git a/web/src/components/features/modal/AddToDashboardModal.tsx b/web/src/components/features/modal/AddToDashboardModal.tsx index 1e8457c4a..97b98c4bf 100644 --- a/web/src/components/features/modal/AddToDashboardModal.tsx +++ b/web/src/components/features/modal/AddToDashboardModal.tsx @@ -4,7 +4,11 @@ import React, { useLayoutEffect, useMemo, useState } from 'react'; import { InputSelectModal, InputSelectModalProps } from '@/components/ui/modal/InputSelectModal'; import { formatDate } from '@/lib'; import { Button } from '@/components/ui/buttons'; -import { useAddMetricsToDashboard, useGetDashboard } from '@/api/buster_rest/dashboards'; +import { + useAddAndRemoveMetricsFromDashboard, + useAddMetricsToDashboard, + useGetDashboard +} from '@/api/buster_rest/dashboards'; export const AddToDashboardModal: React.FC<{ open: boolean; @@ -13,7 +17,7 @@ export const AddToDashboardModal: React.FC<{ }> = React.memo(({ open, onClose, dashboardId }) => { const { data: dashboard, isFetched: isFetchedDashboard } = useGetDashboard(dashboardId); const { data: metrics, isFetched: isFetchedMetrics } = useGetMetricsList({}); - const { mutateAsync: addMetricsToDashboard } = useAddMetricsToDashboard(); + const { mutateAsync: addAndRemoveMetricsFromDashboard } = useAddAndRemoveMetricsFromDashboard(); const [selectedMetrics, setSelectedMetrics] = useState([]); @@ -43,7 +47,7 @@ export const AddToDashboardModal: React.FC<{ }, [metrics.length]); const handleAddAndRemoveMetrics = useMemoizedFn(async () => { - await addMetricsToDashboard({ + await addAndRemoveMetricsFromDashboard({ dashboardId: dashboardId, metricIds: selectedMetrics }); @@ -102,7 +106,7 @@ export const AddToDashboardModal: React.FC<{ return ( { const filteredRows = newRowPreflight(newLayout); + console.log(filteredRows); + if (checkRowEquality(filteredRows, rows)) { return; } @@ -399,9 +403,7 @@ const newRowPreflight = (newRows: BusterResizeableGridRow[]) => { columnSizes: newColumnSizes }; } - return { - ...row - }; + return row; }); return newRowsCopy; diff --git a/web/src/components/ui/grid/interfaces.ts b/web/src/components/ui/grid/interfaces.ts index f46ba6595..bc47e050f 100644 --- a/web/src/components/ui/grid/interfaces.ts +++ b/web/src/components/ui/grid/interfaces.ts @@ -2,7 +2,7 @@ import React from 'react'; export type ResizeableGridDragItem = { id: string; - children?: React.ReactNode; + children: React.ReactNode; }; export type BusterResizeableGridRow = { diff --git a/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardContentController/DashboardContentController.tsx b/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardContentController/DashboardContentController.tsx index 5795f8eec..279bcf184 100644 --- a/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardContentController/DashboardContentController.tsx +++ b/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardContentController/DashboardContentController.tsx @@ -4,7 +4,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import isEmpty from 'lodash/isEmpty'; import { BusterResizeableGrid, BusterResizeableGridRow } from '@/components/ui/grid'; import { useDebounceFn, useMemoizedFn } from '@/hooks'; -import { hasRemovedMetrics, hasUnmappedMetrics, normalizeNewMetricsIntoGrid } from './helpers'; +import { + hasRemovedMetrics, + hasUnmappedMetrics, + normalizeNewMetricsIntoGrid, + removeChildrenFromItems +} from './helpers'; import { DashboardMetricItem } from './DashboardMetricItem'; import { DashboardContentControllerProvider } from './DashboardContentControllerContext'; import type { @@ -14,6 +19,7 @@ import type { } from '@/api/asset_interfaces'; import { DashboardEmptyState } from './DashboardEmptyState'; import { type useUpdateDashboardConfig } from '@/api/buster_rest/dashboards'; +import omit from 'lodash/omit'; const DEFAULT_EMPTY_ROWS: DashboardConfig['rows'] = []; const DEFAULT_EMPTY_METRICS: Record = {}; @@ -77,7 +83,9 @@ export const DashboardContentController: React.FC<{ ); const onRowLayoutChange = useMemoizedFn((rows: BusterResizeableGridRow[]) => { - if (dashboard) onUpdateDashboardConfig({ rows, id: dashboard.id }); + if (dashboard) { + onUpdateDashboardConfig({ rows: removeChildrenFromItems(rows), id: dashboard.id }); + } }); const onDragEnd = useMemoizedFn(() => { diff --git a/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardContentController/helpers/hasMappedMetrics.ts b/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardContentController/helpers/hasMappedMetrics.ts index 2a6b8f119..6eccf787a 100644 --- a/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardContentController/helpers/hasMappedMetrics.ts +++ b/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardContentController/helpers/hasMappedMetrics.ts @@ -1,6 +1,7 @@ -import { DashboardConfig } from '@/api/asset_interfaces/dashboard'; -import { BusterMetric } from '@/api/asset_interfaces/metric'; -import { BusterResizeableGridRow } from '@/components/ui/grid/interfaces'; +import type { DashboardConfig } from '@/api/asset_interfaces/dashboard'; +import type { BusterMetric } from '@/api/asset_interfaces/metric'; +import { BusterResizeableGridRow } from '@/components/ui/grid'; +import omit from 'lodash/omit'; export const hasUnmappedMetrics = ( metrics: Record, @@ -13,7 +14,7 @@ export const hasUnmappedMetrics = ( export const hasRemovedMetrics = ( metrics: Record, - configRows: BusterResizeableGridRow[] + configRows: DashboardConfig['rows'] = [] ) => { const allGridItemsLength = configRows.flatMap((r) => r.items).length; @@ -25,3 +26,10 @@ export const hasRemovedMetrics = ( r.items.some((t) => Object.values(metrics).some((m) => t.id === m.id)) ); }; + +export const removeChildrenFromItems = (row: BusterResizeableGridRow[]) => { + return row.map((r) => ({ + ...r, + items: r.items.map((i) => omit(i, 'children')) + })); +};