Skip to content

Commit

Permalink
TELESTION-460 Dashboard layout editor
Browse files Browse the repository at this point in the history
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
pklaschka committed Jan 15, 2024
1 parent b3cd735 commit 324b35f
Show file tree
Hide file tree
Showing 16 changed files with 1,118 additions and 6 deletions.
2 changes: 2 additions & 0 deletions frontend-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,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",
Expand Down
37 changes: 37 additions & 0 deletions frontend-react/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions frontend-react/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LayoutEditor } from '../lib/application/routes/dashboard-editor/layout-editor';

export function App() {
return (
<div>
<LayoutEditor width={5} height={3} />
</div>
);
}
17 changes: 11 additions & 6 deletions frontend-react/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { initTelestion, registerWidgets, UserData } from '../lib';
import { registerWidgets, UserData } from '../lib';
import { simpleWidget } from './widgets/simple-widget';
import { errorWidget } from './widgets/error-widget';
import ReactDOM from 'react-dom/client';
import { App } from './app.tsx';

const defaultUserData: UserData = {

Check failure on line 7 in frontend-react/src/app/index.tsx

View workflow job for this annotation

GitHub Actions / Lint

'defaultUserData' is assigned a value but never used

Check failure on line 7 in frontend-react/src/app/index.tsx

View workflow job for this annotation

GitHub Actions / Lint

'defaultUserData' is assigned a value but never used

Check failure on line 7 in frontend-react/src/app/index.tsx

View workflow job for this annotation

GitHub Actions / Lint

'defaultUserData' is assigned a value but never used
version: '0.0.1',
Expand Down Expand Up @@ -29,8 +31,11 @@ const defaultUserData: UserData = {

registerWidgets(simpleWidget, errorWidget);

await initTelestion({
version: '0.0.1',
defaultBackendUrl: 'ws://localhost:9222',
defaultUserData
});
// await initTelestion({
// version: '0.0.1',
// defaultBackendUrl: 'ws://localhost:9222',
// defaultUserData
// });

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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

Check failure on line 24 in frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx

View workflow job for this annotation

GitHub Actions / Lint

Visible, non-interactive elements with click handlers must have at least one keyboard listener

Check failure on line 24 in frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx

View workflow job for this annotation

GitHub Actions / Lint

Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element

Check failure on line 24 in frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx

View workflow job for this annotation

GitHub Actions / Lint

Visible, non-interactive elements with click handlers must have at least one keyboard listener

Check failure on line 24 in frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx

View workflow job for this annotation

GitHub Actions / Lint

Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element

Check failure on line 24 in frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx

View workflow job for this annotation

GitHub Actions / Lint

Visible, non-interactive elements with click handlers must have at least one keyboard listener

Check failure on line 24 in frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx

View workflow job for this annotation

GitHub Actions / Lint

Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element
className={clsx(styles.emptyCell)}
style={{
'--x': props.x,
'--y': props.y
}}
ref={ref}
onClick={onClick}
/>
);
});
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 (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<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)}
// 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>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
.layoutEditor {
/*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);
padding: 16px;
overflow: hidden;
}

.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(--bs-secondary-bg)
}

.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;
background: var(--bs-red);
border-radius: 50%;
width: 50%;
height: 50%;
margin: auto;
opacity: 0.3;
pointer-events: none;
z-index: 2;
}

.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(--bs-blue);

/*center the content*/
display: flex;
align-items: center;
justify-content: center;

/*show the resize handle*/
position: relative;
overflow: visible;

cursor: pointer;

&.isSelected,
&.isDragged {
border: 1px solid white;
cursor: move;
z-index: 1;
}

&.isDragged {
z-index: 2;
}
}

.widgetInstanceLabel {
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}

.resizeHandle {
position: absolute;
bottom: -8px;
right: -8px;
width: 16px;
height: 16px;
background: var(--bs-white);
cursor: nwse-resize;
}
Loading

0 comments on commit 324b35f

Please sign in to comment.