feat(home): improve widget editing state management
Signed-off-by: Mikko Korhonen <mikko.korhonen@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user