From 23fa1261eaab828ff4692b9ab44831bb7c17375b Mon Sep 17 00:00:00 2001 From: Lukas Boll Date: Mon, 9 Oct 2023 13:48:22 +0200 Subject: [PATCH] add dynamic enum Tutorial Co-authored-by: Stefan Dirix closes #270 --- content/docs/tutorial/dynamic-enum.mdx | 268 ++++++++++++++++++ docusaurus.config.js | 4 + src/components/common/api.js | 92 ++++++ .../common/country/CountryControl.js | 52 ++++ .../common/country/countryControlTester.js | 6 + src/components/common/region/RegionControl.js | 53 ++++ .../common/region/regionControlTester.js | 6 + src/components/docs/tutorials/dynamic-enum.js | 66 +++++ src/sidebars/docs.js | 2 +- 9 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 content/docs/tutorial/dynamic-enum.mdx create mode 100644 src/components/common/api.js create mode 100644 src/components/common/country/CountryControl.js create mode 100644 src/components/common/country/countryControlTester.js create mode 100644 src/components/common/region/RegionControl.js create mode 100644 src/components/common/region/regionControlTester.js create mode 100644 src/components/docs/tutorials/dynamic-enum.js diff --git a/content/docs/tutorial/dynamic-enum.mdx b/content/docs/tutorial/dynamic-enum.mdx new file mode 100644 index 00000000..a343754f --- /dev/null +++ b/content/docs/tutorial/dynamic-enum.mdx @@ -0,0 +1,268 @@ +--- +id: dynamic-enum +title: Dynamic Renderers +description: This tutorial describes how to create a dynamic enum +--- + +import { WithRegionRenderer } from '../../../src/components/docs/tutorials/dynamic-enum'; + + +In this tutorial, you will learn how to handle dynamic data in React using [custom renderers](./custom-renderers), React Context, and the `useJsonForms` hook. +This approach allows you to build flexible and interactive forms that adapt to user selections and API responses. + +### Scenario + +Imagine a form where users need to provide their location by selecting a country, a region and a city. +The options for countries and regions are fetched from an API. +The available regions depend on the selected country. +To address those requirements, we'll create custom renderers for country and region. + + + + +#### Schema + +To begin, let's introduce the corresponding JSON schema. +We have created an object with properties for country, region, and city. +In our example, the schema also includes a property `x-url`, which specifies the entry point of the corresponding API. +Both `country` and `region` have a property `x-endpoint`, indicating the endpoint from which the data should be fetched. +Additionally, they have a field specifying which fields depend on the input. +In the case of the `country` field, the `region` and `city` fields depend on it and will get reset, if the value of the `country` changes. +The `city` field, in turn, is dependent on the `region` field. + +```js +{ + "type": "object", + "x-url": "www.api.com", + "properties": { + "country": { + "type": "string", + "x-endpoint": "countries", + "dependencies": ["region", "city"] + }, + "region": { + "type": "string", + "x-endpoint": "regions", + "dependencies": ["city"] + }, + "city": { + "type": "string" + } + } +} +``` + + +### Accessing Schema Data and Initialising the React Context + +In this step we will access the data from the schema and initialize the react context. + +#### Accessing the API URL from Schema + +To access the URL defined from the schema we can simply access the `x-url` attribute. + +```js +const url = schema['x-url']; +``` + +#### Initializing the React Context + +Now that we have access to the API URL, we can use React Context to make this data available across our renderers. +[React Context](https://react.dev/learn/passing-data-deeply-with-context) allows you to share data deep in the component tree to access data without needing to pass additional properties through the component hierarchy. +To set up the React Context for your API service, create it in your application as follows: + +```js +export const APIContext = React.createContext(new API(url)); + +const App = () =>{ + + ... + +} +``` + +#### Accessing the API context + + +Access the API service using the context: + +```js +const api = React.useContext(APIContext); +``` + +Changing the context's value will trigger a re-render of components that use it. + + +### The Country Renderer + +The core of the country renderer is a dropdown, therefore we can reuse the MaterialEnumControl from the React Material renderer set. +To reuse material renderers, the Unwrapped renderers must be used. (more information regarding reusing renderers can be seen [here](./custom-renderers#reusing-existing-controls)) + +```js +import { Unwrapped, WithOptionLabel } from '@jsonforms/material-renderers'; + +const { MaterialEnumControl } = Unwrapped; + +... + + +... +``` + +With the `MaterialEnumControl`in place the main question remains how to set the `options` and the `handleChange` attribute. +To determine the available options, we need to access the API. +And to implement the `handleChange` function, we need access to the `dependent` field in the schema. + +#### Accessing Schema Data + +The `endpoint` and `dependent` fields can be obtained from the schema object provided to the custom renderer via JSON Forms. +Since these fields are not part of the standard JSON schema type in JSON Forms, we must add them to the schema's interface and access them as follows: + +```js +type JsonSchemaWithDependenciesAndEndpoint = JsonSchema & { + dependent: string[]; + endpoint: string; +}; +const CountryControl = ( + props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps +) => { +... + + const schema = props.schema as JsonSchemaWithDependenciesAndEndpoint; + const endpoint = schema.endpoint; + const dependent = schema.dependent +... +} +``` + +#### Country Renderer Implementation + +The country renderer uses the `APIContext` to query the API and fetch the available options. +We utilize the `useEffect` hook to initialize the options. +While waiting for the API response, we set the available options to empty and display a loading spinner. +In the `handleChange` function, we set the new selected value and reset all dependent fields; +When changing the country, both the region and city will be reset to `undefined`. + +```js +import { Unwrapped, WithOptionLabel } from '@jsonforms/material-renderers'; + +const { MaterialEnumControl } = Unwrapped; + +type JsonSchemaWithDependenciesAndEndpoint = JsonSchema & { + dependent: string[]; + endpoint: string; +}; + +const CountryControl = ( + props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps +) => { + const { handleChange } = props; + const [options, setOptions] = useState([]); + const api = React.useContext(APIContext); + const schema = props.schema as JsonSchemaWithDependenciesAndEndpoint; + + const endpoint = schema.endpoint; + const dependent: string[] = schema.dependent ? schema.dependent : []; + + useEffect(() => { + api.get(endpoint).then((result) => { + setOptions(result); + }); + }, []); + + if (options.length === 0) { + return ; + } + + return ( + { + handleChange(path, value); + dependent.forEach((path) => { + handleChange(path, undefined); + }); + }} + options={options.map((option) => { + return { label: option, value: option }; + })} + /> + ); +}; + +export default withJsonFormsEnumProps( + withTranslateProps(React.memo(CountryControl)), + false +); +``` + +Now all that´s left to do is to [create a tester](./custom-renderers#2-create-a-tester) and [register](./custom-renderers#3-register-the-renderer) the new custom renderer in our application. + +### The Region Renderer + +The region renderer can be implemented similarly to the country renderer. +It also accesses the API via the context and includes `endpoint` and `dependent` fields defined in its schema. +However, the options, on the other hand, are also dependent on the selected country. +JSON Forms provides the `useJsonForms` hook, allowing you to access form data and trigger component rerenders when the data changes. +Let's use this hook in our region renderer to access the selected country: + +```js +import { Unwrapped, WithOptionLabel } from '@jsonforms/material-renderers'; +const { MaterialEnumControl } = Unwrapped; + +type JsonSchemaWithDependenciesAndEndpont = JsonSchema & { + dependent: string[]; + endpoint: string; +}; + +const RegionControl = ( + props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps +) => { + const schema = props.schema as JsonSchemaWithDependenciesAndEndpont; + const { handleChange } = props; + const [options, setOptions] = useState([]); + const api = React.useContext(APIContext); + const country = useJsonForms().core?.data.country; + const [previousCountry, setPreviousCountry] = useState(); + + const endpoint = schema.endpoint; + const dependent: string[] = schema.dependent ? schema.dependent : []; + + if (previousCountry !== country) { + setOptions([]); + setPreviousCountry(country); + api.get(endpoint + '/' + country).then((result) => { + setOptions(result); + }); + } + + if (options.length === 0 && country !== undefined) { + return ; + } + + return ( + { + handleChange(path, value); + dependent.forEach((path) => { + handleChange(path, undefined); + }); + }} + options={options.map((option) => { + return { label: option, value: option }; + })} + /> + ); +}; + +export default withJsonFormsEnumProps( + withTranslateProps(React.memo(RegionControl)), + false +); +``` +Again we need to create a [create a tester](./custom-renderers#2-create-a-tester) and [register](./custom-renderers#3-register-the-renderer) the new custom renderer. \ No newline at end of file diff --git a/docusaurus.config.js b/docusaurus.config.js index 3e3a0256..1b45a340 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -222,6 +222,10 @@ module.exports = { to: '/docs/tutorial/custom-renderers', from: '/docs/custom-renderers', }, + { + to: '/docs/tutorial/dynamic-enum', + from: '/docs/dynamic-enum', + }, { to: '/docs/tutorial/multiple-forms', from: '/docs/multiple-forms', diff --git a/src/components/common/api.js b/src/components/common/api.js new file mode 100644 index 00000000..05667214 --- /dev/null +++ b/src/components/common/api.js @@ -0,0 +1,92 @@ +export class API { + url; + + constructor(url) { + this.url = url; + } + + async get(endpoint){ + switch (this.url + '/' + endpoint) { + case 'www.api.com/regions/Germany': + return germanStates; + case 'www.api.com/regions/US': + return usStates; + case 'www.api.com/countries': + return ['Germany', 'US']; + default: + return []; + } + } +} + +const germanStates = [ + 'Berlin', + 'Bayern', + 'Niedersachsen', + 'Baden-Württemberg', + 'Rheinland-Pfalz', + 'Sachsen', + 'Thüringen', + 'Hessen', + 'Nordrhein-Westfalen', + 'Sachsen-Anhalt', + 'Brandenburg', + 'Mecklenburg-Vorpommern', + 'Hamburg', + 'Schleswig-Holstein', + 'Saarland', + 'Bremen', +]; + +const usStates = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'New Mexico', + 'New York', + 'North Carolina', + 'North Dakota', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming', +]; diff --git a/src/components/common/country/CountryControl.js b/src/components/common/country/CountryControl.js new file mode 100644 index 00000000..0eb87d7f --- /dev/null +++ b/src/components/common/country/CountryControl.js @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { useState } from 'react'; +import { withJsonFormsEnumProps, withTranslateProps } from '@jsonforms/react'; +import { CircularProgress } from '@mui/material'; +import { Unwrapped } from '@jsonforms/material-renderers'; +import { APIContext } from '../../docs/tutorials/dynamic-enum'; + +const { MaterialEnumControl } = Unwrapped; + + +const CountryControl = ( + props +) => { + const { handleChange } = props; + const [options, setOptions] = useState([]); + const api = React.useContext(APIContext); + const schema = props.schema ; + + const endponit = schema.endpoint; + const dependent = schema.dependent ? schema.dependent : []; + + useEffect(() => { + setOptions([]); + api.get(endponit).then((result) => { + setOptions(result); + }); + }, [api, endponit]); + + if (options.length === 0) { + return ; + } + + return ( + { + handleChange(path, value); + dependent.forEach((path) => { + handleChange(path, undefined); + }); + }} + options={options.map((option) => { + return { label: option, value: option }; + })} + /> + ); +}; + +export default withJsonFormsEnumProps( + withTranslateProps(React.memo(CountryControl)), + false +); \ No newline at end of file diff --git a/src/components/common/country/countryControlTester.js b/src/components/common/country/countryControlTester.js new file mode 100644 index 00000000..a6a7d559 --- /dev/null +++ b/src/components/common/country/countryControlTester.js @@ -0,0 +1,6 @@ +import { rankWith, scopeEndsWith } from '@jsonforms/core'; + +export default rankWith( + 3, //increase rank as needed + scopeEndsWith('country') +); diff --git a/src/components/common/region/RegionControl.js b/src/components/common/region/RegionControl.js new file mode 100644 index 00000000..6c5ce02b --- /dev/null +++ b/src/components/common/region/RegionControl.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { useState } from 'react'; +import { useJsonForms, withJsonFormsEnumProps, withTranslateProps } from '@jsonforms/react'; +import { CircularProgress } from '@mui/material'; +import { Unwrapped } from '@jsonforms/material-renderers'; +import { APIContext } from '../../docs/tutorials/dynamic-enum'; +const { MaterialEnumControl } = Unwrapped; + +const RegionControl = ( + props +) => { + const schema = props.schema; + const { handleChange } = props; + const [options, setOptions] = useState([]); + const api = React.useContext(APIContext); + const country = useJsonForms().core?.data.country; + const [previousCountry, setPreviousCountry] = useState(); + + const endponit = schema.endpoint; + const dependent = schema.dependent ? schema.dependent : []; + + if (previousCountry !== country) { + setOptions([]); + setPreviousCountry(country); + api.get(endponit + '/' + country).then((result) => { + setOptions(result); + }); + } + + if (options.length === 0 && country !== undefined) { + return ; + } + + return ( + { + handleChange(path, value); + dependent.forEach((path) => { + handleChange(path, undefined); + }); + }} + options={options.map((option) => { + return { label: option, value: option }; + })} + /> + ); +}; + +export default withJsonFormsEnumProps( + withTranslateProps(React.memo(RegionControl)), + false +); \ No newline at end of file diff --git a/src/components/common/region/regionControlTester.js b/src/components/common/region/regionControlTester.js new file mode 100644 index 00000000..d81ed49c --- /dev/null +++ b/src/components/common/region/regionControlTester.js @@ -0,0 +1,6 @@ +import { rankWith, scopeEndsWith } from '@jsonforms/core'; + +export default rankWith( + 3, //increase rank as needed + scopeEndsWith('region') +); diff --git a/src/components/docs/tutorials/dynamic-enum.js b/src/components/docs/tutorials/dynamic-enum.js new file mode 100644 index 00000000..5017cd11 --- /dev/null +++ b/src/components/docs/tutorials/dynamic-enum.js @@ -0,0 +1,66 @@ +import { Demo } from '../../common/Demo'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import React from 'react'; + +import countryControlTester from '../../common/country/countryControlTester'; +import CountryControl from '../../common/country/CountryControl'; +import regionControlTester from '../../common/region/regionControlTester'; +import RegionControl from '../../common/region/RegionControl'; +import { API } from '../../common/api'; + +const data = { +}; + +const schema = { + "x-url": "www.api.com", + "type": "object", + "properties": { + "country": { + "type": "string", + "endpoint": "countries", + "dependent": ["region", "city"] + }, + "region": { + "type": "string", + "endpoint": "regions", + "dependent": ["city"] + }, + "city": { + "type": "string" + }, + }} + + +const regionUiSchema = { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/country" + }, + { + "type": "Control", + "scope": "#/properties/region" + }, + { + "type": "Control", + "scope": "#/properties/city" + } + ] + } + +export const WithRegionRenderer = () => ( + +); + +const url = schema['x-url']; +export const APIContext = React.createContext(new API(url)); \ No newline at end of file diff --git a/src/sidebars/docs.js b/src/sidebars/docs.js index fe7fd18b..7762b03a 100644 --- a/src/sidebars/docs.js +++ b/src/sidebars/docs.js @@ -22,7 +22,7 @@ module.exports = { type: 'category', label: 'Tutorials', collapsed: false, - items: ['tutorial/create-app', 'tutorial/custom-layouts', 'tutorial/custom-renderers', 'tutorial/multiple-forms'], + items: ['tutorial/create-app', 'tutorial/custom-layouts', 'tutorial/custom-renderers', 'tutorial/dynamic-enum', 'tutorial/multiple-forms'], }, 'api', {