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 21, 2024
1 parent 5159584 commit 40aef50
Show file tree
Hide file tree
Showing 27 changed files with 1,959 additions and 21 deletions.
1 change: 0 additions & 1 deletion .github/workflows/frontend-react-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,4 @@ jobs:
run: pnpm install
- name: Run unit tests 🛃
run: |
pnpm run build
pnpm run ci:test
2 changes: 2 additions & 0 deletions frontend-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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.

36 changes: 36 additions & 0 deletions frontend-react/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
LayoutEditor,
LayoutEditorState
} from '../lib/application/routes/dashboard-editor/layout-editor';
import { StrictMode, 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 (
<StrictMode>
<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>
</StrictMode>
);
}
7 changes: 6 additions & 1 deletion frontend-react/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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 ReactDOM from 'react-dom/client';
// import { App } from './app.tsx';
import { setAutoLoginCredentials } from '@wuespace/telestion/auth';

const defaultUserData: UserData = {
version: '0.0.1',
Expand Down Expand Up @@ -41,3 +43,6 @@ await initTelestion({
defaultBackendUrl: 'ws://localhost:9222',
defaultUserData
});

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
1 change: 1 addition & 0 deletions frontend-react/src/lib/application/index.scss
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
}
}
Original file line number Diff line number Diff line change
@@ -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 '@wuespace/telestion/application/routes/dashboard-editor/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
Expand All @@ -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<LayoutEditorState>({
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<HTMLSelectElement>) => {
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 (
<>
<div>Dashboard id: {dashboardId}</div>
<Form method="POST" id="dashboard-editor">
<input
type="text"
name="layout"
defaultValue={JSON.stringify(dashboard.layout)}
/>
<button>Submit layout update</button>
</Form>
</>
<Form method="POST" id="dashboard-editor">
<div className={clsx(styles.dashboardEditor)}>
<div className={clsx(styles.dashboard, 'p-3')}>
<h2>Dashboard Metadata</h2>
{errors && (
<Alert variant="danger">
{errors.errors.layout && <p>{errors.errors.layout}</p>}
</Alert>
)}
<FormGroup>
<FormLabel>Dashboard ID</FormLabel>
<FormControl readOnly name="dashboardId" value={dashboardId} />
</FormGroup>
</div>
<section className={clsx(styles.layout)}>
<h2 className={'p-3'}>Dashboard Layout</h2>
<LayoutEditor
value={localDashboard}
onChange={setLocalDashboard}
onCreateWidgetInstance={onLayoutEditorCreateWidgetInstance}
/>
<input
type="hidden"
name="layout"
value={JSON.stringify(localDashboard.layout)}
/>
<input
type="hidden"
name="widgetInstances"
value={JSON.stringify(localWidgetInstances)}
/>
<div className="px-3">
<FormGroup className={clsx('mb-3')}>
<FormLabel>Widget Instance ID</FormLabel>
<FormControl
readOnly
disabled={!selectedWidgetId}
value={selectedWidgetId ?? 'Select a widget instance above'}
/>
<FormText>
This is primarily used by developers to reference the widget.
</FormText>
</FormGroup>
<FormGroup>
<FormLabel>Widget Instance Type</FormLabel>
<FormSelect
disabled={!selectedWidgetId}
value={selectedWidgetInstance?.type ?? ''}
onChange={onFormSelectChange}
>
{!selectedWidgetId && (
<option value="" disabled>
Select a widget to configure it.
</option>
)}
{Object.values(getWidgets()).map(widget => (
<option key={widget.id} value={widget.id}>
{widget.label}
</option>
))}
</FormSelect>
<FormText>Set the type of the widget instance.</FormText>
</FormGroup>
</div>
</section>
<div className={clsx(styles.widgetInstance)}>
<h2 className="p-3 pb-0">Widget Configuration</h2>
{selectedWidgetId ? (
<div className={clsx(styles.widgetInstanceContent)}>
{getWidgetById(selectedWidgetInstance?.type ?? '')?.configElement}
</div>
) : (
<main className="px-3">Select a widget to configure it.</main>
)}
</div>
</div>
</Form>
);
}
Loading

0 comments on commit 40aef50

Please sign in to comment.