Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TELESTION-460 Dashboard layout editor #417

Merged
merged 1 commit into from
Jan 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

2 changes: 1 addition & 1 deletion frontend-react/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
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 './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 className={clsx('mb-3')}>
<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>
);
}
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading