-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
18 changed files
with
1,573 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -109,5 +109,4 @@ jobs: | |
run: pnpm install | ||
- name: Run unit tests 🛃 | ||
run: | | ||
pnpm run build | ||
pnpm run ci:test |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import { | ||
LayoutEditor, | ||
LayoutEditorState | ||
} from '../lib/application/routes/dashboard-editor/layout-editor'; | ||
import { useState } from 'react'; | ||
|
||
let prevId = 0; | ||
const idGenerator = () => `a${++prevId}`; | ||
|
||
export function App() { | ||
const [state, setState] = useState({ | ||
selection: { | ||
x: 0, | ||
y: 0 | ||
}, | ||
layout: [ | ||
['.', '.', '.', '.', '.'], | ||
['.', '.', '.', '.', '.'], | ||
['.', '.', '.', '.', '.'] | ||
] | ||
} satisfies LayoutEditorState); | ||
|
||
return ( | ||
<div> | ||
<LayoutEditor | ||
value={state} | ||
onChange={setState} | ||
onCreateWidgetInstance={idGenerator} | ||
/> | ||
<p>We have the state outside, as well:</p> | ||
<pre>{JSON.stringify(state, null, 2)}</pre> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
66 changes: 66 additions & 0 deletions
66
frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
# Layout Editor | ||
|
||
Date: 2024-01-14 | ||
|
||
Designed by: | ||
|
||
- [Zuri Klaschka](https://github.com/pklaschka) | ||
|
||
The layout editor for dashboards. | ||
|
||
## Interface Definition | ||
|
||
### Inputs | ||
|
||
- the current layout | ||
|
||
### Outputs / Events | ||
|
||
- layout change | ||
- widget selected | ||
|
||
## Behavior | ||
|
||
```mermaid | ||
zenuml | ||
title Layout Editor Behavior | ||
@Actor User | ||
@Boundary editor as "Layout Editor" | ||
@Entity state as "State" | ||
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 |
38 changes: 38 additions & 0 deletions
38
...t/src/lib/application/routes/dashboard-editor/layout-editor/components/action-buttons.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { LayoutEditorProps, selectedWidgetId } from '..'; | ||
import styles from './layout-editor.module.css'; | ||
import { Button, ButtonGroup } from 'react-bootstrap'; | ||
|
||
export function ActionButtons( | ||
props: LayoutEditorProps & { | ||
onDelete?: () => void; | ||
} | ||
) { | ||
const isSelected = selectedWidgetId(props.value) !== undefined; | ||
|
||
return ( | ||
<div className={styles.actionButtons}> | ||
{/*Undo/Redio*/} | ||
<ButtonGroup> | ||
<Button variant="secondary" disabled={!props.onUndo}> | ||
<i className="bi bi-arrow-counterclockwise"></i> | ||
Undo | ||
</Button> | ||
<Button variant="secondary" disabled={!props.onRedo}> | ||
<i className="bi bi-arrow-clockwise"></i> | ||
Redo | ||
</Button> | ||
</ButtonGroup> | ||
{/* Delete selected*/} | ||
{props.onDelete && ( | ||
<Button | ||
variant="danger" | ||
disabled={!isSelected} | ||
onClick={props.onDelete} | ||
> | ||
<i className="bi bi-trash"></i> | ||
Delete Widget Instance | ||
</Button> | ||
)} | ||
</div> | ||
); | ||
} |
35 changes: 35 additions & 0 deletions
35
...react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { forwardRef, useCallback } from 'react'; | ||
import { Bounds } from '../model/layout-editor-model.ts'; | ||
import { clsx } from 'clsx'; | ||
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 ( | ||
<div | ||
className={clsx(styles.emptyCell)} | ||
style={{ | ||
'--x': props.x, | ||
'--y': props.y | ||
}} | ||
ref={ref} | ||
onClick={onClick} | ||
aria-hidden={true} | ||
/> | ||
); | ||
}); |
104 changes: 104 additions & 0 deletions
104
...cation/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import { Bounds, Coordinate } from '../model/layout-editor-model.ts'; | ||
import { DndContext, DragEndEvent, useDraggable } from '@dnd-kit/core'; | ||
import { useCallback, useState } from 'react'; | ||
import { CSS as CSSUtil } from '@dnd-kit/utilities'; | ||
import { ResizeHandle } from './resize-handle.tsx'; | ||
import styles from './layout-editor.module.css'; | ||
import { clsx } from 'clsx'; | ||
|
||
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<Coordinate>({ | ||
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 ( | ||
<div | ||
className={clsx( | ||
styles.widgetInstance, | ||
props.selected && styles.isSelected, | ||
(transform?.x ?? 0) + (transform?.y ?? 0) !== 0 && styles.isDragged | ||
)} | ||
style={{ | ||
'--x': props.bounds.x, | ||
'--y': props.bounds.y, | ||
'--width': props.bounds.width, | ||
'--height': props.bounds.height, | ||
// preview repositioning on drag | ||
transform: CSSUtil.Translate.toString(transform), | ||
// preview resizing on resize | ||
marginRight: -resizeDelta.x, | ||
marginBottom: -resizeDelta.y | ||
}} | ||
ref={setNodeRef} | ||
{...attributes} | ||
{...listeners} | ||
onClick={() => 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} | ||
> | ||
{/*Label*/} | ||
<div className={clsx(styles.widgetInstanceLabel)}>{props.id}</div> | ||
{/*Resize handle*/} | ||
{props.selected && ( | ||
<DndContext | ||
onDragMove={evt => setResizeDelta(evt.delta)} | ||
onDragCancel={() => setResizeDelta({ x: 0, y: 0 })} | ||
onDragEnd={onResizeEnd} | ||
> | ||
<ResizeHandle /> | ||
</DndContext> | ||
)} | ||
</div> | ||
); | ||
} |
Oops, something went wrong.