From 07d2c59b11670e2dd473418e5fdf9b55f8b8dceb Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sun, 21 Jan 2024 17:28:31 +0100 Subject: [PATCH] TELESTION-462 Make widgets configurable 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: ( ) } ``` 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 ( {props.label} setValue(e.target.value)} /> ); } ``` Everything related to widget configuration can be imported from `@wuespace/telestion/widget`. Note that this also adjusts the user data to use a `Record` instead of a `Record` 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! --- .../src/app/widgets/error-widget/index.tsx | 7 +- .../src/app/widgets/simple-widget/index.tsx | 21 +- .../dashboard-editor/dashboard-editor.tsx | 289 +++++++++++------- .../components/resize-button.tsx | 2 + .../application/routes/migration/routing.ts | 15 +- frontend-react/src/lib/user-data/index.ts | 1 + .../src/lib/user-data/json-schema.ts | 10 + frontend-react/src/lib/user-data/model.ts | 4 +- .../configuration/configuration-context.tsx | 92 ++++++ .../src/lib/widget/configuration/hooks.tsx | 63 ++++ .../src/lib/widget/configuration/index.tsx | 86 ++++++ .../src/lib/widget/configuration/model.tsx | 23 ++ frontend-react/src/lib/widget/index.ts | 1 + frontend-react/src/lib/widget/model.ts | 6 +- 14 files changed, 506 insertions(+), 114 deletions(-) create mode 100644 frontend-react/src/lib/user-data/json-schema.ts create mode 100644 frontend-react/src/lib/widget/configuration/configuration-context.tsx create mode 100644 frontend-react/src/lib/widget/configuration/hooks.tsx create mode 100644 frontend-react/src/lib/widget/configuration/index.tsx create mode 100644 frontend-react/src/lib/widget/configuration/model.tsx diff --git a/frontend-react/src/app/widgets/error-widget/index.tsx b/frontend-react/src/app/widgets/error-widget/index.tsx index 660ff20a..2dd928b3 100644 --- a/frontend-react/src/app/widgets/error-widget/index.tsx +++ b/frontend-react/src/app/widgets/error-widget/index.tsx @@ -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', @@ -10,5 +11,9 @@ export const errorWidget: Widget = { }, element: , - configElement:
Config
+ configElement: ( + + The error widget doesn't need any config controls. + + ) }; diff --git a/frontend-react/src/app/widgets/simple-widget/index.tsx b/frontend-react/src/app/widgets/simple-widget/index.tsx index ce7cd3da..4baf08c2 100644 --- a/frontend-react/src/app/widgets/simple-widget/index.tsx +++ b/frontend-react/src/app/widgets/simple-widget/index.tsx @@ -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 = { @@ -13,9 +19,20 @@ export const simpleWidget: Widget = { createConfig( input: Partial & Record ): 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: , - configElement:
Config
+ configElement: ( + + + + + ) }; diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.tsx index bbaeceb5..242b8fd6 100644 --- a/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.tsx +++ b/frontend-react/src/lib/application/routes/dashboard-editor/dashboard-editor.tsx @@ -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 '@wuespace/telestion/application/routes/dashboard-editor/layout-editor'; -import styles from './dashboard-editor.module.scss'; +import { ChangeEvent, useCallback, useEffect, useState } from 'react'; import { clsx } from 'clsx'; import { Alert, @@ -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(), @@ -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({ - 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(); @@ -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) => { + (event: ChangeEvent) => { const value = event.target.value; const widgetType = getWidgetById(value); if (!widgetType) throw new Error(`Widget type ${value} not found`); @@ -98,87 +99,167 @@ export function DashboardEditor() { [ localDashboard, localWidgetInstances, - selectedWidgetInstance?.configuration + selectedWidgetInstance?.configuration, + setLocalWidgetInstances ] ); + const onConfigurationChange = ( + newConfig: z.infer + ) => { + const selectedWidgetId = getSelectedWidgetId(localDashboard); + if (!selectedWidgetId) throw new Error(`No widget selected`); + + setLocalWidgetInstances({ + ...localWidgetInstances, + [selectedWidgetId]: { + ...localWidgetInstances[selectedWidgetId], + configuration: newConfig + } + }); + }; + return ( -
-
-
-

Dashboard Metadata

- {errors && ( - - {errors.errors.layout &&

{errors.errors.layout}

} -
- )} - - Dashboard ID - + +
+

Dashboard Metadata

+ {errors && ( + + {errors.errors.layout &&

{errors.errors.layout}

} +
+ )} + + Dashboard ID + + +
+
+

Dashboard Layout

+ + + +
+ + Widget Instance ID + + + This is primarily used by developers to reference the widget. + + + + Widget Instance Type + + {!selectedWidgetId && ( + + )} + {Object.values(getWidgets()).map(widget => ( + + ))} + + Set the type of the widget instance. -
-
-

Dashboard Layout

- - - -
- - Widget Instance ID - - - This is primarily used by developers to reference the widget. - - - - Widget Instance Type - - {!selectedWidgetId && ( - - )} - {Object.values(getWidgets()).map(widget => ( - - ))} - - Set the type of the widget instance. - -
-
-

Widget Configuration

- {selectedWidgetId ? ( -
- {getWidgetById(selectedWidgetInstance?.type ?? '')?.configElement} -
- ) : ( -
Select a widget to configure it.
- )} -
-
+ +
+

Widget Configuration

+ {selectedWidgetId ? ( + selectedWidgetType?.createConfig(x) ?? x} + > + {selectedWidgetType?.configElement} + + ) : ( +

Select a widget to configure it.

+ )} +
); } + +/** + * 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({ + layout: [['.']], + selection: { + x: 0, + y: 0 + } + }); + const [localWidgetInstances, setLocalWidgetInstances] = useState< + z.infer + >({}); + 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 + }; +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx index 743ecc57..c62cd795 100644 --- a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-button.tsx @@ -60,6 +60,8 @@ export function ResizeButton(props: { max={24} placeholder="Columns" defaultValue={props.defaultWidth} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={true} /> diff --git a/frontend-react/src/lib/application/routes/migration/routing.ts b/frontend-react/src/lib/application/routes/migration/routing.ts index 3498030c..2179972b 100644 --- a/frontend-react/src/lib/application/routes/migration/routing.ts +++ b/frontend-react/src/lib/application/routes/migration/routing.ts @@ -1,4 +1,4 @@ -import { ActionFunctionArgs, redirect } from 'react-router-dom'; +import { ActionFunctionArgs, generatePath, redirect } from 'react-router-dom'; import { isLoggedIn } from '../../../auth'; import { isUserDataUpToDate, loadFileContents } from '../../../utils'; @@ -80,9 +80,16 @@ export function migrationAction({ return { errors }; } - case 'blank': - setUserData(getBlankUserData(version)); - return redirect('/'); + case 'blank': { + const blankUserData = getBlankUserData(version); + const dashboardId = Object.keys(blankUserData.dashboards)[0]; + setUserData(blankUserData); + return redirect( + generatePath('/dashboards/:dashboardId/edit', { + dashboardId + }) + ); + } case 'existing': { const oldUserData = getUserData(); if (!oldUserData) { diff --git a/frontend-react/src/lib/user-data/index.ts b/frontend-react/src/lib/user-data/index.ts index 7df9bf34..96b7b8bf 100644 --- a/frontend-react/src/lib/user-data/index.ts +++ b/frontend-react/src/lib/user-data/index.ts @@ -10,3 +10,4 @@ */ export * from './model.ts'; export * from './state.ts'; +export { jsonSchema } from './json-schema.ts'; diff --git a/frontend-react/src/lib/user-data/json-schema.ts b/frontend-react/src/lib/user-data/json-schema.ts new file mode 100644 index 00000000..559b11b7 --- /dev/null +++ b/frontend-react/src/lib/user-data/json-schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +// Source: zod's `README.md`, crediting https://github.com/ggoodman + +const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +type Literal = z.infer; +type Json = Literal | { [key: string]: Json } | Json[]; +export const jsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) +); diff --git a/frontend-react/src/lib/user-data/model.ts b/frontend-react/src/lib/user-data/model.ts index 7a280c57..1830e6e5 100644 --- a/frontend-react/src/lib/user-data/model.ts +++ b/frontend-react/src/lib/user-data/model.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { generateDashboardId } from '../utils'; +import { jsonSchema } from './json-schema.ts'; /** * A regular expression that matches semantic version numbers. @@ -35,6 +36,7 @@ export const dashboardSchema = z.object({ */ layout: layoutSchema }); + /** * Represents the schema for a widget instance. * @@ -44,7 +46,7 @@ export const widgetInstanceSchema = z.object({ /** * The configuration of the widget. */ - configuration: z.record(z.string(), z.unknown()), + configuration: z.record(z.string(), jsonSchema), /** * The type ID of the widget. * diff --git a/frontend-react/src/lib/widget/configuration/configuration-context.tsx b/frontend-react/src/lib/widget/configuration/configuration-context.tsx new file mode 100644 index 00000000..9c843939 --- /dev/null +++ b/frontend-react/src/lib/widget/configuration/configuration-context.tsx @@ -0,0 +1,92 @@ +import { createContext, SetStateAction, useContext } from 'react'; +import { + BaseWidgetConfiguration, + WidgetConfigurationContextValue +} from '@wuespace/telestion/widget/configuration/model.tsx'; +import { Widget } from '@wuespace/telestion'; + +/** + * The context for widget configuration controls. + * + * Contains a getter and setter for the current widget configuration. + * This is similar to `useState` but for widget configurations. + * + * @internal + */ +const WidgetConfigurationContext = + createContext({ + get configuration(): never { + throw new Error( + 'Widget configuration controls can only be accessed inside a widget configuration context.' + ); + }, + setConfiguration: (): never => { + throw new Error( + 'Widget configuration controls can only be set inside a widget configuration context.' + ); + } + }); + +/** + * Similar to `useState` but for widget configurations. + * + * Only works inside a widget configuration context. Values returned and passed + * into the setter are always validated and transformed by the widget's + * {@link Widget.createConfig} function. + * + * @returns the current widget configuration and a function to update it + */ +export function useConfigureWidget() { + const { configuration, setConfiguration } = useContext( + WidgetConfigurationContext + ); + + return [configuration, setConfiguration] as const; +} + +/** + * Provides a {@link WidgetConfigurationContext} for the given children. + * @internal + * @param props - the props for the widget configuration context provider + */ +export function WidgetConfigurationContextProvider(props: { + /** + * the current value of the configuration + */ + value: BaseWidgetConfiguration; + /** + * a function to update the configuration on the parent component + */ + onChange: (s: BaseWidgetConfiguration) => void; + /** + * a function to create a valid configuration from a raw configuration + * @see Widget.createConfig + */ + createConfig: Widget['createConfig']; + /** + * the children of this context provider + * + * This should be the widget configuration controls. + */ + children: React.ReactNode; +}) { + const onSetConfiguration = ( + newConfig: SetStateAction + ) => { + newConfig = + typeof newConfig === 'function' ? newConfig(props.value) : newConfig; + newConfig = props.createConfig(newConfig); + props.onChange(newConfig); + }; + + return ( + + {props.children} + + ); +} diff --git a/frontend-react/src/lib/widget/configuration/hooks.tsx b/frontend-react/src/lib/widget/configuration/hooks.tsx new file mode 100644 index 00000000..b0a8f901 --- /dev/null +++ b/frontend-react/src/lib/widget/configuration/hooks.tsx @@ -0,0 +1,63 @@ +import { BaseWidgetConfiguration } from './model.tsx'; +import { useConfigureWidget } from './configuration-context.tsx'; +import { SetStateAction, useMemo } from 'react'; + +/** + * A hook to get and set a specific field of the current widget configuration. + * + * Only works inside a widget configuration context. Values returned and passed + * into the setter are always validated and transformed by the widget's + * {@link Widget.createConfig} function. + * + * To validate the type of the individual field, the `validator` function is used. + * + * @param name - the name of the field to get and set + * @param validator - a function to validate the type of the field + * + * @see useConfigureWidget + * + * @returns the current value of the field and a function to update it + * @throws Error - if the field does not exist in the widget configuration + * @throws Error - if the type of the field does not match the validator + * + * @example Basic usage + * ```ts + * // Config: { text: string } + * const [text, setText] = useConfigureWidgetField('text', s => z.string().parse(s)); + * + * return setText(e.target.value)} />; + * ``` + */ +export function useConfigureWidgetField< + T extends BaseWidgetConfiguration[string] +>(name: string, validator: (v: unknown) => T) { + const [widgetConfiguration, setValue] = useConfigureWidget(); + return useMemo(() => { + const onSetValue = (newValue: SetStateAction) => + setValue(oldWidgetConfiguration => { + try { + if (typeof newValue === 'function') + newValue = newValue(validator(oldWidgetConfiguration[name])); + newValue = validator(newValue); + return { ...oldWidgetConfiguration, [name]: newValue }; + } catch (e) { + if (e instanceof Error) + throw new Error( + `Type error while trying to set widget configuration field "${name}". Details: ${e.message}` + ); + else throw e; + } + }); + + try { + const validatedField = validator(widgetConfiguration[name]); + return [validatedField, onSetValue] as const; + } catch (e) { + if (e instanceof Error) + throw new Error( + `Widget configuration does not contain a property named "${name}". Please adjust your createConfig function. Details: ${e.message}` + ); + else throw e; + } + }, [name, validator, widgetConfiguration, setValue]); +} diff --git a/frontend-react/src/lib/widget/configuration/index.tsx b/frontend-react/src/lib/widget/configuration/index.tsx new file mode 100644 index 00000000..eec50d63 --- /dev/null +++ b/frontend-react/src/lib/widget/configuration/index.tsx @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { FormCheck, FormControl, FormGroup, FormLabel } from 'react-bootstrap'; +import { useConfigureWidgetField } from './hooks.tsx'; +import { ReactNode } from 'react'; + +export type { BaseWidgetConfiguration } from './model.tsx'; +export * from './hooks.tsx'; +export { useConfigureWidget } from './configuration-context.tsx'; + +// Helper components + +/** + * Wraps the widget configuration controls and gives them the correct margins. + * + * Should be used inside the widget configuration element. + * + * @see Widget.configElement + */ +export function WidgetConfigWrapper({ children }: { children: ReactNode }) { + return
{children}
; +} + +/** + * A checkbox field for the widget configuration. + * @param props - the props for the checkbox field + * + * @example + * ```tsx + * // Config: { enabled: boolean } + * configElement: + * + * + * ``` + * + * @see Widget.configElement + */ +export function WidgetConfigCheckboxField(props: { + label: string; + name: string; +}) { + const [checked, setChecked] = useConfigureWidgetField(props.name, b => + z.boolean().parse(b) + ); + + return ( + + setChecked(e.target.checked)} + /> + + ); +} + +/** + * A text field for the widget configuration. + * @param props - the props for the text field + * + * @example + * ```tsx + * // Config: { text: string } + * configElement: + * + * + * ``` + * + * @see Widget.configElement + */ +export function WidgetConfigTextField(props: { label: string; name: string }) { + const [value, setValue] = useConfigureWidgetField(props.name, s => + z.string().parse(s) + ); + + return ( + + {props.label} + setValue(e.target.value)} + /> + + ); +} diff --git a/frontend-react/src/lib/widget/configuration/model.tsx b/frontend-react/src/lib/widget/configuration/model.tsx new file mode 100644 index 00000000..6ecc38ad --- /dev/null +++ b/frontend-react/src/lib/widget/configuration/model.tsx @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { SetStateAction } from 'react'; +import { widgetInstanceSchema } from '@wuespace/telestion/user-data'; + +/** + * The base type for all widget configurations. + * + * A JSON object that contains JSON-serializable values under string keys. + */ +export type BaseWidgetConfiguration = z.infer< + typeof widgetInstanceSchema.shape.configuration +>; + +/** + * The context value for widget configuration controls. + * @internal + */ +export interface WidgetConfigurationContextValue< + T extends BaseWidgetConfiguration = BaseWidgetConfiguration +> { + configuration: T; + setConfiguration: (s: SetStateAction) => void; +} diff --git a/frontend-react/src/lib/widget/index.ts b/frontend-react/src/lib/widget/index.ts index 83ef0548..6f747ec4 100644 --- a/frontend-react/src/lib/widget/index.ts +++ b/frontend-react/src/lib/widget/index.ts @@ -11,3 +11,4 @@ export * from './model.ts'; export * from './state.ts'; export * from './component/widget-renderer.tsx'; +export * from './configuration'; diff --git a/frontend-react/src/lib/widget/model.ts b/frontend-react/src/lib/widget/model.ts index 230579dc..76355db1 100644 --- a/frontend-react/src/lib/widget/model.ts +++ b/frontend-react/src/lib/widget/model.ts @@ -1,5 +1,7 @@ import { ReactNode } from 'react'; +import { BaseWidgetConfiguration } from '@wuespace/telestion/widget/configuration/model.tsx'; + /** * A widget that can be used in widget instances on dashboards. * @@ -7,7 +9,7 @@ import { ReactNode } from 'react'; * @see {@link userData.WidgetInstance} */ export interface Widget< - T extends Record = Record + T extends BaseWidgetConfiguration = BaseWidgetConfiguration > { /** * Represents an identifier of the widget type. @@ -28,7 +30,7 @@ export interface Widget< * of the configuration options to enable more complex migration logic in this function. * @param input - previous configuration or empty */ - createConfig(input: Partial & Record): T; + createConfig(input: Partial & BaseWidgetConfiguration): T; /** * A function that takes the configuration of the widget and returns a React element that represents the widget.