diff --git a/src/misc/fileContext.tsx b/src/misc/fileContext.tsx index ebdc1672..48a70b29 100644 --- a/src/misc/fileContext.tsx +++ b/src/misc/fileContext.tsx @@ -178,20 +178,27 @@ export type FileContextType = { location: string, fileDesc: FileDescription, pathname?: string, - pvwsHost?: string + pvwsHost?: string, + replace?: boolean + ) => void; + removePage: ( + location: string, + fileDesc?: FileDescription, + replace?: boolean ) => void; - removePage: (location: string, fileDesc?: FileDescription) => void; addTab: ( location: string, tabName: string, - fileDesc: FileDescription + fileDesc: FileDescription, + replace?: boolean ) => void; removeTab: ( location: string, tabName: string, - fileDesc: FileDescription + fileDesc: FileDescription, + replace?: boolean ) => void; - selectTab: (location: string, index: number) => void; + selectTab: (location: string, index: number, replace?: boolean) => void; }; // React.useContext(FileContext) gives access to each of the @@ -249,7 +256,8 @@ export const FileProvider: React.FC = ( location: string, fileDesc: FileDescription, pathname?: string, - pvwsHost?: string + pvwsHost?: string, + replace?: boolean ): void => { dispatch(setPvwsSettings({ pvwsHost })); const newPageState = addPage(pageState, location, fileDesc); @@ -258,23 +266,28 @@ export const FileProvider: React.FC = ( pageState: newPageState, tabState: historyLocation.state?.tabState ?? tabState }, - replace: false + replace: replace ?? false }); }, - removePage: (location: string, fileDesc?: FileDescription): void => { + removePage: ( + location: string, + fileDesc?: FileDescription, + replace?: boolean + ): void => { const newPageState = removePage(pageState, location, fileDesc); navigate(historyLocation.pathname, { state: { pageState: newPageState, tabState: historyLocation.state?.tabState ?? tabState }, - replace: false + replace: replace ?? false }); }, addTab: ( location: string, tabName: string, - fileDesc: FileDescription + fileDesc: FileDescription, + replace?: boolean ): void => { const newTabState = addTab(tabState, location, tabName, fileDesc); navigate(historyLocation.pathname, { @@ -282,13 +295,14 @@ export const FileProvider: React.FC = ( pageState: historyLocation.state?.pageState ?? pageState, tabState: newTabState }, - replace: false + replace: replace ?? false }); }, removeTab: ( location: string, tabName: string, - fileDesc: FileDescription + fileDesc: FileDescription, + replace?: boolean ): void => { const newTabState = removeTab(tabState, location, tabName, fileDesc); navigate(historyLocation.pathname, { @@ -296,17 +310,17 @@ export const FileProvider: React.FC = ( pageState: historyLocation.state?.pageState ?? pageState, tabState: newTabState }, - replace: false + replace: replace ?? false }); }, - selectTab: (location: string, index: number): void => { + selectTab: (location: string, index: number, replace?: boolean): void => { const newTabState = selectTab(tabState, location, index); navigate(historyLocation.pathname, { state: { pageState: historyLocation.state?.pageState ?? pageState, tabState: newTabState }, - replace: false + replace: replace ?? false }); } }; diff --git a/src/redux/slices/fileCacheSlice.test.ts b/src/redux/slices/fileCacheSlice.test.ts index b33c7850..f87f0a38 100644 --- a/src/redux/slices/fileCacheSlice.test.ts +++ b/src/redux/slices/fileCacheSlice.test.ts @@ -679,6 +679,58 @@ describe("createDisplayInstanceFromQuickScreen", () => { expect(result.displayInstanceIndex[hash]).toBeDefined(); expect(result.fileCache["my screen"]).toBeDefined(); }); + it("sets editable to true when creating an editable display instance", () => { + const state: FileCacheState = { + fileCache: { + "file.bob": { + id: "root", + type: "display", + fileId: "file.bob", + position: newAbsolutePosition("0", "0", "100", "100") + } + }, + displayInstanceCache: {}, + displayInstanceIndex: {} + }; + + const action = createDisplayInstanceFromFile({ + file: "file.bob", + macros: {}, + editable: true + }); + + const result = fileCacheReducer(state, action); + + const instance = Object.values(result.displayInstanceCache)[0]; + + expect(instance.description.editable).toBe(true); + }); + it("sets editable to false when creating a non-editable display instance", () => { + const state: FileCacheState = { + fileCache: { + "file.bob": { + id: "root", + type: "display", + fileId: "file.bob", + position: newAbsolutePosition("0", "0", "100", "100") + } + }, + displayInstanceCache: {}, + displayInstanceIndex: {} + }; + + const action = createDisplayInstanceFromFile({ + file: "file.bob", + macros: {}, + editable: false + }); + + const result = fileCacheReducer(state, action); + + const instance = Object.values(result.displayInstanceCache)[0]; + + expect(instance.description.editable).toBe(false); + }); }); describe("displayInstanceUpdateGridLayout", () => { @@ -783,6 +835,55 @@ describe("displayInstanceUpdateGridLayout", () => { expect(result).toEqual(badState); }); + it("deletes a widget when update type is delete", () => { + const state: FileCacheState = { + fileCache: {}, + displayInstanceCache: { + UUID1: { + uuid: "UUID1", + fileId: "file.bob", + macros: {}, + hash: "", + description: { + id: "display1", + type: "displayGridLayout", + fileId: "file.bob", + children: [ + { + id: "child1", + type: "shape", + fileId: "file.bob" + }, + { + id: "child2", + type: "shape", + fileId: "file.bob" + } + ] + } as any + } + }, + displayInstanceIndex: {} + }; + + const result = fileCacheReducer( + state, + displayInstanceUpdateGridLayout({ + embeddedDisplayUuid: "UUID1", + gridDisplayId: "display1", + gridLayout: [{ i: "child2", x: 0, y: 0, w: 2, h: 2 }], + update: { + type: "delete", + widgetId: "child1" + } + }) + ); + + const children = result.displayInstanceCache.UUID1.description.children; + + expect(children).toHaveLength(1); + expect(children?.[0].id).toBe("child2"); + }); }); describe("displayInstanceUpdateResponsiveLayout", () => { @@ -852,6 +953,60 @@ describe("displayInstanceUpdateResponsiveLayout", () => { expect(result).toEqual(state); }); + it("deletes a widget when update type is delete", () => { + const state: FileCacheState = { + fileCache: {}, + displayInstanceCache: { + uuid1: { + uuid: "uuid1", + fileId: "file", + macros: {}, + hash: "", + description: { + id: "display1", + type: "displayResponsive", + responsiveLayouts: {}, + children: [ + { + id: "child1", + type: "shape", + fileId: "file" + }, + { + id: "child2", + type: "shape", + fileId: "file" + } + ] + } as any + } + }, + displayInstanceIndex: {} + }; + + const layouts = { + lg: [{ i: "child2", x: 0, y: 0, w: 2, h: 2 }] + }; + + const result = fileCacheReducer( + state, + displayInstanceUpdateResponsiveLayout({ + embeddedDisplayUuid: "uuid1", + displayId: "display1", + responsiveLayouts: layouts, + update: { + type: "delete", + widgetId: "child1" + } + }) + ); + + const display = result.displayInstanceCache.uuid1.description; + + expect(display.responsiveLayouts).toEqual(layouts); + expect(display.children).toHaveLength(1); + expect(display.children?.[0].id).toBe("child2"); + }); }); describe("convertDisplayInstanceType", () => { diff --git a/src/redux/slices/fileCacheSlice.ts b/src/redux/slices/fileCacheSlice.ts index 06eddd6b..a809cb69 100644 --- a/src/redux/slices/fileCacheSlice.ts +++ b/src/redux/slices/fileCacheSlice.ts @@ -14,7 +14,10 @@ import { Position } from "../../types"; import { MacroMap } from "../../types/macros"; import stringify from "safe-stable-stringify"; import { Breakpoints, Layout, ResponsiveLayouts } from "react-grid-layout"; -import { resolveWidgetPathsAndMacros } from "./fileCacheSliceUtils"; +import { + deleteWidgetById, + resolveWidgetPathsAndMacros +} from "./fileCacheSliceUtils"; export interface FileCache { [fileId: string]: WidgetDescription; @@ -46,6 +49,12 @@ export interface FileCacheState { }; } +export interface DisplayInstanceUpdateType { + type: string; // is either add, delete or move + widgetId: string; + widgetDescription?: WidgetDescription; +} + const initialState: FileCacheState = { fileCache: {}, displayInstanceCache: {}, @@ -128,22 +137,32 @@ const fileCacheSlice = createSlice({ embeddedDisplayUuid: string; gridDisplayId: string; gridLayout: Layout; + update?: DisplayInstanceUpdateType; }> ) { - const { embeddedDisplayUuid, gridDisplayId, gridLayout } = action.payload; + const { embeddedDisplayUuid, gridDisplayId, gridLayout, update } = + action.payload; const displayInstance = state.displayInstanceCache?.[embeddedDisplayUuid]; if (!displayInstance) { return; } - const display = findWidgetById( [displayInstance.description], gridDisplayId ); if (!display || display.type !== "displayGridLayout") return; + display.gridLayout = gridLayout; + if (!update) return; + // Check what the update type is + if (update.type === "delete") { + // Find and remove widget by id + display.children = deleteWidgetById(display.children, update.widgetId); + } else if (update.type === "add") { + // TO DO - add support for adding widgets + } }, displayInstanceSetResponsiveLayout( @@ -200,9 +219,10 @@ const fileCacheSlice = createSlice({ embeddedDisplayUuid: string; displayId: string; responsiveLayouts: ResponsiveLayouts; + update?: DisplayInstanceUpdateType; }> ) { - const { embeddedDisplayUuid, displayId, responsiveLayouts } = + const { embeddedDisplayUuid, displayId, responsiveLayouts, update } = action.payload; const displayInstance = state.displayInstanceCache[embeddedDisplayUuid]; @@ -210,10 +230,18 @@ const fileCacheSlice = createSlice({ if (!display || display.type !== "displayResponsive") return; display.responsiveLayouts = responsiveLayouts; + if (!update) return; + // Check what the update type is + if (update.type === "delete") { + // Find and remove widget by id + display.children = deleteWidgetById(display.children, update.widgetId); + } else if (update.type === "add") { + // TO DO - add support for adding widgets + } }, createDisplayInstanceFromFile(state, action) { - const { file, macros } = action.payload; + const { file, macros, editable } = action.payload; const fileDescription = state.fileCache?.[file]; const hash = `${file}::${stringify(macros)}`; @@ -231,6 +259,8 @@ const fileCacheSlice = createSlice({ injectFieldsIntoAllDescriptions(description, { embeddedDisplayUuid: uuid }); + // If the file is loaded in an editable view, set editable prop + description.editable = editable ? true : false; if (state.displayInstanceCache) { const parentDir = file.slice(0, file.lastIndexOf("/")); @@ -298,6 +328,7 @@ const fileCacheSlice = createSlice({ instance.description, displayType ); + state.displayInstanceCache[id] = instance; } }, diff --git a/src/redux/slices/fileCacheSliceUtils.test.ts b/src/redux/slices/fileCacheSliceUtils.test.ts index 9ec69d3d..2adaa7df 100644 --- a/src/redux/slices/fileCacheSliceUtils.test.ts +++ b/src/redux/slices/fileCacheSliceUtils.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi } from "vitest"; -import { resolveWidgetPathsAndMacros } from "./fileCacheSliceUtils"; +import { + deleteWidgetById, + resolveWidgetPathsAndMacros +} from "./fileCacheSliceUtils"; import { resolveAndNormaliseWidgetPaths } from "../../ui/widgets/EmbeddedDisplay/parserPatcherUtils"; import { WidgetDescription } from "../../ui/widgets/createComponent"; @@ -14,6 +17,8 @@ vi.mock("../../ui/widgets/EmbeddedDisplay/parserPatcherUtils", () => ({ }) })); +const DEFAULT_WIDGET = { type: "display", fileId: "test" }; + describe("resolveWidgetPathsAndMacros", () => { afterEach(() => { vi.resetAllMocks(); @@ -162,3 +167,75 @@ describe("resolveWidgetPathsAndMacros", () => { expect(result).toBe(widget); }); }); + +describe("deleteWidgetById()", () => { + it("returns undefined when children are undefined", () => { + expect(deleteWidgetById(undefined, "widget-1")).toBeUndefined(); + }); + + it("returns an empty array when no children", () => { + expect(deleteWidgetById([], "widget-1")).toEqual([]); + }); + + it("deletes a widget from the top level", () => { + const children = [ + { ...DEFAULT_WIDGET, id: "widget-1" }, + { ...DEFAULT_WIDGET, id: "widget-2" } + ]; + + expect(deleteWidgetById(children, "widget-1")).toEqual([ + { type: "display", fileId: "test", id: "widget-2" } + ]); + }); + + it("keeps all widgets when the id to delete does not exist", () => { + const children = [ + { ...DEFAULT_WIDGET, id: "widget-1" }, + { ...DEFAULT_WIDGET, id: "widget-2" } + ]; + + expect(deleteWidgetById(children, "widget-3")).toEqual([ + { type: "display", fileId: "test", id: "widget-1" }, + { type: "display", fileId: "test", id: "widget-2" } + ]); + }); + + it("deletes a nested widget", () => { + const children = [ + { + ...DEFAULT_WIDGET, + id: "parent", + children: [ + { ...DEFAULT_WIDGET, id: "child-1" }, + { ...DEFAULT_WIDGET, id: "child-2" } + ] + } + ]; + expect(deleteWidgetById(children, "child-1")).toEqual([ + { + type: "display", + fileId: "test", + id: "parent", + children: [{ type: "display", fileId: "test", id: "child-2" }] + } + ]); + }); + + it("returns an empty children array when the last nested widget is deleted", () => { + const children = [ + { + ...DEFAULT_WIDGET, + id: "parent", + children: [{ ...DEFAULT_WIDGET, id: "child" }] + } + ]; + expect(deleteWidgetById(children, "child")).toEqual([ + { + type: "display", + fileId: "test", + id: "parent", + children: [] + } + ]); + }); +}); diff --git a/src/redux/slices/fileCacheSliceUtils.ts b/src/redux/slices/fileCacheSliceUtils.ts index c4f899a5..6263aa60 100644 --- a/src/redux/slices/fileCacheSliceUtils.ts +++ b/src/redux/slices/fileCacheSliceUtils.ts @@ -55,3 +55,31 @@ export const resolveWidgetPathsAndMacros = ( return widgetDescription; }; + +/** + * Iterate over widget children and removed named widget + * @param children array of widget descriptions + * @param widgetId string id of widget to remove + * @returns array of widget descriptions, with named widget removed + */ +export const deleteWidgetById = ( + children: WidgetDescription[] | undefined, + widgetId: string +): WidgetDescription[] | undefined => { + if (!children) { + return undefined; + } + + return children + .filter(child => child.id !== widgetId) + .map(child => { + if (!child.children) { + return { ...child }; + } + + return { + ...child, + children: deleteWidgetById(child.children, widgetId) + }; + }); +}; diff --git a/src/ui/hooks/useFile.test.tsx b/src/ui/hooks/useFile.test.tsx index e052c23b..eb59280f 100644 --- a/src/ui/hooks/useFile.test.tsx +++ b/src/ui/hooks/useFile.test.tsx @@ -39,9 +39,14 @@ const parsedWidget = { position: { positionType: PositionType.RELATIVE } }; -const FileTester = (props: { file: File }): JSX.Element => { +const FileTester = (props: { file: File; editable?: boolean }): JSX.Element => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [contents, _] = useFile(props.file); + const [contents, _] = useFile( + props.file, + undefined, + undefined, + props.editable + ); return
contents: {JSON.stringify(contents)}
; }; @@ -219,4 +224,57 @@ describe("useFile", (): void => { expect(text).toContain('"fileId":"test.json"'); }); }); + it("sets editable mode when creating a display instance from cached file", async () => { + const file = { + path: "test.json", + defaultProtocol: "ca", + macros: {} + }; + + mockedUseSelector + .mockReturnValueOnce(null) + .mockReturnValueOnce(parsedWidget); + + contextRender(); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + createDisplayInstanceFromFile({ + file: "test.json", + macros: {}, + editable: true + }) + ); + }); + }); + it("sets editable mode when creating a display instance after fetching", async () => { + const file = { + path: "test.json", + defaultProtocol: "ca", + macros: {} + }; + + const mockResponse = JSON.stringify({ + id: "1234", + type: "ellipse" + }); + + mockedHttpRequest.mockResolvedValue({ + text: () => Promise.resolve(mockResponse) + }); + + mockedUseSelector.mockReturnValueOnce(null).mockReturnValueOnce(null); + + contextRender(); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + createDisplayInstanceFromFile({ + file: "test.json", + macros: {}, + editable: true + }) + ); + }); + }); }); diff --git a/src/ui/hooks/useFile.tsx b/src/ui/hooks/useFile.tsx index 140f4c93..2c22dff2 100644 --- a/src/ui/hooks/useFile.tsx +++ b/src/ui/hooks/useFile.tsx @@ -79,7 +79,8 @@ export async function fetchAndConvert( export function useFile( file: File, macros?: MacroMap, - targetDisplayType?: string + targetDisplayType?: string, + editable?: boolean ): [WidgetDescription, string] { const dispatch = useDispatch(); @@ -105,7 +106,8 @@ export function useFile( dispatch( createDisplayInstanceFromFile({ file: file.path, - macros: macros ?? {} + macros: macros ?? {}, + editable: editable }) ); if (targetDisplayType) @@ -124,7 +126,11 @@ export function useFile( fetchData(); } else if (displayInstance == null) { dispatch( - createDisplayInstanceFromFile({ file: file.path, macros: macros ?? {} }) + createDisplayInstanceFromFile({ + file: file.path, + macros: macros ?? {}, + editable: editable + }) ); if (targetDisplayType) dispatch( @@ -147,7 +153,8 @@ export function useFile( dispatch, macros, displayInstance, - targetDisplayType + targetDisplayType, + editable ]); return [ diff --git a/src/ui/widgets/DisplayReactGridLayout/displayGridLayout.test.tsx b/src/ui/widgets/DisplayReactGridLayout/displayGridLayout.test.tsx index f718db9a..f553707e 100644 --- a/src/ui/widgets/DisplayReactGridLayout/displayGridLayout.test.tsx +++ b/src/ui/widgets/DisplayReactGridLayout/displayGridLayout.test.tsx @@ -71,7 +71,8 @@ vi.mock("../../hooks/useStyle", () => ({ })); const mocks = vi.hoisted(() => ({ - calculateDefaultLayout: vi.fn() + calculateDefaultLayout: vi.fn(), + displayInstanceUpdateGridLayout: vi.fn() })); vi.mock("./displayLayoutUtilities", () => ({ @@ -79,8 +80,8 @@ vi.mock("./displayLayoutUtilities", () => ({ toNumber: (v: any, fallback: number) => Number(v ?? fallback) })); -vi.mock("../../../redux/csState", async () => { - const actual = await vi.importActual("../../../redux/csState"); +vi.mock("../../../redux/slices/fileCacheSlice", async () => { + const actual = await vi.importActual("../../../redux/slices/fileCacheSlice"); return { ...actual, makeSelectWidgetPosition: () => { @@ -88,7 +89,8 @@ vi.mock("../../../redux/csState", async () => { width: 1200 }); }, - fileDisplaySetRGLayout: vi.fn() + displayInstanceSetGridLayout: vi.fn(), + displayInstanceUpdateGridLayout: mocks.displayInstanceUpdateGridLayout }; }); @@ -340,4 +342,50 @@ describe("DisplayGridLayoutComponent", () => { fireEvent.click(overlay); expect(childClickHandler).not.toHaveBeenCalled(); }); + it("renders delete buttons when editable", () => { + renderGrid({ + editable: true, + gridLayout: [ + { i: "a", w: 8, h: 4 }, + { i: "b", w: 8, h: 4 } + ] + }); + + expect(screen.getByLabelText("Delete widget a")).toBeInTheDocument(); + + expect(screen.getByLabelText("Delete widget b")).toBeInTheDocument(); + }); + + it("does not render delete buttons when not editable", () => { + renderGrid({ + editable: false, + gridLayout: [{ i: "a", w: 8, h: 4 }] + }); + + expect(screen.queryByLabelText("Delete widget a")).not.toBeInTheDocument(); + }); + it("deletes a widget when the delete button is clicked", () => { + renderGrid({ + editable: true, + embeddedDisplayUuid: "display-1", + gridLayout: [ + { i: "a", x: 0, y: 0, w: 8, h: 4 }, + { i: "b", x: 8, y: 0, w: 8, h: 4 } + ] + }); + + const deleteButton = screen.getByLabelText("Delete widget a"); + + fireEvent.click(deleteButton); + + expect(mocks.displayInstanceUpdateGridLayout).toHaveBeenCalledWith({ + embeddedDisplayUuid: "display-1", + gridDisplayId: "grid-test", + gridLayout: [{ i: "b", x: 8, y: 0, w: 8, h: 4 }], + update: { + type: "delete", + widgetId: "a" + } + }); + }); }); diff --git a/src/ui/widgets/DisplayReactGridLayout/displayGridLayout.tsx b/src/ui/widgets/DisplayReactGridLayout/displayGridLayout.tsx index b71c3622..7369ecae 100644 --- a/src/ui/widgets/DisplayReactGridLayout/displayGridLayout.tsx +++ b/src/ui/widgets/DisplayReactGridLayout/displayGridLayout.tsx @@ -45,6 +45,8 @@ import { makeSelectWidgetPosition } from "../../../redux/slices/fileCacheSlice"; import { useDispatch, useSelector } from "react-redux"; +import IconButton from "@mui/material/IconButton"; +import CancelIcon from "@mui/icons-material/Cancel"; const widgetName = "displayGridLayout"; @@ -69,7 +71,8 @@ const DisplayGridLayoutProps = { gridCellHeight: IntPropOpt, gridCellMargins: IntArrayPropOpt, gridLayoutColumns: IntPropOpt, - gridLayout: ObjectArrayPropOpt + gridLayout: ObjectArrayPropOpt, + editable: BoolPropOpt }; const overlapCompactor = getCompactor( @@ -217,6 +220,28 @@ export const DisplayGridLayoutComponent = ( cols: columns }); + const handleDelete = React.useCallback( + (id: string, event: React.MouseEvent) => { + // Prevent default actions on click e.g drag + event.preventDefault(); + event.stopPropagation(); + + const newLayout = layout.filter(item => item.i !== id); + dispatch( + displayInstanceUpdateGridLayout({ + embeddedDisplayUuid: props.embeddedDisplayUuid, + gridDisplayId: props.id, + gridLayout: newLayout, + update: { + type: "delete", + widgetId: id + } + }) + ); + }, + [dispatch, layout, props.embeddedDisplayUuid, props.id] + ); + // Wrap the child components in a div keyed by the child id. The key MUST map to the i field of Layout item for the component. const gridChildren = useMemo( () => @@ -232,8 +257,48 @@ export const DisplayGridLayoutComponent = ( return (
+ {props.editable && ( + { + // Prevent default drag action + e.preventDefault(); + e.stopPropagation(); + }} + onClick={e => handleDelete(id, e)} + sx={{ + position: "absolute", + top: 4, + right: 4, + zIndex: 20, + width: 24, + height: 24, + padding: 0, + backgroundColor: "rgba(255, 255, 255, 0.9)", + color: "error", + // Invisible by default + opacity: 0, + visibility: "hidden", + pointerEvents: "none", + // Show on hover + ".display-grid-layout-child:hover &": { + opacity: 1, + visibility: "visible", + pointerEvents: "auto" + }, + "&:hover": { + backgroundColor: "#fff", + color: "error.dark" + } + }} + > + + + )} {child} {isActiveDragging && ( // overlay captures clicks during drag so child won't toggle @@ -257,7 +322,14 @@ export const DisplayGridLayoutComponent = (
); }), - [childrenArray, gridCellDragEnabled, isInteracting, dragState] + [ + childrenArray, + gridCellDragEnabled, + isInteracting, + dragState, + handleDelete, + props.editable + ] ); return ( diff --git a/src/ui/widgets/DisplayReactGridLayout/displayResponsive.test.tsx b/src/ui/widgets/DisplayReactGridLayout/displayResponsive.test.tsx index cbf4cecf..5fdee146 100644 --- a/src/ui/widgets/DisplayReactGridLayout/displayResponsive.test.tsx +++ b/src/ui/widgets/DisplayReactGridLayout/displayResponsive.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import { DisplayResponsiveComponent } from "./displayResponsive"; @@ -14,6 +14,13 @@ let capturedCols: any; let capturedDragEnabled: boolean | undefined; let capturedResizeEnabled: boolean | undefined; +const mocks = vi.hoisted(() => ({ + displayInstanceUpdateResponsiveLayout: vi.fn(), + calculateDefaultLayoutWithHorizontalCompactor: vi.fn(() => [ + { i: "mock", x: 0, y: 0, w: 1, h: 1 } + ]) +})); + vi.mock("react-grid-layout", async () => { const actual = await vi.importActual("react-grid-layout"); @@ -36,7 +43,9 @@ vi.mock("react-grid-layout", async () => { return
{children}
; }, - + useResponsiveLayout: vi.fn(({ layouts }) => ({ + layouts + })), useContainerWidth: () => ({ width: 1200, mounted: true, @@ -60,14 +69,25 @@ vi.mock("react-redux", () => ({ }) })); +vi.mock("../../../redux/slices/fileCacheSlice", async () => { + const actual = await vi.importActual( + "../../../redux/slices/fileCacheSlice" + ); + + return { + ...actual, + displayInstanceUpdateResponsiveLayout: + mocks.displayInstanceUpdateResponsiveLayout + }; +}); + vi.mock("./displayLayoutUtilities", async () => { const actual = await vi.importActual("./displayLayoutUtilities"); return { ...actual, - calculateDefaultLayoutWithHorizontalCompactor: vi.fn(() => [ - { i: "mock", x: 0, y: 0, w: 1, h: 1 } - ]) + calculateDefaultLayoutWithHorizontalCompactor: + mocks.calculateDefaultLayoutWithHorizontalCompactor }; }); @@ -388,4 +408,85 @@ describe("DisplayResponsiveComponent – high‑value behaviors", () => { { i: "mock", x: 0, y: 0, w: 1, h: 1 } ]); }); + it("renders delete buttons when editable", () => { + render( + + + + + ); + + expect(screen.getByLabelText("Delete widget a")).toBeInTheDocument(); + + expect(screen.getByLabelText("Delete widget b")).toBeInTheDocument(); + }); + + it("does not render delete buttons when not editable", () => { + render( + + + + ); + + expect(screen.queryByLabelText("Delete widget a")).not.toBeInTheDocument(); + }); + it("deletes a widget from all responsive layouts when the delete button is clicked", () => { + render( + + + + + ); + + const deleteButton = screen.getByLabelText("Delete widget a"); + + fireEvent.click(deleteButton); + + expect(mocks.displayInstanceUpdateResponsiveLayout).toHaveBeenCalledWith({ + embeddedDisplayUuid: "uuid1", + displayId: "display-1", + responsiveLayouts: { + lg: [{ i: "b", x: 4, y: 0, w: 4, h: 3 }], + md: [{ i: "b", x: 3, y: 0, w: 5, h: 2 }] + }, + update: { + type: "delete", + widgetId: "a" + } + }); + }); }); diff --git a/src/ui/widgets/DisplayReactGridLayout/displayResponsive.tsx b/src/ui/widgets/DisplayReactGridLayout/displayResponsive.tsx index 37312ef4..fac14ad8 100644 --- a/src/ui/widgets/DisplayReactGridLayout/displayResponsive.tsx +++ b/src/ui/widgets/DisplayReactGridLayout/displayResponsive.tsx @@ -13,7 +13,8 @@ import { useContainerWidth, Breakpoints, useResponsiveLayout, - Breakpoint + Breakpoint, + Layout } from "react-grid-layout"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; @@ -53,6 +54,8 @@ import { } from "../../../redux/slices/fileCacheSlice"; import log from "loglevel"; import { Dispatch } from "@reduxjs/toolkit"; +import IconButton from "@mui/material/IconButton"; +import CancelIcon from "@mui/icons-material/Cancel"; const widgetName = "displayResponsive"; @@ -78,7 +81,8 @@ const DisplayResponsiveProps = { gridCellDragEnabled: BoolPropOpt, gridCellResizeEnabled: BoolPropOpt, gridCellMargins: IntArrayPropOpt, - gridCellHeight: IntPropOpt + gridCellHeight: IntPropOpt, + editable: BoolPropOpt }; type propsType = InferWidgetProps & { @@ -246,10 +250,46 @@ export const DisplayResponsiveComponent = (props: propsType): JSX.Element => { width: 100 }); + const handleDelete = useCallback( + (id: string, event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const newLayouts = Object.entries(layouts).reduce( + (acc, [breakpoint, layout]) => { + acc[breakpoint as Breakpoint] = (layout as Layout).filter( + item => item.i !== id + ); + return acc; + }, + {} as ResponsiveLayouts + ); + + dispatch( + displayInstanceUpdateResponsiveLayout({ + embeddedDisplayUuid: props.embeddedDisplayUuid, + displayId: props.id, + responsiveLayouts: newLayouts, + update: { + type: "delete", + widgetId: id + } + }) + ); + }, + [dispatch, layouts, props.embeddedDisplayUuid, props.id] + ); + // Wrap the child components in a div keyed by the child id. The key MUST map to the i field of Layout item for the component. const gridChildren = useMemo( - () => wrapChildrenForGridLayout(childrenArray, gridCellDragEnabled), - [childrenArray, gridCellDragEnabled] + () => + wrapChildrenForGridLayout( + childrenArray, + gridCellDragEnabled, + newProps.editable, + handleDelete + ), + [childrenArray, gridCellDragEnabled, newProps.editable, handleDelete] ); const hasLayouts = useMemo(() => { @@ -429,7 +469,9 @@ const wrapChildrenForGridLayout = ( PVWidgetComponent, string | React.JSXElementConstructor >[], - gridCellDragEnabled: boolean + gridCellDragEnabled: boolean, + editable: boolean | undefined, + handleDelete: (id: string, event: React.MouseEvent) => void ) => { return childrenArray.map(child => { const id = child.props.id; @@ -440,8 +482,48 @@ const wrapChildrenForGridLayout = ( return (
+ {editable && ( + { + // Prevent default drag action + e.preventDefault(); + e.stopPropagation(); + }} + onClick={e => handleDelete(id, e)} + sx={{ + position: "absolute", + top: 4, + right: 4, + zIndex: 20, + width: 24, + height: 24, + padding: 0, + backgroundColor: "rgba(255, 255, 255, 0.9)", + color: "error", + // Invisible by default + opacity: 0, + visibility: "hidden", + pointerEvents: "none", + // Show on hover + ".display-grid-layout-child:hover &": { + opacity: 1, + visibility: "visible", + pointerEvents: "auto" + }, + "&:hover": { + backgroundColor: "#fff", + color: "error.dark" + } + }} + > + + + )} {child}
); diff --git a/src/ui/widgets/DynamicPage/dynamicPage.tsx b/src/ui/widgets/DynamicPage/dynamicPage.tsx index 5c274b85..897769b6 100644 --- a/src/ui/widgets/DynamicPage/dynamicPage.tsx +++ b/src/ui/widgets/DynamicPage/dynamicPage.tsx @@ -39,7 +39,8 @@ const DynamicPageProps = { showCloseButton: BoolPropOpt, scroll: BoolPropOpt, mjpgEndpoint: StringPropOpt, - targetDisplayType: StringPropOpt + targetDisplayType: StringPropOpt, + editable: BoolPropOpt }; type DynamicPageComponentProps = InferWidgetProps & @@ -150,6 +151,7 @@ export const DynamicPageComponent = ( )} widgetIdsCallback={props?.widgetIdsCallback} targetDisplayType={newProps.targetDisplayType} + editable={newProps.editable} /> ); diff --git a/src/ui/widgets/EmbeddedDisplay/embeddedDisplay.tsx b/src/ui/widgets/EmbeddedDisplay/embeddedDisplay.tsx index ff80c937..989ce34d 100644 --- a/src/ui/widgets/EmbeddedDisplay/embeddedDisplay.tsx +++ b/src/ui/widgets/EmbeddedDisplay/embeddedDisplay.tsx @@ -57,7 +57,8 @@ const EmbeddedDisplayProps = { macros: MacrosPropOpt, groupName: StringPropOpt, rules: RulesPropOpt, - targetDisplayType: StringPropOpt + targetDisplayType: StringPropOpt, + editable: BoolPropOpt }; export const EmbeddedDisplay = ( @@ -92,7 +93,8 @@ export const EmbeddedDisplay = ( const [description, embeddedDisplayUuid] = useFile( resolvedProps.file as File, embeddedDisplayMacroContext.macros, - props.targetDisplayType + props.targetDisplayType, + props.editable ); const widgetIdsCallback = props?.widgetIdsCallback; diff --git a/src/ui/widgets/widgetActions.ts b/src/ui/widgets/widgetActions.ts index d4a4a60f..fb02b90c 100644 --- a/src/ui/widgets/widgetActions.ts +++ b/src/ui/widgets/widgetActions.ts @@ -121,43 +121,47 @@ export const openPage = ( action: DynamicAction, fileContext?: FileContextType, parentMacros?: MacroMap, - pathname?: string + pathname?: string, + replace?: boolean ): void => { const { location, file, pvwsHost } = action.dynamicInfo; file.macros = { ...(parentMacros ?? {}), ...file.macros }; - fileContext?.addPage(location, file, pathname, pvwsHost); + fileContext?.addPage(location, file, pathname, pvwsHost, replace); }; export const closePage = ( action: DynamicAction, - fileContext?: FileContextType + fileContext?: FileContextType, + replace?: boolean ): void => { const { location, file } = action.dynamicInfo; - fileContext?.removePage(location, file); + fileContext?.removePage(location, file, replace); }; export const openTab = ( action: DynamicAction, fileContext?: FileContextType, - parentMacros?: MacroMap + parentMacros?: MacroMap, + replace?: boolean ): void => { const { name, location, file } = action.dynamicInfo; file.macros = { ...(parentMacros ?? {}), ...file.macros }; - fileContext?.addTab(location, name, file); + fileContext?.addTab(location, name, file, replace); }; export const closeTab = ( action: DynamicAction, - fileContext: FileContextType + fileContext: FileContextType, + replace?: boolean ): void => { const { name, location, file } = action.dynamicInfo; - fileContext.removeTab(location, name, file); + fileContext.removeTab(location, name, file, replace); }; export const executeAction = ( @@ -165,33 +169,34 @@ export const executeAction = ( files?: FileContextType, exitContext?: ExitContextType, parentMacros?: MacroMap, - pathname?: string + pathname?: string, + replace?: boolean ): void => { switch (action.type) { case OPEN_PAGE: if (files) { - openPage(action, files, parentMacros, pathname); + openPage(action, files, parentMacros, pathname, replace); } else { log.error("Tried to open a page but no file context passed"); } break; case CLOSE_PAGE: if (files) { - closePage(action, files); + closePage(action, files, replace); } else { log.error("Tried to open a page but no file context passed"); } break; case OPEN_TAB: if (files) { - openTab(action, files, parentMacros); + openTab(action, files, parentMacros, replace); } else { log.error("Tried to open a page but no file context passed"); } break; case CLOSE_TAB: if (files) { - closeTab(action, files); + closeTab(action, files, replace); } else { log.error("Tried to open a page but no file context passed"); }