Skip to content

Commit

Permalink
TELESTION-462 Make widgets configurable
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 committed Jan 21, 2024
1 parent 835d7fa commit 881e234
Show file tree
Hide file tree
Showing 12 changed files with 228 additions and 7 deletions.
2 changes: 1 addition & 1 deletion frontend-react/src/app/widgets/error-widget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export const errorWidget: Widget = {
},

element: <ErrorWidget />,
configElement: <div>Config</div>
configElement: <div>The error widget doesn't need any config controls.</div>
};
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
Expand Up @@ -20,6 +20,8 @@ import {
import { generateDashboardId } from '@wuespace/telestion/utils';
import { getWidgetById, getWidgets } from '@wuespace/telestion/widget';

import { WidgetConfigurationContextProvider } from '@wuespace/telestion/widget/configuration/configuration-context.tsx';

const loaderSchema = z.object({
dashboardId: z.string(),
dashboard: dashboardSchema,
Expand Down Expand Up @@ -102,6 +104,22 @@ export function DashboardEditor() {
]
);

const configuration = selectedWidgetInstance?.configuration ?? {};
const onSetConfiguration = (
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)}>
Expand Down Expand Up @@ -172,7 +190,20 @@ export function DashboardEditor() {
<h2 className="p-3 pb-0">Widget Configuration</h2>
{selectedWidgetId ? (
<div className={clsx(styles.widgetInstanceContent)}>
{getWidgetById(selectedWidgetInstance?.type ?? '')?.configElement}
<WidgetConfigurationContextProvider
configuration={configuration}
setConfiguration={onSetConfiguration}
createConfig={x =>
getWidgetById(
selectedWidgetInstance?.type ?? ''
)?.createConfig(x) ?? x
}
>
{
getWidgetById(selectedWidgetInstance?.type ?? '')
?.configElement
}
</WidgetConfigurationContextProvider>
</div>
) : (
<main className="px-3">Select a widget to configure it.</main>
Expand Down
1 change: 1 addition & 0 deletions frontend-react/src/lib/user-data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
*/
export * from './model.ts';
export * from './state.ts';
export { jsonSchema } from './json-schema.ts';
10 changes: 10 additions & 0 deletions frontend-react/src/lib/user-data/json-schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof literalSchema>;
type Json = Literal | { [key: string]: Json } | Json[];
export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])
);
4 changes: 3 additions & 1 deletion frontend-react/src/lib/user-data/model.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -35,6 +36,7 @@ export const dashboardSchema = z.object({
*/
layout: layoutSchema
});

/**
* Represents the schema for a widget instance.
*
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createContext, SetStateAction, useContext } from 'react';
import {
BaseWidgetConfiguration,
WidgetConfigurationContextValue
} from '@wuespace/telestion/widget/configuration/model.tsx';
import { Widget } from '@wuespace/telestion';

const WidgetConfigurationContext =
createContext<WidgetConfigurationContextValue>({
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.'
);
}
});

export function useConfigureWidget() {
const { configuration, setConfiguration } = useContext(
WidgetConfigurationContext
);

return [configuration, setConfiguration] as const;
}

export function WidgetConfigurationContextProvider(props: {
configuration: BaseWidgetConfiguration;
setConfiguration: (s: BaseWidgetConfiguration) => void;
createConfig: Widget['createConfig'];
children: React.ReactNode;
}) {
const onSetConfiguration = (
newConfig: SetStateAction<BaseWidgetConfiguration>
) => {
newConfig =
typeof newConfig === 'function'
? newConfig(props.configuration)
: newConfig;
newConfig = props.createConfig(newConfig);
props.setConfiguration(newConfig);
};

return (
<WidgetConfigurationContext.Provider
value={{
configuration: props.createConfig(props.configuration),
setConfiguration: onSetConfiguration
}}
>
{props.children}
</WidgetConfigurationContext.Provider>
);
}
37 changes: 37 additions & 0 deletions frontend-react/src/lib/widget/configuration/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { BaseWidgetConfiguration } from './model.tsx';
import { useConfigureWidget } from './configuration-context.tsx';
import { SetStateAction, useMemo } from 'react';

export function useConfigureWidgetField<
T extends BaseWidgetConfiguration[string]
>(name: string, validator: (v: unknown) => T) {
const [widgetConfiguration, setValue] = useConfigureWidget();
return useMemo(() => {
const onSetValue = (newValue: SetStateAction<T>) =>
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]);
}
49 changes: 49 additions & 0 deletions frontend-react/src/lib/widget/configuration/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { z } from 'zod';
import { FormCheck, FormControl, FormGroup, FormLabel } from 'react-bootstrap';
import { useConfigureWidgetField } from './hooks.tsx';
import { ReactNode } from 'react';

export * from './hooks.tsx';
export { useConfigureWidget } from './configuration-context.tsx';

// Helper components
export function WidgetConfigWrapper({ children }: { children: ReactNode }) {
return <section className={'px-3'}>{children}</section>;
}

export function WidgetConfigCheckboxField(props: {
label: string;
name: string;
}) {
const [checked, setChecked] = useConfigureWidgetField(props.name, b =>
z.boolean().parse(b)
);

return (
<FormGroup className={'mb-3'}>
<FormCheck
data-name={props.name}
label={props.label}
checked={checked}
onChange={e => setChecked(e.target.checked)}
/>
</FormGroup>
);
}

export 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>
);
}
14 changes: 14 additions & 0 deletions frontend-react/src/lib/widget/configuration/model.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z } from 'zod';
import { SetStateAction } from 'react';
import { widgetInstanceSchema } from '@wuespace/telestion/user-data';

export type BaseWidgetConfiguration = z.infer<
typeof widgetInstanceSchema.shape.configuration
>;

export interface WidgetConfigurationContextValue<
T extends BaseWidgetConfiguration = BaseWidgetConfiguration
> {
configuration: T;
setConfiguration: (s: SetStateAction<T>) => void;
}
1 change: 1 addition & 0 deletions frontend-react/src/lib/widget/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
export * from './model.ts';
export * from './state.ts';
export * from './component/widget-renderer.tsx';
export * from './configuration';
6 changes: 4 additions & 2 deletions frontend-react/src/lib/widget/model.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { ReactNode } from 'react';

import { BaseWidgetConfiguration } from '@wuespace/telestion/widget/configuration/model.tsx';

/**
* A widget that can be used in widget instances on dashboards.
*
* @typeParam T - the type of the widget configuration
* @see {@link userData.WidgetInstance}
*/
export interface Widget<
T extends Record<string, unknown> = Record<string, unknown>
T extends BaseWidgetConfiguration = BaseWidgetConfiguration
> {
/**
* Represents an identifier of the widget type.
Expand All @@ -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<T> & Record<string, unknown>): T;
createConfig(input: Partial<T> & BaseWidgetConfiguration): T;

/**
* A function that takes the configuration of the widget and returns a React element that represents the widget.
Expand Down

0 comments on commit 881e234

Please sign in to comment.