1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-13 13:48:59 +02:00

Update impact metrics state (#10342)

This commit is contained in:
Tymoteusz Czech 2025-07-11 10:46:20 +02:00 committed by GitHub
parent 69905185c5
commit 1948861a46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 458 additions and 181 deletions

View File

@ -9,6 +9,8 @@ import { ChartItem } from './ChartItem.tsx';
import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx'; import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx';
import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts'; import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts';
import type { ChartConfig, LayoutItem } from './types.ts'; import type { ChartConfig, LayoutItem } from './types.ts';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
const StyledEmptyState = styled(Paper)(({ theme }) => ({ const StyledEmptyState = styled(Paper)(({ theme }) => ({
textAlign: 'center', textAlign: 'center',
@ -21,9 +23,18 @@ const StyledEmptyState = styled(Paper)(({ theme }) => ({
export const ImpactMetrics: FC = () => { export const ImpactMetrics: FC = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingChart, setEditingChart] = useState<ChartConfig | undefined>(); const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
const { setToastApiError } = useToast();
const { charts, layout, addChart, updateChart, deleteChart, updateLayout } = const {
useImpactMetricsState(); charts,
layout,
loading: settingsLoading,
error: settingsError,
addChart,
updateChart,
deleteChart,
updateLayout,
} = useImpactMetricsState();
const { const {
metadata, metadata,
@ -51,20 +62,39 @@ export const ImpactMetrics: FC = () => {
setModalOpen(true); setModalOpen(true);
}; };
const handleSaveChart = (config: Omit<ChartConfig, 'id'>) => { const handleSaveChart = async (config: Omit<ChartConfig, 'id'>) => {
if (editingChart) { try {
updateChart(editingChart.id, config); if (editingChart) {
} else { await updateChart(editingChart.id, config);
addChart(config); } else {
await addChart(config);
}
setModalOpen(false);
} catch (error) {
setToastApiError(formatUnknownError(error));
} }
setModalOpen(false);
}; };
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(layout: any[]) => { async (layout: any[]) => {
updateLayout(layout as LayoutItem[]); try {
await updateLayout(layout as LayoutItem[]);
} catch (error) {
setToastApiError(formatUnknownError(error));
}
}, },
[updateLayout], [updateLayout, setToastApiError],
);
const handleDeleteChart = useCallback(
async (id: string) => {
try {
await deleteChart(id);
} catch (error) {
setToastApiError(formatUnknownError(error));
}
},
[deleteChart],
); );
const gridItems: GridItem[] = useMemo( const gridItems: GridItem[] = useMemo(
@ -79,7 +109,7 @@ export const ImpactMetrics: FC = () => {
<ChartItem <ChartItem
config={config} config={config}
onEdit={handleEditChart} onEdit={handleEditChart}
onDelete={deleteChart} onDelete={handleDeleteChart}
/> />
), ),
w: existingLayout?.w ?? 6, w: existingLayout?.w ?? 6,
@ -92,10 +122,11 @@ export const ImpactMetrics: FC = () => {
maxH: 8, maxH: 8,
}; };
}), }),
[charts, layout, handleEditChart, deleteChart], [charts, layout, handleEditChart, handleDeleteChart],
); );
const hasError = metadataError; const hasError = metadataError || settingsError;
const isLoading = metadataLoading || settingsLoading;
return ( return (
<> <>
@ -111,14 +142,14 @@ export const ImpactMetrics: FC = () => {
variant='contained' variant='contained'
startIcon={<Add />} startIcon={<Add />}
onClick={handleAddChart} onClick={handleAddChart}
disabled={metadataLoading || !!hasError} disabled={isLoading || !!hasError}
> >
Add Chart Add Chart
</Button> </Button>
} }
/> />
{charts.length === 0 && !metadataLoading && !hasError ? ( {charts.length === 0 && !isLoading && !hasError ? (
<StyledEmptyState> <StyledEmptyState>
<Typography variant='h6' gutterBottom> <Typography variant='h6' gutterBottom>
No charts configured No charts configured
@ -135,7 +166,7 @@ export const ImpactMetrics: FC = () => {
variant='contained' variant='contained'
startIcon={<Add />} startIcon={<Add />}
onClick={handleAddChart} onClick={handleAddChart}
disabled={metadataLoading || !!hasError} disabled={isLoading || !!hasError}
> >
Add Chart Add Chart
</Button> </Button>
@ -153,7 +184,7 @@ export const ImpactMetrics: FC = () => {
onSave={handleSaveChart} onSave={handleSaveChart}
initialConfig={editingChart} initialConfig={editingChart}
metricSeries={metricSeries} metricSeries={metricSeries}
loading={metadataLoading} loading={metadataLoading || settingsLoading}
/> />
</> </>
); );

View File

@ -184,7 +184,11 @@ export const ImpactMetricsChart: FC<ImpactMetricsChartProps> = ({
background: theme.palette.background.elevation1, background: theme.palette.background.elevation1,
})} })}
> >
<Typography variant='caption' color='text.secondary'> <Typography
variant='caption'
color='text.secondary'
sx={{ textWrap: 'break-all' }}
>
<code>{debug.query}</code> <code>{debug.query}</code>
</Typography> </Typography>
</Box> </Box>

View File

@ -1,123 +1,332 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'utils/testRenderer'; import { render } from 'utils/testRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { useImpactMetricsState } from './useImpactMetricsState.ts'; import { useImpactMetricsState } from './useImpactMetricsState.ts';
import { Route, Routes } from 'react-router-dom';
import { createLocalStorage } from '../../../utils/createLocalStorage.ts';
import type { FC } from 'react'; import type { FC } from 'react';
import type { ImpactMetricsState } from '../types.ts'; import type { ImpactMetricsState } from '../types.ts';
const TestComponent: FC = () => { const server = testServerSetup();
const { charts, layout } = useImpactMetricsState();
const TestComponent: FC<{
enableActions?: boolean;
}> = ({ enableActions = false }) => {
const {
charts,
layout,
loading,
error,
addChart,
updateChart,
deleteChart,
} = useImpactMetricsState();
return ( return (
<div> <div>
<span data-testid='charts-count'>{charts.length}</span> <span data-testid='charts-count'>{charts.length}</span>
<span data-testid='layout-count'>{layout.length}</span> <span data-testid='layout-count'>{layout.length}</span>
<span data-testid='loading'>{loading.toString()}</span>
<span data-testid='error'>{error ? 'has-error' : 'no-error'}</span>
{enableActions && (
<button
type='button'
data-testid='add-chart'
onClick={() =>
addChart({
selectedSeries: 'test-series',
selectedRange: 'day',
beginAtZero: true,
showRate: false,
selectedLabels: {},
title: 'Test Chart',
})
}
>
Add Chart
</button>
)}
{enableActions && charts.length > 0 && (
<button
type='button'
data-testid='update-chart'
onClick={() =>
updateChart(charts[0].id, { title: 'Updated Chart' })
}
>
Update Chart
</button>
)}
{enableActions && charts.length > 0 && (
<button
type='button'
data-testid='delete-chart'
onClick={() => deleteChart(charts[0].id)}
>
Delete Chart
</button>
)}
</div> </div>
); );
}; };
const TestWrapper = () => ( const mockSettings: ImpactMetricsState = {
<Routes> charts: [
<Route path='/impact-metrics' element={<TestComponent />} /> {
</Routes> id: 'test-chart',
); selectedSeries: 'test-series',
selectedRange: 'day' as const,
beginAtZero: true,
showRate: false,
selectedLabels: {},
title: 'Test Chart',
},
],
layout: [
{
i: 'test-chart',
x: 0,
y: 0,
w: 6,
h: 4,
minW: 4,
minH: 2,
maxW: 12,
maxH: 8,
},
],
};
const emptySettings: ImpactMetricsState = {
charts: [],
layout: [],
};
describe('useImpactMetricsState', () => { describe('useImpactMetricsState', () => {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); testServerRoute(server, '/api/admin/ui-config', {});
}); });
it('loads state from localStorage to the URL after opening page without URL state', async () => { it('loads settings from API', async () => {
const { setValue } = createLocalStorage<ImpactMetricsState>( testServerRoute(
'impact-metrics-state', server,
{ '/api/admin/impact-metrics/settings',
charts: [], mockSettings,
layout: [],
},
); );
setValue({ render(<TestComponent />);
charts: [
{ await waitFor(() => {
id: 'test-chart', expect(screen.getByTestId('charts-count')).toHaveTextContent('1');
selectedSeries: 'test-series', expect(screen.getByTestId('layout-count')).toHaveTextContent('1');
selectedRange: 'day' as const, expect(screen.getByTestId('loading')).toHaveTextContent('false');
beginAtZero: true, expect(screen.getByTestId('error')).toHaveTextContent('no-error');
showRate: false,
selectedLabels: {},
title: 'Test Chart',
},
],
layout: [
{
i: 'test-chart',
x: 0,
y: 0,
w: 6,
h: 4,
minW: 4,
minH: 2,
maxW: 12,
maxH: 8,
},
],
}); });
render(<TestWrapper />, { route: '/impact-metrics' });
expect(window.location.href).toContain('charts=');
expect(window.location.href).toContain('layout=');
}); });
it('does not modify URL when URL already contains data', async () => { it('handles empty settings', async () => {
const { setValue } = createLocalStorage<ImpactMetricsState>( testServerRoute(
'impact-metrics-state', server,
'/api/admin/impact-metrics/settings',
emptySettings,
);
render(<TestComponent />);
await waitFor(() => {
expect(screen.getByTestId('charts-count')).toHaveTextContent('0');
expect(screen.getByTestId('layout-count')).toHaveTextContent('0');
expect(screen.getByTestId('loading')).toHaveTextContent('false');
expect(screen.getByTestId('error')).toHaveTextContent('no-error');
});
});
it('handles API errors', async () => {
testServerRoute(
server,
'/api/admin/impact-metrics/settings',
{ message: 'Server error' },
'get',
500,
);
render(<TestComponent />);
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent('has-error');
});
});
it('adds a chart successfully', async () => {
testServerRoute(
server,
'/api/admin/impact-metrics/settings',
emptySettings,
);
render(<TestComponent enableActions />);
await waitFor(() => {
expect(screen.getByTestId('charts-count')).toHaveTextContent('0');
});
testServerRoute(
server,
'/api/admin/impact-metrics/settings',
{ {
charts: [], charts: [
layout: [], {
id: 'new-chart-id',
selectedSeries: 'test-series',
selectedRange: 'day',
beginAtZero: true,
showRate: false,
selectedLabels: {},
title: 'Test Chart',
},
],
layout: [
{
i: 'new-chart-id',
x: 0,
y: 0,
w: 6,
h: 4,
minW: 4,
minH: 2,
maxW: 12,
maxH: 8,
},
],
}, },
'put',
200,
); );
setValue({ testServerRoute(
charts: [ server,
{ '/api/admin/impact-metrics/settings',
id: 'old-chart', {
selectedSeries: 'old-series', charts: [
selectedRange: 'day' as const, {
beginAtZero: true, id: 'new-chart-id',
showRate: false, selectedSeries: 'test-series',
selectedLabels: {}, selectedRange: 'day',
title: 'Old Chart', beginAtZero: true,
}, showRate: false,
], selectedLabels: {},
layout: [], title: 'Test Chart',
}); },
],
const urlCharts = btoa( layout: [
JSON.stringify([ {
{ i: 'new-chart-id',
id: 'url-chart', x: 0,
selectedSeries: 'url-series', y: 0,
selectedRange: 'day', w: 6,
beginAtZero: true, h: 4,
showRate: false, minW: 4,
selectedLabels: {}, minH: 2,
title: 'URL Chart', maxW: 12,
}, maxH: 8,
]), },
],
},
'get',
200,
); );
render(<TestWrapper />, { const addButton = screen.getByTestId('add-chart');
route: `/impact-metrics?charts=${encodeURIComponent(urlCharts)}`, await userEvent.click(addButton);
await waitFor(
() => {
expect(screen.getByTestId('charts-count')).toHaveTextContent(
'1',
);
},
{ timeout: 5000 },
);
});
it('updates a chart successfully', async () => {
testServerRoute(
server,
'/api/admin/impact-metrics/settings',
mockSettings,
);
testServerRoute(
server,
'/api/admin/impact-metrics/settings',
{
charts: [
{
...mockSettings.charts[0],
title: 'Updated Chart',
},
],
layout: mockSettings.layout,
},
'put',
200,
);
render(<TestComponent enableActions />);
await waitFor(() => {
expect(screen.getByTestId('charts-count')).toHaveTextContent('1');
}); });
const urlParams = new URLSearchParams(window.location.search); const updateButton = screen.getByTestId('update-chart');
const chartsParam = urlParams.get('charts'); await userEvent.click(updateButton);
expect(chartsParam).toBeTruthy(); await waitFor(() => {
expect(screen.getByTestId('charts-count')).toHaveTextContent('1');
});
});
const decodedCharts = JSON.parse(atob(chartsParam!)); it('deletes a chart successfully', async () => {
expect(decodedCharts[0].id).toBe('url-chart'); testServerRoute(
expect(decodedCharts[0].id).not.toBe('old-chart'); server,
'/api/admin/impact-metrics/settings',
mockSettings,
);
render(<TestComponent enableActions />);
await waitFor(() => {
expect(screen.getByTestId('charts-count')).toHaveTextContent('1');
});
testServerRoute(
server,
'/api/admin/impact-metrics/settings',
emptySettings,
'put',
200,
);
testServerRoute(
server,
'/api/admin/impact-metrics/settings',
emptySettings,
'get',
200,
);
const deleteButton = screen.getByTestId('delete-chart');
await userEvent.click(deleteButton);
await waitFor(
() => {
expect(screen.getByTestId('charts-count')).toHaveTextContent(
'0',
);
},
{ timeout: 5000 },
);
}); });
}); });

View File

@ -1,72 +1,40 @@
import { useCallback, useMemo } from 'react'; import { useCallback } from 'react';
import { withDefault } from 'use-query-params'; import { useImpactMetricsSettings } from 'hooks/api/getters/useImpactMetricsSettings/useImpactMetricsSettings.js';
import { usePersistentTableState } from 'hooks/usePersistentTableState'; import { useImpactMetricsSettingsApi } from 'hooks/api/actions/useImpactMetricsSettingsApi/useImpactMetricsSettingsApi.js';
import type { ChartConfig, ImpactMetricsState, LayoutItem } from '../types.ts'; import type { ChartConfig, ImpactMetricsState, LayoutItem } from '../types.ts';
const createArrayParam = <T>() => ({
encode: (items: T[]): string =>
items.length > 0 ? btoa(JSON.stringify(items)) : '',
decode: (value: string | (string | null)[] | null | undefined): T[] => {
if (typeof value !== 'string' || !value) return [];
try {
return JSON.parse(atob(value));
} catch {
return [];
}
},
});
const ChartsParam = createArrayParam<ChartConfig>();
const LayoutParam = createArrayParam<LayoutItem>();
export const useImpactMetricsState = () => { export const useImpactMetricsState = () => {
const stateConfig = { const {
charts: withDefault(ChartsParam, []), settings,
layout: withDefault(LayoutParam, []), loading: settingsLoading,
}; error: settingsError,
refetch,
} = useImpactMetricsSettings();
const [tableState, setTableState] = usePersistentTableState( const {
'impact-metrics-state', updateSettings,
stateConfig, loading: actionLoading,
); errors: actionErrors,
} = useImpactMetricsSettingsApi();
const currentState: ImpactMetricsState = useMemo(
() => ({
charts: tableState.charts || [],
layout: tableState.layout || [],
}),
[tableState.charts, tableState.layout],
);
const updateState = useCallback(
(newState: ImpactMetricsState) => {
setTableState({
charts: newState.charts,
layout: newState.layout,
});
},
[setTableState],
);
const addChart = useCallback( const addChart = useCallback(
(config: Omit<ChartConfig, 'id'>) => { async (config: Omit<ChartConfig, 'id'>) => {
const newChart: ChartConfig = { const newChart: ChartConfig = {
...config, ...config,
id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}; };
const maxY = const maxY =
currentState.layout.length > 0 settings.layout.length > 0
? Math.max( ? Math.max(
...currentState.layout.map((item) => item.y + item.h), ...settings.layout.map((item) => item.y + item.h),
) )
: 0; : 0;
updateState({ const newState: ImpactMetricsState = {
charts: [...currentState.charts, newChart], charts: [...settings.charts, newChart],
layout: [ layout: [
...currentState.layout, ...settings.layout,
{ {
i: newChart.id, i: newChart.id,
x: 0, x: 0,
@ -79,46 +47,61 @@ export const useImpactMetricsState = () => {
maxH: 8, maxH: 8,
}, },
], ],
}); };
await updateSettings(newState);
refetch();
}, },
[currentState.charts, currentState.layout, updateState], [settings, updateSettings, refetch],
); );
const updateChart = useCallback( const updateChart = useCallback(
(id: string, updates: Partial<ChartConfig>) => { async (id: string, updates: Partial<ChartConfig>) => {
updateState({ const updatedCharts = settings.charts.map((chart) =>
charts: currentState.charts.map((chart) => chart.id === id ? { ...chart, ...updates } : chart,
chart.id === id ? { ...chart, ...updates } : chart, );
), const newState: ImpactMetricsState = {
layout: currentState.layout, charts: updatedCharts,
}); layout: settings.layout,
};
await updateSettings(newState);
refetch();
}, },
[currentState.charts, currentState.layout, updateState], [settings, updateSettings, refetch],
); );
const deleteChart = useCallback( const deleteChart = useCallback(
(id: string) => { async (id: string) => {
updateState({ const newState: ImpactMetricsState = {
charts: currentState.charts.filter((chart) => chart.id !== id), charts: settings.charts.filter((chart) => chart.id !== id),
layout: currentState.layout.filter((item) => item.i !== id), layout: settings.layout.filter((item) => item.i !== id),
}); };
await updateSettings(newState);
refetch();
}, },
[currentState.charts, currentState.layout, updateState], [settings, updateSettings, refetch],
); );
const updateLayout = useCallback( const updateLayout = useCallback(
(newLayout: LayoutItem[]) => { async (newLayout: LayoutItem[]) => {
updateState({ const newState: ImpactMetricsState = {
charts: currentState.charts, charts: settings.charts,
layout: newLayout, layout: newLayout,
}); };
await updateSettings(newState);
refetch();
}, },
[currentState.charts, updateState], [settings, updateSettings, refetch],
); );
return { return {
charts: currentState.charts || [], charts: settings.charts || [],
layout: currentState.layout || [], layout: settings.layout || [],
loading: settingsLoading || actionLoading,
error:
settingsError || Object.keys(actionErrors).length > 0
? actionErrors
: undefined,
addChart, addChart,
updateChart, updateChart,
deleteChart, deleteChart,

View File

@ -0,0 +1,32 @@
import { useCallback } from 'react';
import useAPI from '../useApi/useApi.js';
import type { ImpactMetricsState } from 'component/impact-metrics/types.ts';
export const useImpactMetricsSettingsApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const updateSettings = useCallback(
async (settings: ImpactMetricsState) => {
const path = `api/admin/impact-metrics/settings`;
const req = createRequest(
path,
{
method: 'PUT',
body: JSON.stringify(settings),
},
'updateImpactMetricsSettings',
);
return makeRequest(req.caller, req.id);
},
[makeRequest, createRequest],
);
return {
updateSettings,
errors,
loading,
};
};

View File

@ -0,0 +1,18 @@
import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js';
import { formatApiPath } from 'utils/formatPath';
import type { ImpactMetricsState } from 'component/impact-metrics/types.ts';
export const useImpactMetricsSettings = () => {
const PATH = `api/admin/impact-metrics/settings`;
const { data, refetch, loading, error } = useApiGetter<ImpactMetricsState>(
formatApiPath(PATH),
() => fetcher(formatApiPath(PATH), 'Impact metrics settings'),
);
return {
settings: data || { charts: [], layout: [] },
refetch,
loading,
error,
};
};