From 81b92d92db51dc8481b76b60c5fcca5c30a2527d Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sun, 14 Jan 2024 23:35:04 +0100 Subject: [PATCH] TELESTION-460 Dashboard layout editor A layout editor for dashboards to make them configurable by users. Interaction can be performed using both the mouse and the keyboard. Changes are handled completely independently from the UI, allowing for both an easier way to ensure invalid states cannot occur as well as testability. During the user interaction, no actual state changes are performed. Once the interaction finishes, the changes get interpreted to the closest possible state change and "committed" to the state, where (in compliance with any business logic) the changes are applied and, thus, updates in the UI. This also allows us to implement any interactions without absolute positioning and resize observers. Instead, the native CSS grid can be used. Due to the relative complexity and clear boundaries of this subsystem, a separate folder structure which also includes a `README.md` was created. --- .github/workflows/frontend-react-ci.yml | 1 - frontend-react/package.json | 2 + frontend-react/pnpm-lock.yaml | 37 ++ frontend-react/src/app/index.tsx | 2 +- frontend-react/src/lib/application/index.scss | 1 + .../dashboard-editor.module.scss | 46 ++ .../dashboard-editor/dashboard-editor.tsx | 178 ++++++- .../dashboard-editor/layout-editor/README.md | 64 +++ .../components/action-buttons.tsx | 57 +++ .../layout-editor/components/cursor.svg | 6 + .../layout-editor/components/empty-cell.tsx | 40 ++ .../layout-editor-widget-instance.tsx | 112 +++++ .../components/layout-editor.module.css | 222 +++++++++ .../components/layout-editor.tsx | 215 ++++++++ .../components/resize-button.tsx | 89 ++++ .../components/resize-handle.tsx | 27 + .../layout-editor/constants.tsx | 6 + .../use-layout-editor-keyboard-shortcuts.tsx | 125 +++++ .../dashboard-editor/layout-editor/index.ts | 4 + .../model/layout-editor-model.test.ts | 307 ++++++++++++ .../model/layout-editor-model.ts | 469 ++++++++++++++++++ .../model/layout-editor-props.ts | 55 ++ .../routes/dashboard-editor/routing.ts | 19 +- .../page-wrapper/page-wrapper.module.css | 15 + .../routes/page-wrapper/page-wrapper.tsx | 2 +- .../component/widget-renderer.module.css | 6 + 26 files changed, 2086 insertions(+), 21 deletions(-) create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.module.scss create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/action-buttons.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/cursor.svg create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/constants.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/hooks/use-layout-editor-keyboard-shortcuts.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-props.ts diff --git a/.github/workflows/frontend-react-ci.yml b/.github/workflows/frontend-react-ci.yml index 26e1d02c..e4e03d0f 100644 --- a/.github/workflows/frontend-react-ci.yml +++ b/.github/workflows/frontend-react-ci.yml @@ -109,5 +109,4 @@ jobs: run: pnpm install - name: Run unit tests 🛃 run: | - pnpm run build pnpm run ci:test diff --git a/frontend-react/package.json b/frontend-react/package.json index 64810652..1ddf24cb 100644 --- a/frontend-react/package.json +++ b/frontend-react/package.json @@ -92,6 +92,8 @@ "directory": "frontend-react" }, "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/utilities": "^3.2.2", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.2", "bootstrap-icons": "^1.11.2", diff --git a/frontend-react/pnpm-lock.yaml b/frontend-react/pnpm-lock.yaml index 52151412..8d6e604f 100644 --- a/frontend-react/pnpm-lock.yaml +++ b/frontend-react/pnpm-lock.yaml @@ -5,6 +5,12 @@ settings: excludeLinksFromLockfile: false dependencies: + '@dnd-kit/core': + specifier: ^6.1.0 + version: 6.1.0(react-dom@18.2.0)(react@18.2.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.2.0) '@popperjs/core': specifier: ^2.11.8 version: 2.11.8 @@ -301,6 +307,37 @@ packages: resolution: {integrity: sha512-N43uWud8ZXuVjza423T9ZCIJsaZhFekmakt7S9bvogTxqdVGbRobjR663s0+uW0Rz9e+Pa8I6jUuWtoBLQD2Mw==} dev: true + /@dnd-kit/accessibility@3.1.0(react@18.2.0): + resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.2.0 + tslib: 2.6.2 + dev: false + + /@dnd-kit/core@6.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@dnd-kit/accessibility': 3.1.0(react@18.2.0) + '@dnd-kit/utilities': 3.2.2(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.6.2 + dev: false + + /@dnd-kit/utilities@3.2.2(react@18.2.0): + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.2.0 + tslib: 2.6.2 + dev: false + /@esbuild/android-arm64@0.19.5: resolution: {integrity: sha512-5d1OkoJxnYQfmC+Zd8NBFjkhyCNYwM4n9ODrycTFY6Jk1IGiZ+tjVJDDSwDt77nK+tfpGP4T50iMtVi4dEGzhQ==} engines: {node: '>=12'} diff --git a/frontend-react/src/app/index.tsx b/frontend-react/src/app/index.tsx index dfff2892..54dd81ff 100644 --- a/frontend-react/src/app/index.tsx +++ b/frontend-react/src/app/index.tsx @@ -1,7 +1,7 @@ import { initTelestion, registerWidgets, UserData } from '@wuespace/telestion'; import { simpleWidget } from './widgets/simple-widget'; import { errorWidget } from './widgets/error-widget'; -import { setAutoLoginCredentials } from '../lib/auth'; +import { setAutoLoginCredentials } from '@wuespace/telestion/auth'; const defaultUserData: UserData = { version: '0.0.1', diff --git a/frontend-react/src/lib/application/index.scss b/frontend-react/src/lib/application/index.scss index bc7105c3..d1d53887 100644 --- a/frontend-react/src/lib/application/index.scss +++ b/frontend-react/src/lib/application/index.scss @@ -1,5 +1,6 @@ @import '../../../node_modules/bootstrap/scss/functions'; @import '../../../node_modules/bootstrap/scss/variables'; +@import '../../../node_modules/bootstrap/scss/mixins'; /* @include color-mode(light) { diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.module.scss b/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.module.scss new file mode 100644 index 00000000..4e0d4695 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.module.scss @@ -0,0 +1,46 @@ +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/mixins'; + +.dashboardEditor { + display: grid; + grid-template-areas: 'dashboard layout widget-instance'; + grid-auto-columns: 1fr; + grid-gap: var(--gutter); + height: calc(100vh - var(--navbar-height) - var(--gutter)); + + > * { + background: var(--bs-body-bg); + color: var(--bs-body-color); + border-radius: var(--bs-border-radius); + + overflow-y: auto; + overflow-x: hidden; + } + + .dashboard { + grid-area: dashboard; + } + + .layout { + grid-area: layout; + } + + .widgetInstance { + grid-area: widget-instance; + } + + @include media-breakpoint-down(xl) { + grid-template-areas: 'dashboard layout' 'widget-instance widget-instance'; + height: auto; + + > * { + /*height: auto;*/ + overflow-y: visible; + } + } + + @include media-breakpoint-down(md) { + grid-template-areas: 'dashboard' 'layout' 'widget-instance'; + } +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.tsx index e4cd56c2..202f119b 100644 --- a/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.tsx +++ b/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.tsx @@ -1,10 +1,29 @@ import { z } from 'zod'; -import { dashboardSchema } from '../../../user-data'; +import { dashboardSchema, widgetInstanceSchema } from '../../../user-data'; import { Form, useActionData, useLoaderData } from 'react-router-dom'; +import { useCallback, useState } from 'react'; +import { + LayoutEditor, + LayoutEditorState, + selectedWidgetId as getSelectedWidgetId +} from './layout-editor'; +import styles from './dashboard-editor.module.scss'; +import { clsx } from 'clsx'; +import { + Alert, + FormControl, + FormGroup, + FormLabel, + FormSelect, + FormText +} from 'react-bootstrap'; +import { generateDashboardId } from '@wuespace/telestion/utils'; +import { getWidgetById, getWidgets } from '@wuespace/telestion/widget'; const loaderSchema = z.object({ dashboardId: z.string(), - dashboard: dashboardSchema + dashboard: dashboardSchema, + widgetInstances: z.record(z.string(), widgetInstanceSchema) }); const actionSchema = z @@ -16,23 +35,150 @@ const actionSchema = z .optional(); export function DashboardEditor() { - const { dashboardId, dashboard } = loaderSchema.parse(useLoaderData()); + const { dashboardId, dashboard, widgetInstances } = + loaderSchema.parse(useLoaderData()); const errors = actionSchema.parse(useActionData()); - // TODO: Implement dashboard editor - console.log(errors); + const [localDashboard, setLocalDashboard] = useState({ + layout: dashboard.layout, + selection: { + x: 0, + y: 0 + } + }); + + const [localWidgetInstances, setLocalWidgetInstances] = + useState(widgetInstances); + const onLayoutEditorCreateWidgetInstance = useCallback(() => { + const newId = generateDashboardId(); + const widgetTypes = getWidgets(); + const widgetType = widgetTypes[0]; + + const configuration = widgetType.createConfig({}); + const type = widgetType.id; + + setLocalWidgetInstances({ + ...localWidgetInstances, + [newId]: { + type, + configuration + } + }); + + return newId; + }, [localWidgetInstances]); + + const selectedWidgetId = getSelectedWidgetId(localDashboard); + const selectedWidgetInstance = !selectedWidgetId + ? undefined + : localWidgetInstances[selectedWidgetId]; + + const onFormSelectChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + const widgetType = getWidgetById(value); + if (!widgetType) throw new Error(`Widget type ${value} not found`); + + const selectedWidgetId = getSelectedWidgetId(localDashboard); + if (!selectedWidgetId) throw new Error(`No widget selected`); + + const configuration = widgetType.createConfig( + selectedWidgetInstance?.configuration ?? {} + ); + const type = widgetType.id; + + setLocalWidgetInstances({ + ...localWidgetInstances, + [selectedWidgetId]: { + type, + configuration + } + }); + }, + [ + localDashboard, + localWidgetInstances, + selectedWidgetInstance?.configuration + ] + ); return ( - <> -
Dashboard id: {dashboardId}
-
- - -
- +
+
+
+

Dashboard Metadata

+ {errors && ( + + {errors.errors.layout &&

{errors.errors.layout}

} +
+ )} + + Dashboard ID + + +
+
+

Dashboard Layout

+ + + +
+ + Widget Instance ID + + + This is primarily used by developers to reference the widget. + + + + Widget Instance Type + + {!selectedWidgetId && ( + + )} + {Object.values(getWidgets()).map(widget => ( + + ))} + + Set the type of the widget instance. + +
+
+
+

Widget Configuration

+ {selectedWidgetId ? ( +
+ {getWidgetById(selectedWidgetInstance?.type ?? '')?.configElement} +
+ ) : ( +
Select a widget to configure it.
+ )} +
+
+
); } diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md new file mode 100644 index 00000000..208597f8 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md @@ -0,0 +1,64 @@ +# Layout Editor + +Date: 2024-01-14 + +Designed by: + +- [Zuri Klaschka](https://github.com/pklaschka) + +The layout editor for dashboards. + +## Interface Definition + +You can find the props passed into `LayoutEditor` in +the [`model/layout-editor-props.ts`](./model/layout-editor-props.ts) file. + +Note that apart from the current `value: LayoutEditorState`, all props are optional. + +However, they are required for various functions, like editing the layout, creating new widget instances, etc. + +## Behavior + +```mermaid +zenuml + title Layout Editor Behavior + + @Actor User + @Boundary editor as "Layout Editor" + @Entity state as "State / Parent" + + User->editor.beginInteraction() { + while ("edits not final") { + preview = User->editor.editLayout() + } + User->editor.endInteraction() { + newState = calculateNewState() + updatedState = state.update(newState) + return updatedState + } + } +``` + +Note that the state is not updated until the user ends the interaction. + +There are therefore two very distinct phases during an interaction: + +1. the preview phase where any changes are visualized in real-time to the user. +2. the commit phase where the changes are interpolated to the closest applicable change (rounded to full grid cell + units, etc.), and actually applied to the state. + +This means that the application to the state can be performed without any regard to the actual user interaction, and +written and tested independently. + +## State Management + +The state is considered to be immutable. Updates to the state are performed using pure functions that return the new +state based on the previous state and the new data. + +## User Interaction + +User interaction can be performed using both the mouse and the keyboard. + +## Changes + +n/a diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/action-buttons.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/action-buttons.tsx new file mode 100644 index 00000000..8d600d2b --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/action-buttons.tsx @@ -0,0 +1,57 @@ +import { Button, ButtonGroup } from 'react-bootstrap'; + +import { LayoutEditorProps, selectedWidgetId } from '..'; + +import styles from './layout-editor.module.css'; +import { ResizeButton } from '@wuespace/telestion/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx'; + +export function ActionButtons( + props: LayoutEditorProps & { + onDelete?: () => void; + onResizeGrid?: (width: number, height: number) => void; + } +) { + const isSelected = selectedWidgetId(props.value) !== undefined; + + const width = props.value.layout[0].length; + const height = props.value.layout.length; + + return ( +
+ {/*Undo/Redio*/} + + + + + {props.onResizeGrid && ( + + )} + {/* Delete selected*/} + {props.onDelete && ( + + )} +
+ ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/cursor.svg b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/cursor.svg new file mode 100644 index 00000000..50026cb5 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/cursor.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx new file mode 100644 index 00000000..2614c5ba --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx @@ -0,0 +1,40 @@ +import { forwardRef, useCallback } from 'react'; +import { clsx } from 'clsx'; + +import { Bounds } from '../model/layout-editor-model.ts'; + +import styles from './layout-editor.module.css'; + +export const EmptyCell = forwardRef< + HTMLDivElement, + { + y: number; + x: number; + onCreate?(bounds: Bounds): void; + } +>(function EmptyCell(props, ref) { + const onClick = useCallback(() => { + props.onCreate?.({ + x: props.x, + y: props.y, + width: 1, + height: 1 + }); + }, [props]); + + return ( +
+ +
+ ); +}); diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx new file mode 100644 index 00000000..72a44367 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx @@ -0,0 +1,112 @@ +import { useCallback, useState } from 'react'; +import { DndContext, DragEndEvent, useDraggable } from '@dnd-kit/core'; +import { CSS as CSSUtil } from '@dnd-kit/utilities'; +import { clsx } from 'clsx'; + +import { Bounds, Coordinate } from '../model/layout-editor-model.ts'; +import { ResizeHandle } from './resize-handle.tsx'; + +import styles from './layout-editor.module.css'; + +export function LayoutEditorWidgetInstance(props: { + bounds: Bounds; + id: string; + selected?: boolean; + onSelect?(bounds: Bounds): void; + onResize?(bounds: Bounds, resizeDelta: Coordinate): void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + node: widgetInstanceNode + } = useDraggable({ + id: props.id, + data: { + widgetId: props.id, + bounds: props.bounds + } + }); + const [resizeDelta, setResizeDelta] = useState({ + x: 0, + y: 0 + }); + const onResizeEnd = useCallback( + (event: DragEndEvent) => { + const resizeDelta = event.delta; // delta in px + const oldBounds = props.bounds; // previous bounds to select + + if (!widgetInstanceNode.current) + throw new Error('widgetInstanceNode.current is null'); + + const originalNodeWidth = + widgetInstanceNode.current.offsetWidth - resizeDelta.x; + const originalNodeHeight = + widgetInstanceNode.current.offsetHeight - resizeDelta.y; + + const singleCellWidth = originalNodeWidth / oldBounds.width; + const singleCellHeight = originalNodeHeight / oldBounds.height; + + const onResizeDelta = { + x: Math.round(resizeDelta.x / singleCellWidth), + y: Math.round(resizeDelta.y / singleCellHeight) + }; + + props.onResize?.(oldBounds, onResizeDelta); + setResizeDelta({ x: 0, y: 0 }); + }, + [props, widgetInstanceNode] + ); + + return ( +
props.onSelect?.(props.bounds)} + aria-hidden={true} + // disable dnd-kit keyboard shortcuts since we have our own + tabIndex={undefined} + role={undefined} + aria-describedby={undefined} + aria-disabled={undefined} + aria-roledescription={undefined} + // title on hover + title={ + props.selected + ? `Drag to move or use Alt/Option + Arrow keys.` + : 'Click to select.' + } + > + {/*Label*/} +
{props.id}
+ {/*Resize handle*/} + {props.selected && ( + setResizeDelta(evt.delta)} + onDragCancel={() => setResizeDelta({ x: 0, y: 0 })} + onDragEnd={onResizeEnd} + > + + + )} +
+ ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css new file mode 100644 index 00000000..f3b114a1 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css @@ -0,0 +1,222 @@ +/*Component Variables*/ +.layoutEditor { + /*Variables*/ + --keyboard-hints-bg: var(--bs-tertiary-bg); + --empty-cell-bg: var(--bs-secondary-bg); + --empty-cell-color: var(--bs-secondary); + --cursor-color: var(--bs-body-color); + --widget-instance-bg: var(--bs-teal); + --widget-instance-color: var(--bs-body-color); +} + +[data-bs-theme='dark'] .layoutEditor { + --keyboard-hints-bg: var(--bs-tertiary-bg); + --empty-cell-bg: var(--bs-secondary-bg); + --empty-cell-color: var(--bs-secondary-color); + --cursor-color: var(--bs-body-color); + --widget-instance-bg: var(--bs-blue); + --widget-instance-color: var(--bs-body-color); +} + +/*Styles*/ +.layoutEditor { + position: relative; +} + +.grid { + /*Input Props*/ + --width: 4; /* Number of columns */ + --height: 4; /* Number of rows */ + --gap: 4; /* Gap between cells in pixels */ + + /*Styles*/ + display: grid; + grid-template-columns: repeat(var(--width), 1fr); + grid-template-rows: repeat(var(--height), 1fr); + aspect-ratio: 16 / 9; + gap: calc(var(--gap) * 1px); + margin: 1em; + + /*Interactivity*/ + + &:focus { + outline: 2px solid var(--bs-focus-ring-color); + } +} + +.emptyCell { + /*Input props*/ + --x: 1; /* Column Index (0-based) */ + --y: 1; /* Row Index (0-based) */ + + /*Styles*/ + grid-area: calc(var(--y) + 1) / calc(var(--x) + 1) / span 1 / span 1; + background: var(--empty-cell-bg); + color: var(--empty-cell-color); + + /*Interactivity*/ + position: relative; + cursor: pointer; + user-select: none; +} + +.emptyCellContent { + /*Positioning*/ + position: absolute; + inset: 1em; + overflow: hidden; + + /*Text size*/ + font-size: 1.3em; + + /*Center everything*/ + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + /*Interactivity*/ + opacity: 0; + @media (prefers-reduced-motion: no-preference) { + will-change: opacity; + transition: opacity 0.1s ease-in-out; + } +} + +.emptyCell:hover .emptyCellContent { + opacity: 1; +} + +.cursor { + /*Input props*/ + --x: 1; /* Column Index (0-based) */ + --y: 1; /* Row Index (0-based) */ + + /*Styles*/ + grid-area: calc(var(--y) + 1) / calc(var(--x) + 1) / span 1 / span 1; + width: 100%; + height: 100%; + + color: var(--cursor-color); + background: url('./cursor.svg') no-repeat center center / contain; + + pointer-events: none; + z-index: 2; + + /*Hide unless focused*/ + opacity: 0; + @media (prefers-reduced-motion: no-preference) { + will-change: opacity; + transition: opacity 0.1s ease-in-out; + } +} + +:focus .cursor { + opacity: 0.3; +} + +.widgetInstance { + /*Input props*/ + --x: 1; /* Column Index (0-based) */ + --y: 1; /* Row Index (0-based) */ + --width: 1; /* Number of columns */ + --height: 1; /* Number of rows */ + + /*Styles*/ + + /*positioning*/ + grid-area: calc(var(--y) + 1) / calc(var(--x) + 1) / span var(--height) / span + var(--width); + + /*styling the widget instance itself*/ + background: var(--widget-instance-bg); + color: var(--widget-instance-color); + + /*center the content*/ + display: flex; + align-items: center; + justify-content: center; + + /*show the resize handle*/ + position: relative; + overflow: visible; + + /*make sure the text can't "blow out" the grid*/ + min-width: 0; + min-height: 0; + /*max-width: 100%;*/ + + cursor: pointer; + + &.isSelected, + &.isDragged { + border: 2px solid; + border-color: var(--widget-instance-color); + cursor: move; + z-index: 1; + } + + &.isDragged { + z-index: 2; + } +} + +.widgetInstanceLabel { + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.resizeHandle { + position: absolute; + bottom: -0.5em; + right: -0.5em; + width: 1em; + height: 1em; + background: var(--widget-instance-color); + cursor: nwse-resize; +} + +.actionButtons { + --padding: 1em; + padding: var(--padding) var(--padding) 0; + display: flex; + justify-content: end; + gap: 1em; + flex-wrap: wrap; +} + +.layoutEditorKeyboardHints { + min-height: 3lh; + opacity: 0; + padding: 0.5em 1em; + margin: 0; + + position: absolute; + bottom: 0; + left: 1em; + right: 1em; + transform: translateY(100%); + + background: var(--keyboard-hints-bg); + outline: 2px solid var(--bs-focus-ring-color); + + border-bottom-left-radius: 1em; + border-bottom-right-radius: 1em; + + pointer-events: none; + + @media (prefers-reduced-motion: no-preference) { + will-change: opacity; + transition: opacity 0.1s ease-in-out; + } +} + +.layoutEditorKeyboardHints kbd { + white-space: nowrap; +} + +.grid:focus + .layoutEditorKeyboardHints { + opacity: 1; +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx new file mode 100644 index 00000000..e8d161a0 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx @@ -0,0 +1,215 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { z } from 'zod'; +import { clsx } from 'clsx'; +import { DndContext, DragEndEvent } from '@dnd-kit/core'; + +import { + anyInBounds, + Bounds, + Coordinate, + deleteSelected, + fillWith, + getBounds, + getWidgetIds, + LayoutEditorProps, + LayoutEditorState, + moveSelected, + moveSelection, + resizeGrid, + resizeSelected, + select, + selectedWidgetId +} from '..'; +import { gap } from '../constants.tsx'; +import { EmptyCell } from './empty-cell.tsx'; +import { LayoutEditorWidgetInstance } from './layout-editor-widget-instance.tsx'; +import { ActionButtons } from './action-buttons.tsx'; +import { useLayoutEditorKeyboardShortcuts } from '../hooks/use-layout-editor-keyboard-shortcuts.tsx'; + +import styles from './layout-editor.module.css'; + +export function LayoutEditor(props: LayoutEditorProps) { + const widgetInstances = useMemo(() => { + return getWidgetIds(props.value).map(id => { + const bounds = getBounds(props.value, id); + return { + id, + bounds + }; + }); + }, [props.value]); + + const height = props.value.layout.length; + const width = props.value.layout[0].length; + + const layoutEditorRef = useRef(null); + const applyLayoutChange = useCallback( + (stateFn: (state: LayoutEditorState) => LayoutEditorState) => { + if (!props.onChange) { + console.warn( + 'Cannot apply layout change when onChange is not provided' + ); + return; + } + layoutEditorRef.current?.focus(); + props.onChange(s => stateFn(s)); + }, + [props] + ); + + /** + * A reference to a single background cell for size calculations. + * + * Corresponds to a grid cell with a size of 1x1. + */ + const singleCellRef = useRef(null); + + const onMoveEnd = useCallback( + (event: DragEndEvent) => { + if (!singleCellRef.current) + throw new Error('singleCellRef.current is null'); + const cellRect = singleCellRef.current.getBoundingClientRect(); + const deltaX = Math.round(event.delta.x / (cellRect.width + gap)); + const deltaY = Math.round(event.delta.y / (cellRect.height + gap)); + + const oldCoords = z + .object({ + x: z.number(), + y: z.number() + }) + .parse(event.active.data.current?.bounds); + + applyLayoutChange(state => + moveSelected(select(state, oldCoords), { + x: deltaX, + y: deltaY + }) + ); + }, + [applyLayoutChange] + ); + + const onLayoutEditorWidgetInstanceSelect = useCallback( + (bounds: Bounds) => { + applyLayoutChange(state => select(state, bounds)); + }, + [applyLayoutChange] + ); + + const onLayoutEditorWidgetInstanceResize = useCallback( + (bounds: Bounds, resizeDelta: Coordinate) => { + applyLayoutChange(state => + resizeSelected(select(state, bounds), resizeDelta) + ); + }, + [applyLayoutChange] + ); + + const onEmptyCellCreate = useCallback( + (bounds: Bounds) => { + applyLayoutChange(state => { + if (anyInBounds(state, bounds, s => s !== '.')) { + console.warn( + 'Cannot create new widget instance while another is selected' + ); + return state; + } + + if (!props.onCreateWidgetInstance) { + console.warn( + 'Cannot create new widget instance when onCreateWidgetInstance is not provided' + ); + return state; + } + + const widgetId = props.onCreateWidgetInstance(); + return select(fillWith(state, widgetId, bounds), bounds); + }); + }, + [applyLayoutChange, props] + ); + + const { onKeyDown, hint } = useLayoutEditorKeyboardShortcuts({ + onCreateWidgetInstance: () => + onEmptyCellCreate({ + ...props.value.selection, + width: 1, + height: 1 + }), + onDeleteSelected: () => applyLayoutChange(state => deleteSelected(state)), + onMoveSelected: delta => + applyLayoutChange(state => moveSelected(state, delta)), + onMoveSelection: delta => + applyLayoutChange(state => moveSelection(state, delta)), + onResizeSelected: delta => + applyLayoutChange(state => resizeSelected(state, delta)), + isSelected: selectedWidgetId(props.value) !== undefined + }); + const onGridResize = useCallback( + (width: number, height: number) => { + applyLayoutChange(s => resizeGrid(s, width, height)); + }, + [applyLayoutChange] + ); + + return ( +
+ + applyLayoutChange(state => deleteSelected(state))} + onResizeGrid={onGridResize} + /> + + {/* eslint-disable jsx-a11y/no-noninteractive-tabindex */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ {/*Background cells:*/} + {props.value.layout.map((row, y) => + row.map((_, x) => ( + + )) + )} + {/*Widget Instances*/} + {widgetInstances.map(({ id, bounds }) => ( + + ))} + {/* Cursor*/} +
+
+

+ {hint} +

+ +
+ ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx new file mode 100644 index 00000000..743ecc57 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx @@ -0,0 +1,89 @@ +import { useCallback, useId, useState } from 'react'; +import { Alert, Button, Form, Modal } from 'react-bootstrap'; + +export function ResizeButton(props: { + onResizeGrid: (width: number, height: number) => void; + defaultWidth: number; + defaultHeight: number; +}) { + const [showResizeModal, setShowResizeModal] = useState(false); + const onResizeModalCancel = useCallback(() => { + setShowResizeModal(false); + }, []); + const onShowResizeModal = useCallback(() => { + setShowResizeModal(true); + }, []); + + const onResizeFormSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + + const form = event.currentTarget; + const data = new FormData(form); + const width = Number(data.get('columns')); + const height = Number(data.get('rows')); + + props.onResizeGrid(width, height); + setShowResizeModal(false); + }, + [props] + ); + const formId = useId(); + + return ( + <> + + + + + +   Resize Grid + + + + {/*Warning about widget instances getting lost*/} + + Resizing the grid will remove all widget instances that are outside + of the new grid. + + {/* Form for selecting new column and row count */} +
+ + Columns + + + + Rows + + +
+
+ + + + +
+ + ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx new file mode 100644 index 00000000..a4f744ac --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx @@ -0,0 +1,27 @@ +import { useDraggable } from '@dnd-kit/core'; + +import styles from './layout-editor.module.css'; + +export function ResizeHandle() { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: 'resize-handle' + }); + + return ( +
+ ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/constants.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/constants.tsx new file mode 100644 index 00000000..02ef9076 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/constants.tsx @@ -0,0 +1,6 @@ +/** + * The gap between the grid lines in pixels. + * + * Used for calculating diffs when moving elements. + */ +export const gap = 4; diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/hooks/use-layout-editor-keyboard-shortcuts.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/hooks/use-layout-editor-keyboard-shortcuts.tsx new file mode 100644 index 00000000..5a71ca0c --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/hooks/use-layout-editor-keyboard-shortcuts.tsx @@ -0,0 +1,125 @@ +import { KeyboardEvent, useMemo } from 'react'; + +import { Coordinate } from '..'; + +export const DELTA_MAP = { + // Arrow keys + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, + // WASD + KeyW: { x: 0, y: -1 }, + KeyS: { x: 0, y: 1 }, + KeyA: { x: -1, y: 0 }, + KeyD: { x: 1, y: 0 }, + // HJKL (vim) + KeyH: { x: -1, y: 0 }, + KeyL: { x: 1, y: 0 }, + KeyK: { x: 0, y: -1 }, + KeyJ: { x: 0, y: 1 } +} as const satisfies Record; + +export interface UseLayoutEditorKeyboardShortcutsOptions { + onMoveSelection?: (delta: Coordinate) => void; + onMoveSelected?: (delta: Coordinate) => void; + onResizeSelected?: (delta: Coordinate) => void; + onUndo?: () => void; + onRedo?: () => void; + onDeleteSelected?: () => void; + onCreateWidgetInstance?: () => void; + isSelected?: boolean; +} + +function getDelta(event: KeyboardEvent) { + if (Object.prototype.hasOwnProperty.call(DELTA_MAP, event.code)) + return DELTA_MAP[event.code as keyof typeof DELTA_MAP]; +} + +const arrowKeysJsx = ( + + + + + + +); + +/** + * Hook that returns keyboard shortcuts for the layout editor. + * + * Can be registered on the `onKeyDown` event of a DOM element. + * + * Keyboard shortcuts aren't documented to their ever-changing nature. However, + * they follow the following pattern: + * - several directional keys are supported (arrow keys, WASD, HJKL) + * - directional keys move the cursor + * - if the cursor is on a widget, the widget is selected + * - if the cursor is on a selected widget, and the alt key is pressed, the + * widget, the widget gets moved instead of the cursor + * - if the cursor is on a selected widget, and the shift and alt keys are + * pressed, the widget gets resized instead of the cursor. In other words, the + * bottom-right corner of the widget is moved instead of the cursor, while the + * top-left corner stays in place. + * - there are a number of additional shortcuts (creating, deleting, etc.) that + * don't require any modifiers. They get executed in the context of the current + * cursor position. + * + * @param options - callback functions for the shortcuts and `isSelected` flag + * which determines whether the element is focused (and thus whether to apply + * the keyboard shortcuts) + * + * @returns an object with `onKeyDown` and `hint` properties + * - `onKeyDown` is a callback function that should be registered on the + * `onKeyDown` event of a DOM element + * - `hint` is a JSX element that can be used to display the keyboard shortcuts + * to the user + */ +export function useLayoutEditorKeyboardShortcuts( + options: UseLayoutEditorKeyboardShortcutsOptions +) { + return useMemo( + () => ({ + onKeyDown: (event: KeyboardEvent) => { + const delta = getDelta(event); + + if (delta) { + event.preventDefault(); + if (event.shiftKey && event.altKey && options.isSelected) { + return options.onResizeSelected?.(delta); + } else if (event.altKey && options.isSelected) { + return options.onMoveSelected?.(delta); + } else { + return options.onMoveSelection?.(delta); + } + } + + switch (event.code) { + case 'Delete': + case 'Backspace': + event.preventDefault(); + options.onDeleteSelected?.(); + return; + case 'Enter': + case 'Space': + event.preventDefault(); + options.onCreateWidgetInstance?.(); + return; + } + }, + hint: options.isSelected ? ( + <> + Move cursor: {arrowKeysJsx}; Resize: ⇧ Shift+ + Alt / ⌥ Option+{arrowKeysJsx}; Move:{' '} + Alt / ⌥ Option+{arrowKeysJsx}; Delete:{' '} + Delete / ⌫ + + ) : ( + <> + Move cursor: {arrowKeysJsx}; Create: Enter + + ) + }), + [options] + ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts new file mode 100644 index 00000000..2d9b4c70 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts @@ -0,0 +1,4 @@ +export * from './components/layout-editor.tsx'; +export * from './constants.tsx'; +export * from './model/layout-editor-model.ts'; +export * from './model/layout-editor-props.ts'; diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts new file mode 100644 index 00000000..a1388ce4 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts @@ -0,0 +1,307 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { + deleteSelected, + getBounds, + getWidgetIds, + LayoutEditorState, + moveSelected, + moveSelection, + resizeSelected, + selectedWidgetId +} from './layout-editor-model.ts'; + +let state: LayoutEditorState = { + layout: [ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', '.', '.', '.'] + ], + selection: { x: 3, y: 3 } +}; + +function layoutToString(layout: string[][]) { + return layout.map(row => row.join('')).join('\n'); +} + +beforeEach(() => { + state = { + layout: [ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', '.', '.', '.'] + ], + selection: { x: 4, y: 4 } + }; +}); + +describe('getting the selected widget id', () => { + beforeEach(() => { + state = { + ...state, + selection: { x: 3, y: 0 } + }; + }); + + test('widget selected in top left corner', () => { + const selected = selectedWidgetId(state); + expect(selected).toEqual('a'); + }); + + test('widget selected somewhere in the middle', () => { + const selected = selectedWidgetId(moveSelection(state, { x: 1, y: 1 })); + expect(selected).toEqual('a'); + }); + + test('no widget selected', () => { + const selected = selectedWidgetId(moveSelection(state, { x: -3, y: 0 })); + expect(selected).toEqual(undefined); + }); +}); + +test('getting a list of widget instance IDs', () => { + const ids = getWidgetIds(state); + expect(ids).toEqual(['a', 'b', 'c']); +}); + +describe('get bounds of a widget instance', () => { + test('get bounds of a widget instance', () => { + const bounds = getBounds(state, 'a'); + expect(bounds).toEqual({ x: 3, y: 0, width: 2, height: 2 }); + }); + + test('get bounds of a widget instance that does not exist', () => { + expect(() => getBounds(state, 'd')).toThrow(); + }); +}); + +describe('moving a selection', () => { + test('moving a selection', () => { + const moved = moveSelection(state, { x: 1, y: 1 }); + expect(moved.selection).toEqual({ x: 5, y: 5 }); + }); + + test('moving a selection out of bounds', () => { + const moved = moveSelection(state, { x: 10, y: 10 }); + expect(moved.selection).toEqual({ x: 5, y: 5 }); + }); +}); + +describe('deleting a selection', () => { + test('deleting a widget instance', () => { + const deleted = deleteSelected(state); + expect(layoutToString(deleted.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.', '.'] + ]) + ); + }); + + test('deleting a non-existing selection', () => { + const deleted = deleteSelected(moveSelection(state, { x: -3, y: -3 })); + expect(layoutToString(deleted.layout)).toEqual( + layoutToString(state.layout) + ); + }); +}); + +describe('moving a widget instance', () => { + describe('valid movement', () => { + test('moving a widget instance', () => { + const moved = moveSelected(state, { x: 1, y: 1 }); + expect(moved.selection).toEqual({ x: 4, y: 4 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', 'c', 'c'], + ['.', '.', '.', '.', 'c', 'c'] + ]) + ); + const movedAgain = moveSelected(moved, { x: -1, y: -1 }); + expect(movedAgain.selection).toEqual({ x: 3, y: 3 }); + expect(layoutToString(movedAgain.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', '.', '.', '.'] + ]) + ); + }); + }); + + describe('movement out of bounds', () => { + beforeEach(() => { + state = { + layout: [ + ['a', 'a'], + ['a', 'a'] + ], + selection: { x: 0, y: 0 } + }; + }); + test('moving out of left bounds', () => { + const moved = moveSelected(state, { x: -1, y: 0 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('moving out of right bounds', () => { + const moved = moveSelected(state, { x: 1, y: 0 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('moving out of top bounds', () => { + const moved = moveSelected(state, { x: 0, y: -1 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('moving out of bottom bounds', () => { + const moved = moveSelected(state, { x: 0, y: 1 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString(state.layout) + ); + }); + }); + + describe('movement into another widget', () => { + beforeEach(() => { + state = { + layout: [ + ['b', 'a', 'a'], + ['b', 'a', 'a'], + ['c', 'a', 'a'] + ], + selection: { x: 0, y: 0 } + }; + }); + describe('position conflicts', () => { + test('moving a widget instance into another widget is impossible', () => { + const moved = moveSelected(state, { x: 0, y: 1 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString([ + ['b', 'a', 'a'], + ['b', 'a', 'a'], + ['c', 'a', 'a'] + ]) + ); + }); + }); + }); +}); + +describe('resizing a widget instance', () => { + beforeEach(() => { + state = { + layout: [ + ['a', '.', 'b'], + ['a', '.', 'b'], + ['a', '.', 'b'] + ], + selection: { x: 0, y: 0 } + }; + }); + + test('resizing a widget instance', () => { + const resized = resizeSelected(state, { x: 1, y: 2 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString([ + ['a', 'a', 'b'], + ['a', 'a', 'b'], + ['a', 'a', 'b'] + ]) + ); + + const resizedAgain = resizeSelected(resized, { x: -1, y: -1 }); + expect(resizedAgain.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resizedAgain.layout)).toEqual( + layoutToString([ + ['a', '.', 'b'], + ['a', '.', 'b'], + ['.', '.', 'b'] + ]) + ); + }); + + describe('resizing a widget instance out of bounds is impossible', () => { + test('resizing a widget instance out of right bounds', () => { + state = { ...state, selection: { x: 2, y: 0 } }; + const resized = resizeSelected(state, { x: 1, y: 0 }); + expect(resized.selection).toEqual({ x: 2, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('resizing a widget instance out of bottom bounds', () => { + const resized = resizeSelected(state, { x: 0, y: 1 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString(state.layout) + ); + }); + }); + + describe('collapsing a widget instance is impossible', () => { + beforeEach(() => { + state = { + layout: [['a']], + selection: { x: 0, y: 0 } + }; + }); + + test('collapsing a widget instance to 0 width', () => { + const resized = resizeSelected(state, { x: -1, y: 0 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('collapsing a widget instance to 0 height', () => { + const resized = resizeSelected(state, { x: 0, y: -1 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString(state.layout) + ); + }); + }); + + test('resizing a widget instance into another widget is impossible', () => { + const resized = resizeSelected(state, { x: 2, y: 0 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString([ + ['a', '.', 'b'], + ['a', '.', 'b'], + ['a', '.', 'b'] + ]) + ); + }); +}); diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts new file mode 100644 index 00000000..7b5df3b6 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts @@ -0,0 +1,469 @@ +export interface Coordinate { + x: number; + y: number; +} + +export interface Bounds extends Coordinate { + width: Bounds['x']; + height: Bounds['y']; +} + +export type WidgetInstanceId = string; + +export interface LayoutEditorState { + layout: WidgetInstanceId[][]; + selection: Coordinate; +} + +/** + * Returns the widget instance id at the selection. + * Returns undefined if the selection is empty. + * + * @throws InvalidSelectionError if the selection is invalid (out of bounds) + * + * @param state - the layout editor state + * @returns the widget instance id at the selection or undefined if the selection is empty + */ +export function selectedWidgetId( + state: LayoutEditorState +): WidgetInstanceId | undefined { + const { selection, layout } = state; + const { x, y } = selection; + + if (x < 0 || y < 0) { + throw new InvalidSelectionError('Invalid selection'); + } + + if (y >= layout.length) { + throw new InvalidSelectionError('Invalid selection'); + } + const row = layout[y]; + + if (x >= row.length) { + throw new InvalidSelectionError('Invalid selection'); + } + const widgetId = row[x]; + + if (widgetId === '.') { + return undefined; + } + return widgetId; +} + +/** + * Returns true if the given values are in ascending order. + * @param values - the values to check + * @returns true if the given values are in ascending order, i.e., `values[n] <= values[n+1]` for all `n` + */ +function isAscending(...values: number[]) { + for (let i = 1; i < values.length; i++) { + if (values[i] < values[i - 1]) { + return false; + } + } + return true; +} + +/** + * Fills the given area with the given widget instance id. + * + * Ignores any cells described by the bounds that are not in the layout. + * @param state - the layout editor state + * @param widgetId - the widget instance id to fill with + * @param bounds - the bounds of the area to fill + * @returns the new layout editor state + */ +export function fillWith( + state: LayoutEditorState, + widgetId: WidgetInstanceId, + bounds: Bounds +): LayoutEditorState { + const { x, y, width, height } = bounds; + const { layout } = state; + + return { + ...state, + layout: layout.map((row, rowIndex) => + isAscending(y, rowIndex, y + height - 1) // in fill area + ? row.map((cell, columnIndex) => + isAscending(x, columnIndex, x + width - 1) // in fill area + ? widgetId + : cell + ) + : row + ) + }; +} + +/** + * Selects the given coordinate. + * @param state - the layout editor state + * @param selection - the coordinate to select + * @returns the new layout editor state (with the given selection) + */ +export function select( + state: LayoutEditorState, + selection: Coordinate +): LayoutEditorState { + return { + ...state, + selection + }; +} + +/** + * Moves the selection by the given delta. + * + * The selection is clamped to the layout. + * @param state - the layout editor state + * @param delta - the delta to move the selection by + * @returns the new layout editor state (with the selection moved by the given delta) + */ +export function moveSelection( + state: LayoutEditorState, + delta: Coordinate +): LayoutEditorState { + const { selection } = state; + const { x, y } = selection; + + return select(state, { + x: Math.max(0, Math.min(state.layout[0].length - 1, x + delta.x)), + y: Math.max(0, Math.min(state.layout.length - 1, y + delta.y)) + }); +} + +/** + * Returns the bounds of the given widget instance. + * @param state - the layout editor state + * @param widgetId - the widget instance id + * @throws WidgetInstanceNotFoundError if the widget instance is not in the layout + * @returns the bounds of the given widget instance + */ +export function getBounds( + state: LayoutEditorState, + widgetId: WidgetInstanceId +): Bounds { + const { layout } = state; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (let y = 0; y < layout.length; y++) { + const row = layout[y]; + + for (let x = 0; x < row.length; x++) { + if (row[x] === widgetId) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (!Number.isFinite(minX)) { + throw new WidgetInstanceNotFoundError( + `widget instance ${widgetId} not found in layout` + ); + } + + return { + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1 + }; +} + +/** + * Gets the widget instance ids of all widget instances in the layout. + * @param state - the layout editor state + * @returns an array of all widget instance ids in the layout (without duplicates) + */ +export function getWidgetIds(state: LayoutEditorState): WidgetInstanceId[] { + const { layout } = state; + const widgetIds = new Set(); + + for (const row of layout) { + for (const cell of row) { + if (cell !== '.') { + widgetIds.add(cell); + } + } + } + + return Array.from(widgetIds); +} + +/** + * Transforms the given bounds by the given delta. + * + * The delta is applied to the top left corner of the bounds. + * + * The width and height of the bounds are always at least 1. + * @param bounds - the old bounds + * @param delta - the delta to apply + * @returns the new bounds + */ +export function transformBounds(bounds: Bounds, delta: Bounds): Bounds { + return { + x: Math.max(0, bounds.x + delta.x), + y: Math.max(0, bounds.y + delta.y), + width: Math.max(1, bounds.width + delta.width), + height: Math.max(1, bounds.height + delta.height) + }; +} + +/** + * Moves the selected widget instance by the given delta. + * + * The widget instance is clamped to the layout. + * + * The delta is applied to the top left corner of the widget instance. + * @param state - the layout editor state + * @param delta - the delta to move the widget instance by + * @returns the new layout editor state (with the selected widget instance moved by the given delta) + * If the widget instance cannot be moved, the old state is returned. + */ +export function moveSelected( + state: LayoutEditorState, + delta: Coordinate +): LayoutEditorState { + const widgetId = selectedWidgetId(state); + + if (!widgetId) { + return state; + } + + const { layout } = state; + + const bounds = getBounds(state, widgetId); + const newBounds = transformBounds(bounds, { ...delta, width: 0, height: 0 }); + + // check if newBounds is within the layout + const newMaxX = newBounds.x + newBounds.width; + const newMaxY = newBounds.y + newBounds.height; + const layoutWidth = layout[0].length; + const layoutHeight = layout.length; + if ( + newMaxX > layoutWidth || + newMaxY > layoutHeight || + newBounds.x < 0 || + newBounds.y < 0 + ) { + console.warn('Cannot move widget instance outside of the layout'); + return state; + } + + // check if newBounds is not overlapping with other widgets + for (let y = newBounds.y; y < newMaxY; y++) { + const row = layout[y]; + + for (let x = newBounds.x; x < newMaxX; x++) { + if (row[x] !== '.' && row[x] !== widgetId) { + console.warn( + 'Cannot move widget instance on top of another widget instance' + ); + return state; + } + } + } + + // no collision, move the widget + state = fillWith(state, '.', bounds); + state = fillWith(state, widgetId, newBounds); + state = select(state, { x: newBounds.x, y: newBounds.y }); + return state; +} + +/** + * Resizes the selected widget instance by the given delta. + * @param state - the layout editor state + * @param delta - the delta to resize the widget instance by + * @returns the new layout editor state (with the selected widget instance resized by the given delta) + * If the widget instance cannot be resized, the old state is returned. + */ +export function resizeSelected( + state: LayoutEditorState, + delta: Coordinate +): LayoutEditorState { + const widgetId = selectedWidgetId(state); + + if (!widgetId) { + return state; + } + + const oldBounds = getBounds(state, widgetId); + const newBounds = transformBounds(oldBounds, { + x: 0, + y: 0, + width: delta.x, + height: delta.y + }); + + if ( + anyInBounds( + state, + newBounds, + widgetId => widgetId !== '.' && widgetId !== selectedWidgetId(state) + ) + ) { + console.warn( + 'Cannot resize widget instance on top of another widget instance' + ); + return state; + } + + state = fillWith(state, '.', oldBounds); + state = fillWith(state, widgetId, newBounds); + state = select(state, { x: newBounds.x, y: newBounds.y }); + return state; +} + +/** + * Deletes the selected widget instance from the layout, replacing it with empty cells. + * @param state - the layout editor state + * @returns the new layout editor state (with the selected widget instance deleted) + */ +export function deleteSelected(state: LayoutEditorState): LayoutEditorState { + const widgetId = selectedWidgetId(state); + + if (!widgetId) { + return state; + } + + const bounds = getBounds(state, widgetId); + + return fillWith(state, '.', bounds); +} + +/** + * Resizes the grid to the given size. + * If the grid is enlarged, the new cells are filled with empty cells. + * If the grid is shrunk, the cells outside the new grid are deleted. + * @param state - the layout editor state + * @param columns - the new number of columns + * @param rows - the new number of rows + * @throws InvalidBoundsError if the given size is invalid (less than 1) + * @returns the new layout editor state (with the grid resized to the given size) + */ +export function resizeGrid( + state: LayoutEditorState, + columns: number, + rows: number +) { + const { layout } = state; + + if (columns < 1 || rows < 1) { + throw new InvalidBoundsError( + `Invalid grid size while resizing the grid. Must be at least 1x1, got ${columns}x${rows}` + ); + } + + const newLayout: WidgetInstanceId[][] = []; + + for (let y = 0; y < rows; y++) { + const row: WidgetInstanceId[] = []; + + for (let x = 0; x < columns; x++) { + if (y < layout.length && x < layout[y].length) { + row.push(layout[y][x]); + } else { + row.push('.'); + } + } + + newLayout.push(row); + } + + const newSelection = { + x: Math.min(state.selection.x, columns - 1), + y: Math.min(state.selection.y, rows - 1) + }; + + return { + ...state, + selection: newSelection, + layout: newLayout + }; +} + +/** + * Returns true if any cell in the given bounds matches the given predicate. + * @param state - the layout editor state + * @param bounds - the bounds to check + * @param predicate - the predicate to check against. + * The predicate is called with the widget instance id, x and y coordinates of each cell. + * @returns true if any cell in the given bounds matches the given predicate + */ +export function anyInBounds( + state: LayoutEditorState, + bounds: Bounds, + predicate: (widgetId: WidgetInstanceId, x: number, y: number) => boolean +): boolean { + const { layout } = state; + const { x, y, width, height } = bounds; + + for (let row = y; row < y + height && row < layout.length; row++) { + for ( + let column = x; + column < x + width && column < layout[0].length; + column++ + ) { + if (predicate(layout[row][column], column, row)) { + return true; + } + } + } + + return false; +} + +/** + * Returns true if all cells in the given bounds match the given predicate. + * @param state - the layout editor state + * @param bounds - the bounds to check + * @param predicate - the predicate to check against. + * The predicate is called with the widget instance id, x and y + * coordinates of each cell. + * @returns true if all cells in the given bounds match the given predicate + */ +export function everyInBounds( + state: LayoutEditorState, + bounds: Bounds, + predicate: (widgetId: WidgetInstanceId, x: number, y: number) => boolean +): boolean { + const { layout } = state; + const { x, y, width, height } = bounds; + + for (let row = y; row < y + height; row++) { + for (let column = x; column < x + width; column++) { + if (!predicate(layout[row][column], column, row)) { + return false; + } + } + } + + return true; +} + +/** + * An error that is thrown when the selection is invalid. + * + * The selection is invalid if it is out of bounds. + */ +export class InvalidSelectionError extends Error {} + +/** + * An error that is thrown when the bounds are invalid. + * + * The bounds are invalid if they are less than 1x1. + */ +export class InvalidBoundsError extends Error {} + +/** + * An error that is thrown when a widget instance is not found in the layout. + */ +export class WidgetInstanceNotFoundError extends Error {} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-props.ts b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-props.ts new file mode 100644 index 00000000..b5017a5f --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-props.ts @@ -0,0 +1,55 @@ +import type { SetStateAction } from 'react'; + +import type { + LayoutEditorState, + WidgetInstanceId +} from './layout-editor-model.ts'; + +/** + * The props for the layout editor. + * + * Defines the interface (and separation of concerns) between the layout editor and its parent. + */ +export interface LayoutEditorProps { + /** + * The current state of the layout editor. + * + * Contains the selection and the layout. + * @see LayoutEditorState + * @see import('./layout-editor-model').selectedWidgetId + */ + value: LayoutEditorState; + /** + * Callback for when the user changes the layout. + * + * If not provided, the layout cannot be changed. + * @param value - The new state of the layout editor. + */ + onChange?: (value: SetStateAction) => void; + /** + * Callback for when the user adds a widget instance to the layout. + * If not provided, no widget instances can be added. + * @returns The ID of the new widget instance. + * @throws If the widget instance could not be added. + */ + onCreateWidgetInstance?: () => WidgetInstanceId; + /** + * Callback for when the user clones a widget instance. + * + * If not provided, no widget instances can be cloned (i.e., copied and pasted). + * @param widgetId - The ID of the widget instance to clone. + * @returns The ID of the new widget instance. + * @throws If the widget instance could not be cloned. + */ + onCloneWidgetInstance?: (widgetId: WidgetInstanceId) => WidgetInstanceId; + /** + * Callback for when the user clicks the undo button. + * If not provided, the undo button will not be rendered. + */ + onUndo?: () => void; + /** + * Callback for when the user clicks the redo button. + * If not provided, the redo button will not be rendered. + */ + onRedo?: () => void; +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/routing.ts b/frontend-react/src/lib/application/routes/dashboard-editor/routing.ts index 3badf980..107fdfbc 100644 --- a/frontend-react/src/lib/application/routes/dashboard-editor/routing.ts +++ b/frontend-react/src/lib/application/routes/dashboard-editor/routing.ts @@ -9,11 +9,13 @@ import { getUserData, layoutSchema, setUserData, - UserData + UserData, + widgetInstanceSchema } from '../../../user-data'; import { isUserDataUpToDate } from '../../../utils'; import { TelestionOptions } from '../../model.ts'; import { setResumeAfterLogin } from '../login'; +import { z } from 'zod'; export function dashboardEditorLoader({ version }: TelestionOptions) { return ({ params }: LoaderFunctionArgs) => { @@ -48,7 +50,8 @@ export function dashboardEditorLoader({ version }: TelestionOptions) { return { dashboardId, - dashboard: userData.dashboards[dashboardId] + dashboard: userData.dashboards[dashboardId], + widgetInstances: userData.widgetInstances }; }; } @@ -83,8 +86,16 @@ export function dashboardEditorAction({ version }: TelestionOptions) { throw new Error('No layout given'); } + const rawNewWidgetInstances = formData.get('widgetInstances'); + if (typeof rawNewWidgetInstances !== 'string') { + throw new Error('No widgetInstances given'); + } + try { const newLayout = layoutSchema.parse(JSON.parse(rawNewLayout)); + const newWidgetInstances = z + .record(z.string(), widgetInstanceSchema) + .parse(JSON.parse(rawNewWidgetInstances)); const dashboard = userData.dashboards[dashboardId]; const newUserData: UserData = { @@ -95,6 +106,10 @@ export function dashboardEditorAction({ version }: TelestionOptions) { ...dashboard, layout: newLayout } + }, + widgetInstances: { + ...userData.widgetInstances, + ...newWidgetInstances } }; setUserData(newUserData); diff --git a/frontend-react/src/lib/application/routes/page-wrapper/page-wrapper.module.css b/frontend-react/src/lib/application/routes/page-wrapper/page-wrapper.module.css index 60ed5867..9696383b 100644 --- a/frontend-react/src/lib/application/routes/page-wrapper/page-wrapper.module.css +++ b/frontend-react/src/lib/application/routes/page-wrapper/page-wrapper.module.css @@ -1,3 +1,18 @@ +/* +Set the main background color to a non-default-body-background color. + +This way, widgets and other elements can use the default body background color, allowing to use the design system as +if it got applied immediately on the body while being in a visible wrapper / widget / card / ... +*/ +.pageBackground { + background-color: #eee; +} + +[data-bs-theme='dark'] .pageBackground { + background-color: black; +} + +/*Layout, etc.*/ .nav { height: var(--navbar-height); } diff --git a/frontend-react/src/lib/application/routes/page-wrapper/page-wrapper.tsx b/frontend-react/src/lib/application/routes/page-wrapper/page-wrapper.tsx index 5750034b..f40e571a 100644 --- a/frontend-react/src/lib/application/routes/page-wrapper/page-wrapper.tsx +++ b/frontend-react/src/lib/application/routes/page-wrapper/page-wrapper.tsx @@ -56,7 +56,7 @@ export function PageWrapper({ showSelector }: PageWrapperProps) { } return ( - + {/* only render on dashboard pages and when dashboards are defined */} diff --git a/frontend-react/src/lib/widget/component/widget-renderer.module.css b/frontend-react/src/lib/widget/component/widget-renderer.module.css index 2afd757d..b182d729 100644 --- a/frontend-react/src/lib/widget/component/widget-renderer.module.css +++ b/frontend-react/src/lib/widget/component/widget-renderer.module.css @@ -7,4 +7,10 @@ /* Disallow grid areas to resize based on content */ overflow: auto; + + /* Set the background color to the body background color */ + background: var(--bs-body-bg); + + /* Set the border radius to the body border radius */ + border-radius: var(--bs-border-radius); }