Skip to content

Commit

Permalink
✨ [open-formulieren/open-forms#5016] Referentielijsten as dataSrc
Browse files Browse the repository at this point in the history
dynamically fetch options for select, selectboxes and radio components
  • Loading branch information
stevenbal committed Jan 28, 2025
1 parent edb202c commit 1984b83
Show file tree
Hide file tree
Showing 16 changed files with 360 additions and 61 deletions.
2 changes: 2 additions & 0 deletions src/components/ComponentConfiguration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const ComponentConfiguration: React.FC<ComponentConfigurationProps> = ({
getFormComponents,
getValidatorPlugins,
getRegistrationAttributes,
getServices,
getPrefillPlugins,
getPrefillAttributes,
getFileTypes,
Expand Down Expand Up @@ -66,6 +67,7 @@ const ComponentConfiguration: React.FC<ComponentConfigurationProps> = ({
getFormComponents,
getValidatorPlugins,
getRegistrationAttributes,
getServices,
getPrefillPlugins,
getPrefillAttributes,
getFileTypes,
Expand Down
35 changes: 35 additions & 0 deletions src/components/builder/values/referentielijsten/code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {useFormikContext} from 'formik';
import {FormattedMessage, useIntl} from 'react-intl';

import {TextField} from '@/components/formio';

const NAME = 'openForms.code';

/**
* The `ReferentielijstenTabelCode` component is used to specify the code of the tabel
* in Referentielijsten API for which the items will be fetched
*/
export const ReferentielijstenTabelCode: React.FC = () => {
const intl = useIntl();
const {setFieldValue} = useFormikContext();
const name = `editform-${NAME}`;
return (
<TextField
name={name}
label={
<FormattedMessage
description="Label for 'openForms.code' builder field"
defaultMessage="Referentielijsten table code"
/>
}
tooltip={intl.formatMessage({
description: "Description for the 'openForms.code' builder field",
defaultMessage: `The code of the table from which the options will be retrieved.`,
})}
onChange={event => setFieldValue(NAME, event.target.value)}
required
/>
);
};

export default ReferentielijstenTabelCode;
9 changes: 9 additions & 0 deletions src/components/builder/values/referentielijsten/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Components to manage options/values for fields that support this, such as:
*
* - select
* - selectboxes
* - radio
*/
export {default as ReferentielijstenServiceSelect} from './service';
export {default as ReferentielijstenTabelCode} from './code';
75 changes: 75 additions & 0 deletions src/components/builder/values/referentielijsten/service.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {useFormikContext} from 'formik';
import {useContext} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import useAsync from 'react-use/esm/useAsync';

import Select from '@/components/formio/select';
import {BuilderContext} from '@/context';

const NAME = 'openForms.service';

// TODO transform this to id and label?
export interface ReferentielijstenServiceOption {
url: string;
slug: string;
label: string;
apiRoot: string;
apiType: string;
}

function isServiceOptions(
options: ReferentielijstenServiceOption[] | undefined
): options is ReferentielijstenServiceOption[] {
return options !== undefined;
}

/**
* Fetch the available Referentielijsten Services and display them in a Select
*
* The selected service is used at runtime to retrieve options to populate a Select
*
* This requires an async function `getServices` to be provided to the
* BuilderContext which is responsible for retrieving the list of available plugins.
*
* If a fetch error occurs, it is thrown during rendering - you should provide your
* own error boundary to catch this.
*/
const ReferentielijstenServiceSelect: React.FC = () => {
const name = `editform-${NAME}`;
const intl = useIntl();
const {getServices} = useContext(BuilderContext);
const {setFieldValue} = useFormikContext();

const {
value: options,
loading,
error,
} = useAsync(async () => await getServices('referentielijsten'), []);
if (error) {
throw error;
}
const _options = isServiceOptions(options) ? options : [];

return (
<Select
name={name}
label={
<FormattedMessage
description="Label for 'openForms.service' builder field"
defaultMessage="Referentielijsten service"
/>
}
tooltip={intl.formatMessage({
description: "Description for the 'openForms.service' builder field",
defaultMessage: `The identifier of the Referentielijsten service from which the options will be retrieved.`,
})}
isLoading={loading}
options={_options}
valueProperty="slug"
onChange={event => setFieldValue(NAME, event.target.value)}
required
/>
);
};

export default ReferentielijstenServiceSelect;
28 changes: 28 additions & 0 deletions src/components/builder/values/values-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {useLayoutEffect} from 'react';
import {hasOwnProperty} from '@/types';

import ItemsExpression from './items-expression';
import {ReferentielijstenServiceSelect, ReferentielijstenTabelCode} from './referentielijsten';
import {SchemaWithDataSrc} from './types';
import ValuesSrc from './values-src';
import ValuesTable, {ValuesTableProps} from './values-table';
Expand Down Expand Up @@ -59,6 +60,12 @@ export function ValuesConfig<T extends SchemaWithDataSrc>({
if (values.openForms.hasOwnProperty('itemsExpression')) {
setFieldValue('openForms.itemsExpression', undefined);
}
if (values.openForms.hasOwnProperty('code')) {
setFieldValue('openForms.code', undefined);
}
if (values.openForms.hasOwnProperty('service')) {
setFieldValue('openForms.service', undefined);
}
if (!isNestedKeySet(values, name)) {
setFieldValue(name, [{value: '', label: '', openForms: {translations: {}}}]);
}
Expand All @@ -68,6 +75,21 @@ export function ValuesConfig<T extends SchemaWithDataSrc>({
if (isNestedKeySet(values, name)) {
setFieldValue(name, undefined);
}
if (values.openForms.hasOwnProperty('code')) {
setFieldValue('openForms.code', undefined);
}
if (values.openForms.hasOwnProperty('service')) {
setFieldValue('openForms.service', undefined);
}
break;
}
case 'referentielijsten': {
if (isNestedKeySet(values, name)) {
setFieldValue(name, undefined);
}
if (values.openForms.hasOwnProperty('itemsExpression')) {
setFieldValue('openForms.itemsExpression', undefined);
}
break;
}
}
Expand All @@ -82,6 +104,12 @@ export function ValuesConfig<T extends SchemaWithDataSrc>({
<ValuesTable<T> name={name} withOptionDescription={withOptionDescription} />
)}
{dataSrc === 'variable' && <ItemsExpression />}
{dataSrc === 'referentielijsten' && (
<>
<ReferentielijstenServiceSelect />
<ReferentielijstenTabelCode />
</>
)}
</>
);
}
Expand Down
6 changes: 5 additions & 1 deletion src/components/builder/values/values-src.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ const OPTION_LABELS = defineMessages<OptionValue>({
description: "Data source option label for value 'variable'",
defaultMessage: 'From variable',
},
referentielijsten: {
description: "Data source option label for value 'referentielijsten'",
defaultMessage: 'Referentielijsten API',
},
});

// define the values with the the desired correct order
const OPTION_VALUES = ['manual', 'variable'] as const;
const OPTION_VALUES = ['manual', 'variable', 'referentielijsten'] as const;

/**
* The `ValuesSrc` component is used to configure on the component where options/values
Expand Down
3 changes: 3 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {PrefillAttributeOption, PrefillPluginOption} from '@/components/builder/
import {RegistrationAttributeOption} from '@/components/builder/registration/registration-attribute';
import type {ColorOption} from '@/components/builder/rich-text';
import {ValidatorOption} from '@/components/builder/validate/validator-select';
import {ReferentielijstenServiceOption} from '@/components/builder/values/referentielijsten/service';
import {AuthPluginOption} from '@/registry/cosignV1/edit';
import {AnyComponentSchema} from '@/types';

Expand Down Expand Up @@ -52,6 +53,7 @@ export interface BuilderContextType {
getFormComponents: () => AnyComponentSchema[];
getValidatorPlugins: (componentType: string) => Promise<ValidatorOption[]>;
getRegistrationAttributes: (componentType: string) => Promise<RegistrationAttributeOption[]>;
getServices: (type: string) => Promise<ReferentielijstenServiceOption[]>;
getPrefillPlugins: (componentType: string) => Promise<PrefillPluginOption[]>;
getPrefillAttributes: (plugin: string) => Promise<PrefillAttributeOption[]>;
getFileTypes: () => Promise<SelectOption[]>;
Expand All @@ -70,6 +72,7 @@ const BuilderContext = React.createContext<BuilderContextType>({
getFormComponents: () => [],
getValidatorPlugins: async () => [],
getRegistrationAttributes: async () => [],
getServices: async () => [],
getPrefillPlugins: async () => [],
getPrefillAttributes: async () => [],
getFileTypes: async () => [],
Expand Down
6 changes: 5 additions & 1 deletion src/registry/radio/edit-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ const buildValuesSchema = (intl: IntlShape) =>
z.object({
values: optionSchema(intl).array().min(1).optional(),
openForms: z.object({
dataSrc: z.union([z.literal('manual'), z.literal('variable')]),
dataSrc: z.union([
z.literal('manual'),
z.literal('variable'),
z.literal('referentielijsten'),
]),
// TODO: wire up infernologic type checking
itemsExpression: jsonSchema.optional(),
}),
Expand Down
25 changes: 25 additions & 0 deletions src/registry/radio/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {RadioComponentSchema} from '@open-formulieren/types';
import {Option} from '@open-formulieren/types/lib/formio/common';
import {JSONObject} from '@open-formulieren/types/lib/types';

// A type guard is needed because TS cannot figure out it's a discriminated union
// when the discriminator is nested.
Expand All @@ -9,3 +10,27 @@ export const checkIsManualOptions = (
): component is RadioComponentSchema & {values: Option[] | undefined} => {
return component.openForms.dataSrc === 'manual';
};

// A type guard is needed because TS cannot figure out it's a discriminated union
// when the discriminator is nested.
// See https://github.com/microsoft/TypeScript/issues/18758
export const checkIsReferentielijstenOptions = (
component: RadioComponentSchema
): component is RadioComponentSchema & {
data: {values: Option[] | undefined};
openForms: {code: string; service: string};
} => {
return component.openForms.dataSrc === 'referentielijsten';
};

// A type guard is needed because TS cannot figure out it's a discriminated union
// when the discriminator is nested.
// See https://github.com/microsoft/TypeScript/issues/18758
export const checkIsVariableOptions = (
component: RadioComponentSchema
): component is RadioComponentSchema & {
data: {values: Option[] | undefined};
openForms: {itemsExpression: string | JSONObject};
} => {
return component.openForms.dataSrc === 'variable';
};
57 changes: 38 additions & 19 deletions src/registry/radio/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import {RadioComponentSchema} from '@open-formulieren/types';
import {useIntl} from 'react-intl';

import {Radio} from '@/components/formio';
import {Option} from '@/components/formio/radio';

import {ComponentPreviewProps} from '../types';
import {checkIsManualOptions} from './helpers';
import {
checkIsManualOptions,
checkIsReferentielijstenOptions,
checkIsVariableOptions,
} from './helpers';

/**
* Show a formio radio component preview.
Expand All @@ -17,24 +22,38 @@ const Preview: React.FC<ComponentPreviewProps<RadioComponentSchema>> = ({compone
const intl = useIntl();
const {key, label, description, tooltip, validate} = component;
const {required = false} = validate || {};
const isManualOptions = checkIsManualOptions(component);
const options = isManualOptions
? component.values || []
: [
{
value: 'itemsExpression',
label: intl.formatMessage(
{
description: 'Radio dummy option for itemsExpression',
defaultMessage: 'Options from expression: <code>{expression}</code>',
},
{
expression: JSON.stringify(component.openForms.itemsExpression),
code: chunks => <code>{chunks}</code>,
}
),
},
];

let options: Option[] = [];
if (checkIsManualOptions(component)) {
options = component?.values || [];
} else if (checkIsReferentielijstenOptions(component)) {
options = [
{
value: 'option1',
label: intl.formatMessage({
description: 'Radio dummy option1 from referentielijsten',
defaultMessage: 'Option from referentielijsten: <code>option1</code>',
}),
},
];
} else if (checkIsVariableOptions(component)) {
options = [
{
value: 'itemsExpression',
label: intl.formatMessage(
{
description: 'Radio dummy option for itemsExpression',
defaultMessage: 'Options from expression: <code>{expression}</code>',
},
{
expression: JSON.stringify(component.openForms.itemsExpression),
code: chunks => <code>{chunks}</code>,
}
),
},
];
}

return (
<Radio
name={key}
Expand Down
6 changes: 5 additions & 1 deletion src/registry/select/edit-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ const buildValuesSchema = (intl: IntlShape) =>
values: optionSchema(intl).array().min(1).optional(),
}),
openForms: z.object({
dataSrc: z.union([z.literal('manual'), z.literal('variable')]),
dataSrc: z.union([
z.literal('manual'),
z.literal('variable'),
z.literal('referentielijsten'),
]),
// TODO: wire up infernologic type checking
itemsExpression: jsonSchema.optional(),
}),
Expand Down
Loading

0 comments on commit 1984b83

Please sign in to comment.