Skip to content

Commit

Permalink
TELESTION-462 Make widgets configurable (#418)
Browse files Browse the repository at this point in the history
Uses a context to make widgets configurable. While currently, configuration is only possible in the edit dashboard page, this context can, in the future, be used to also allow editing the configuration in other places, such as within the widget itself.

For convenience, components are provided for basic confiugration fields such as textfields and checkboxes. This makes configurability as easy as this:

```tsx
{
	...
	configElement: (
		<WidgetConfigWrapper>
			<WidgetConfigCheckboxField label={'Bool value'} name={'bool'} />
			<WidgetConfigTextField label={'Test Text'} name={'text'} />
		</WidgetConfigWrapper>
	)
}
```

It is also possible to create custom configuration fields (using `useConfigureWidgetField(name, validator)`) or even fully custom configuration UIs (using `useConfigureWidget()`). Both of these hooks return both the current configuration and a function that works the same way a `useState()`-setter works. Note that any congiuration passed into or out of the confiuration controls automatically, controlled by the context, get validated using the widget's `createConfig` function.

Example of using the `useConfiugreWidgetField()` hook:

```tsx
function WidgetConfigTextField(props: { label: string; name: string }) {
	const [value, setValue] = useConfigureWidgetField(props.name, s =>
		z.string().parse(s)
	);

	return (
		<FormGroup className={'mb-3'}>
			<FormLabel>{props.label}</FormLabel>
			<FormControl
				data-name={props.name}
				value={value}
				onChange={e => setValue(e.target.value)}
			/>
		</FormGroup>
	);
}
```

Everything related to widget configuration can be imported from `@wuespace/telestion/widget`.

Note that this also adjusts the user data to use a `Record<string, jsonSchema>` instead of a `Record<string, unknown>` as the widget instance configuration type. The `jsonSchema` implementation is taken from the zod documentation (`README.md`) wiwhere https://github.com/ggoodman is credited; thank you for this great implementation!
  • Loading branch information
pklaschka authored Jan 26, 2024
2 parents 6a83e2f + c271b9e commit 66ce249
Show file tree
Hide file tree
Showing 14 changed files with 506 additions and 114 deletions.
7 changes: 6 additions & 1 deletion frontend-react/src/app/widgets/error-widget/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ErrorWidget } from './error-widget.tsx';
import { Widget } from '../../../lib';
import { WidgetConfigWrapper } from '@wuespace/telestion/widget';

export const errorWidget: Widget = {
id: 'error-widget',
Expand All @@ -10,5 +11,9 @@ export const errorWidget: Widget = {
},

element: <ErrorWidget />,
configElement: <div>Config</div>
configElement: (
<WidgetConfigWrapper>
The error widget doesn't need any config controls.
</WidgetConfigWrapper>
)
};
21 changes: 19 additions & 2 deletions frontend-react/src/app/widgets/simple-widget/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { z } from 'zod';
import { SimpleWidget } from './simple-widget.tsx';
import { Widget } from '../../../lib';
import {
WidgetConfigCheckboxField,
WidgetConfigTextField,
WidgetConfigWrapper
} from '@wuespace/telestion/widget';

export type WidgetConfig = {
text: string;
bool: boolean;
};

export const simpleWidget: Widget<WidgetConfig> = {
Expand All @@ -13,9 +19,20 @@ export const simpleWidget: Widget<WidgetConfig> = {
createConfig(
input: Partial<WidgetConfig> & Record<string, unknown>
): WidgetConfig {
return { text: z.string().catch('Initial Text').parse(input.text) };
return z
.object({
text: z.string().catch('Initial Text'),
bool: z.boolean().catch(false)
})
.default({})
.parse(input);
},

element: <SimpleWidget />,
configElement: <div>Config</div>
configElement: (
<WidgetConfigWrapper>
<WidgetConfigCheckboxField label={'Bool value'} name={'bool'} />
<WidgetConfigTextField label={'Test Text'} name={'text'} />
</WidgetConfigWrapper>
)
};
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import { z } from 'zod';
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 { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { clsx } from 'clsx';
import {
Alert,
Expand All @@ -17,8 +10,21 @@ import {
FormSelect,
FormText
} from 'react-bootstrap';

import {
dashboardSchema,
widgetInstanceSchema
} from '@wuespace/telestion/user-data';
import { generateDashboardId } from '@wuespace/telestion/utils';
import { getWidgetById, getWidgets } from '@wuespace/telestion/widget';
import { WidgetConfigurationContextProvider } from '@wuespace/telestion/widget/configuration/configuration-context.tsx';
import {
LayoutEditor,
LayoutEditorState,
selectedWidgetId as getSelectedWidgetId
} from './layout-editor';

import styles from './dashboard-editor.module.scss';

const loaderSchema = z.object({
dashboardId: z.string(),
Expand All @@ -35,20 +41,20 @@ const actionSchema = z
.optional();

export function DashboardEditor() {
const { dashboardId, dashboard, widgetInstances } =
loaderSchema.parse(useLoaderData());
const errors = actionSchema.parse(useActionData());

const [localDashboard, setLocalDashboard] = useState<LayoutEditorState>({
layout: dashboard.layout,
selection: {
x: 0,
y: 0
}
});
const {
localDashboard,
setLocalDashboard,
localWidgetInstances,
setLocalWidgetInstances,
selectedWidgetInstance,
selectedWidgetId,
selectedWidgetType,
configuration,
dashboardId
} = useDashboardEditorData();

const [localWidgetInstances, setLocalWidgetInstances] =
useState(widgetInstances);
const onLayoutEditorCreateWidgetInstance = useCallback(() => {
const newId = generateDashboardId();
const widgetTypes = getWidgets();
Expand All @@ -57,24 +63,19 @@ export function DashboardEditor() {
const configuration = widgetType.createConfig({});
const type = widgetType.id;

setLocalWidgetInstances({
...localWidgetInstances,
setLocalWidgetInstances(oldLocalWidgetInstances => ({
...oldLocalWidgetInstances,
[newId]: {
type,
configuration
}
});
}));

return newId;
}, [localWidgetInstances]);

const selectedWidgetId = getSelectedWidgetId(localDashboard);
const selectedWidgetInstance = !selectedWidgetId
? undefined
: localWidgetInstances[selectedWidgetId];
}, [setLocalWidgetInstances]);

const onFormSelectChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
(event: ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value;
const widgetType = getWidgetById(value);
if (!widgetType) throw new Error(`Widget type ${value} not found`);
Expand All @@ -98,87 +99,167 @@ export function DashboardEditor() {
[
localDashboard,
localWidgetInstances,
selectedWidgetInstance?.configuration
selectedWidgetInstance?.configuration,
setLocalWidgetInstances
]
);

const onConfigurationChange = (
newConfig: z.infer<typeof widgetInstanceSchema.shape.configuration>
) => {
const selectedWidgetId = getSelectedWidgetId(localDashboard);
if (!selectedWidgetId) throw new Error(`No widget selected`);

setLocalWidgetInstances({
...localWidgetInstances,
[selectedWidgetId]: {
...localWidgetInstances[selectedWidgetId],
configuration: newConfig
}
});
};

return (
<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} />
<Form
method="POST"
id="dashboard-editor"
className={clsx(styles.dashboardEditor)}
>
<section 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>
</section>
<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)}
/>
<section 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 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>
</section>
<section className={clsx(styles.widgetInstance)}>
<h2 className="p-3 pb-0">Widget Configuration</h2>
{selectedWidgetId ? (
<WidgetConfigurationContextProvider
value={configuration}
onChange={onConfigurationChange}
createConfig={x => selectedWidgetType?.createConfig(x) ?? x}
>
{selectedWidgetType?.configElement}
</WidgetConfigurationContextProvider>
) : (
<p className="px-3">Select a widget to configure it.</p>
)}
</section>
</Form>
);
}

/**
* Stores a local working copy of the dashboard data that can be used before
* submitting the form.
*
* @returns the local working copy of the dashboard data
*/
function useDashboardEditorData() {
const loaderData = useLoaderData();
const [localDashboard, setLocalDashboard] = useState<LayoutEditorState>({
layout: [['.']],
selection: {
x: 0,
y: 0
}
});
const [localWidgetInstances, setLocalWidgetInstances] = useState<
z.infer<typeof loaderSchema.shape.widgetInstances>
>({});
const [dashboardId, setDashboardId] = useState('');

// create the local working copy of the data whenever the loader data changes
useEffect(() => {
const { dashboardId, dashboard, widgetInstances } =
loaderSchema.parse(loaderData);

setLocalDashboard({
selection: {
x: 0,
y: 0
},
layout: dashboard.layout
});
setLocalWidgetInstances(widgetInstances);
setDashboardId(dashboardId);
}, [loaderData]);

const selectedWidgetId = getSelectedWidgetId(localDashboard);
const selectedWidgetInstance = !selectedWidgetId
? undefined
: localWidgetInstances[selectedWidgetId];

const configuration = selectedWidgetInstance?.configuration ?? {};

const selectedWidgetType = getWidgetById(selectedWidgetInstance?.type ?? '');

return {
localDashboard,
setLocalDashboard,
localWidgetInstances,
setLocalWidgetInstances,
selectedWidgetInstance,
selectedWidgetId,
configuration,
selectedWidgetType,
dashboardId
};
}
Loading

0 comments on commit 66ce249

Please sign in to comment.