feat(home): improve widget editing state management

Signed-off-by: Mikko Korhonen <mikko.korhonen@gmail.com>
This commit is contained in:
Mikko Korhonen
2025-09-18 15:37:53 +03:00
parent ae31b31664
commit e091a83703
6 changed files with 209 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-home': minor
---
Widget configurations are now only saved to storage when the Save button is explicitly clicked. Added a Cancel button that allows users to discard unsaved changes and revert to the last saved state.
+1
View File
@@ -107,6 +107,7 @@ export const homeTranslationRef: TranslationRef<
{
readonly 'starredEntities.noStarredEntitiesMessage': 'Click the star beside an entity name to add it to this list!';
readonly 'addWidgetDialog.title': 'Add new widget to dashboard';
readonly 'customHomepageButtons.cancel': 'Cancel';
readonly 'customHomepageButtons.clearAll': 'Clear all';
readonly 'customHomepageButtons.edit': 'Edit';
readonly 'customHomepageButtons.restoreDefaults': 'Restore defaults';
@@ -45,6 +45,7 @@ interface CustomHomepageButtonsProps {
changeEditMode: (mode: boolean) => void;
defaultConfigAvailable: boolean;
restoreDefault: () => void;
cancel: () => void;
}
export const CustomHomepageButtons = (props: CustomHomepageButtonsProps) => {
const {
@@ -55,6 +56,7 @@ export const CustomHomepageButtons = (props: CustomHomepageButtonsProps) => {
changeEditMode,
defaultConfigAvailable,
restoreDefault,
cancel,
} = props;
const styles = useStyles();
const { t } = useTranslationRef(homeTranslationRef);
@@ -73,6 +75,9 @@ export const CustomHomepageButtons = (props: CustomHomepageButtonsProps) => {
</Button>
) : (
<>
<Button variant="contained" onClick={cancel} size="small">
{t('customHomepageButtons.cancel')}
</Button>
{defaultConfigAvailable && (
<Button
variant="contained"
@@ -0,0 +1,169 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { screen } from '@testing-library/react';
import { CustomHomepageGrid } from './CustomHomepageGrid';
import {
renderInTestApp,
mockApis,
TestApiProvider,
} from '@backstage/test-utils';
import { homePlugin } from '../../plugin';
import {
createComponentExtension,
storageApiRef,
} from '@backstage/core-plugin-api';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
const ComponentA = homePlugin.provide(
createComponentExtension({
name: 'A',
component: {
sync: () => <div>A</div>,
},
}),
);
const ComponentB = homePlugin.provide(
createComponentExtension({
name: 'B',
component: {
sync: () => <div>B</div>,
},
}),
);
const ComponentC = homePlugin.provide(
createComponentExtension({
name: 'C',
component: {
sync: () => <div>C</div>,
},
}),
);
const defaultConfig: ComponentProps<typeof CustomHomepageGrid>['config'] = [
{
component: 'A',
x: 0,
y: 0,
width: 10,
height: 10,
},
{
component: 'B',
x: 10,
y: 0,
width: 10,
height: 10,
},
{
component: 'C',
x: 0,
y: 10,
width: 20,
height: 10,
},
];
describe('CustomHomepageGrid', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should save edits', async () => {
const mockStorage = mockApis.storage();
await renderInTestApp(
<TestApiProvider apis={[[storageApiRef, mockStorage]]}>
<CustomHomepageGrid config={defaultConfig}>
<ComponentA />
<ComponentB />
<ComponentC />
</CustomHomepageGrid>
</TestApiProvider>,
);
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
const editButton = screen.getByRole('button', { name: 'Edit' });
await userEvent.click(editButton);
expect(editButton).not.toBeVisible();
await userEvent.click(
screen.getAllByRole('button', { name: 'Delete widget' })[0],
);
const saveButton = screen.getByRole('button', { name: 'Save' });
await userEvent.click(saveButton);
expect(saveButton).not.toBeVisible();
expect(screen.queryByText('A')).not.toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
expect(
mockStorage.forBucket('home.customHomepage').snapshot('home'),
).toEqual({
key: 'home',
presence: 'present',
value: expect.any(String),
});
});
it('should cancel edits', async () => {
const mockStorage = mockApis.storage();
await renderInTestApp(
<TestApiProvider apis={[[storageApiRef, mockStorage]]}>
<CustomHomepageGrid config={defaultConfig}>
<ComponentA />
<ComponentB />
<ComponentC />
</CustomHomepageGrid>
</TestApiProvider>,
);
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
const widgets = screen.getAllByRole('button', { name: 'Delete widget' });
for (const widget of widgets) {
await userEvent.click(widget);
}
expect(screen.queryByText('A')).not.toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
expect(
mockStorage.forBucket('home.customHomepage').snapshot('home'),
).toEqual(
expect.objectContaining({ presence: 'absent', value: undefined }),
);
});
});
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import { isValidElement, useState, useCallback, useMemo } from 'react';
import {
isValidElement,
useState,
useCallback,
useMemo,
useEffect,
} from 'react';
import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout';
import {
ElementCollection,
@@ -220,10 +226,18 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
? convertConfigToDefaultWidgets(props.config, availableWidgets)
: [];
}, [props.config, availableWidgets]);
const [widgets, setWidgets, isStorageLoading] = useHomeStorage(defaultLayout);
const [storedWidgets, storeWidgets, isStorageLoading] =
useHomeStorage(defaultLayout);
const [widgets, setWidgets] = useState(storedWidgets);
const [addWidgetDialogOpen, setAddWidgetDialogOpen] = useState(false);
const editModeOn = widgets.find(w => w.layout.isResizable) !== undefined;
const [editMode, setEditMode] = useState(editModeOn);
useEffect(() => {
setWidgets(storedWidgets);
}, [storedWidgets]);
const getWidgetByName = (name: string) => {
return availableWidgets.find(widget => widget.name === name);
};
@@ -287,16 +301,18 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
const changeEditMode = (mode: boolean) => {
setEditMode(mode);
setWidgets(
widgets.map(w => {
if (!mode) {
const newWidgets = widgets.map(w => {
const resizable = w.resizable === false ? false : mode;
const movable = w.movable === false ? false : mode;
return {
...w,
layout: { ...w.layout, isDraggable: movable, isResizable: resizable },
};
}),
);
});
storeWidgets(newWidgets);
}
};
const handleLayoutChange = (newLayout: Layout[], _: Layouts) => {
@@ -329,6 +345,11 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
);
};
const handleCancel = () => {
setWidgets(storedWidgets);
setEditMode(false);
};
if (isStorageLoading) {
return <Progress />;
}
@@ -344,6 +365,7 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
changeEditMode={changeEditMode}
defaultConfigAvailable={props.config !== undefined}
restoreDefault={handleRestoreDefaultConfig}
cancel={handleCancel}
/>
</ContentHeader>
<Dialog
+1
View File
@@ -30,6 +30,7 @@ export const homeTranslationRef = createTranslationRef({
clearAll: 'Clear all',
addWidget: 'Add widget',
save: 'Save',
cancel: 'Cancel',
},
customHomepage: {
noWidgets: "No widgets added. Start by clicking the 'Add widget' button.",