-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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: ( <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
Showing
12 changed files
with
335 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,3 +10,4 @@ | |
*/ | ||
export * from './model.ts'; | ||
export * from './state.ts'; | ||
export { jsonSchema } from './json-schema.ts'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)]) | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
92 changes: 92 additions & 0 deletions
92
frontend-react/src/lib/widget/configuration/configuration-context.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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.' | ||
); | ||
} | ||
}); | ||
|
||
/** | ||
* 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<BaseWidgetConfiguration> | ||
) => { | ||
newConfig = | ||
typeof newConfig === 'function' ? newConfig(props.value) : newConfig; | ||
newConfig = props.createConfig(newConfig); | ||
props.onChange(newConfig); | ||
}; | ||
|
||
return ( | ||
<WidgetConfigurationContext.Provider | ||
value={{ | ||
configuration: props.createConfig(props.value), | ||
setConfiguration: onSetConfiguration | ||
}} | ||
> | ||
{props.children} | ||
</WidgetConfigurationContext.Provider> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <input value={text} onChange={e => 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<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]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <section className={'px-3'}>{children}</section>; | ||
} | ||
|
||
/** | ||
* A checkbox field for the widget configuration. | ||
* @param props - the props for the checkbox field | ||
* | ||
* @example | ||
* ```tsx | ||
* // Config: { enabled: boolean } | ||
* configElement: <WidgetConfigWrapper> | ||
* <WidgetConfigCheckboxField label={'Enabled'} name={'enabled'} /> | ||
* </WidgetConfigWrapper> | ||
* ``` | ||
* | ||
* @see Widget.configElement | ||
*/ | ||
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> | ||
); | ||
} | ||
|
||
/** | ||
* A text field for the widget configuration. | ||
* @param props - the props for the text field | ||
* | ||
* @example | ||
* ```tsx | ||
* // Config: { text: string } | ||
* configElement: <WidgetConfigWrapper> | ||
* <WidgetConfigTextField label={'Text'} name={'text'} /> | ||
* </WidgetConfigWrapper> | ||
* ``` | ||
* | ||
* @see Widget.configElement | ||
*/ | ||
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> | ||
); | ||
} |
Oops, something went wrong.