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.