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 (
-
);
}
+
+/**
+ * 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 ;
+}
+
+/**
+ * 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.