From a8e87db87b6c27295abb0e60dcc4c961052f6dfa 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 closes #270 --- content/docs/tutorial/dynamic-enum.mdx | 260 ++++++++++++++++++ docusaurus.config.js | 4 + src/components/common/api.ts | 92 +++++++ src/components/common/country/Country.tsx | 52 ++++ .../common/country/CountryControl.tsx | 18 ++ .../common/country/countryControlTester.ts | 6 + src/components/common/region/Region.tsx | 54 ++++ .../common/region/RegionControl.tsx | 18 ++ .../common/region/regionControlTester.ts | 6 + src/components/docs/tutorials/dynamic-enum.js | 66 +++++ src/sidebars/docs.js | 2 +- 11 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 content/docs/tutorial/dynamic-enum.mdx create mode 100644 src/components/common/api.ts create mode 100644 src/components/common/country/Country.tsx create mode 100644 src/components/common/country/CountryControl.tsx create mode 100644 src/components/common/country/countryControlTester.ts create mode 100644 src/components/common/region/Region.tsx create mode 100644 src/components/common/region/RegionControl.tsx create mode 100644 src/components/common/region/regionControlTester.ts 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..2761b9e5 --- /dev/null +++ b/content/docs/tutorial/dynamic-enum.mdx @@ -0,0 +1,260 @@ +--- +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 learned 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 tacks those requirements. we'll create custom renderers for the country and region. +The example is also implemented in the [react-seed](https://github.com/eclipsesource/jsonforms-react-seed) app. + + + + +#### 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 `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 allows you to share data globally within your application, enabling components deep in the component tree to access data without needing to pass properties through all parent elements. +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 = () =>{ + + ... + +} +``` + +### The Country Renderer + +The core of the country renderer is a dropdown, we can reuse the MaterialEnumControl from the 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 `dependened` field in the schema. + +#### 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, making it a powerful tool for managing dynamic data. + +#### 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 & { + dependened: string[]; + endpoint: string; +}; +export const Country = ( + props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps +) => { +... + + const schema = props.schema as JsonSchemaWithDependenciesAndEndpoint; + const endponit = schema.endpoint; + const dependened = schema.dependened +... +} +``` + +#### The Country Renderer + +The country renderer uses the `APIContext` to query the API and fetch the available options. +We utilize the `useEffect` hook to reload new options, if API changes. +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 & { + dependened: string[]; + endpoint: string; +}; + +export const Country = ( + props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps +) => { + const { handleChange } = props; + const [options, setOptions] = useState([]); + const api = React.useContext(APIContext); + const schema = props.schema as JsonSchemaWithDependenciesAndEndpoint; + + const endponit = schema.endpoint; + const dependened: string[] = schema.dependened ? schema.dependened : []; + + useEffect(() => { + setOptions([]); + api.get(endponit).then((result) => { + setOptions(result); + }); + }, [api, endponit]); + + if (options.length === 0) { + return ; + } + + return ( + { + handleChange(path, value); + dependened.forEach((path) => { + handleChange(path, undefined); + }); + }} + options={options.map((option) => { + return { label: option, value: option }; + })} + /> + ); +}; +``` + +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 & { + dependened: string[]; + endpoint: string; +}; + +export const Region = ( + 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 endponit = schema.endpoint; + const dependened: string[] = schema.dependened ? schema.dependened : []; + + 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); + dependened.forEach((path) => { + handleChange(path, undefined); + }); + }} + options={options.map((option) => { + return { label: option, value: option }; + })} + /> + ); +}; +``` +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. +A running example of the scenario can also be seen at the [react-seed](https://github.com/eclipsesource/jsonforms-react-seed) app. \ No newline at end of file diff --git a/docusaurus.config.js b/docusaurus.config.js index 710344ff..784104f5 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -225,6 +225,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.ts b/src/components/common/api.ts new file mode 100644 index 00000000..a32746ef --- /dev/null +++ b/src/components/common/api.ts @@ -0,0 +1,92 @@ +export class API { + private url: string; + + constructor(url: string) { + this.url = url; + } + + async get(endpoint: string): Promise { + 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/Country.tsx b/src/components/common/country/Country.tsx new file mode 100644 index 00000000..c37e591e --- /dev/null +++ b/src/components/common/country/Country.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { useState } from 'react'; +import { ControlProps, JsonSchema, OwnPropsOfEnum } from '@jsonforms/core'; +import { TranslateProps } from '@jsonforms/react'; +import { CircularProgress } from '@mui/material'; +import { Unwrapped, WithOptionLabel } from '@jsonforms/material-renderers'; +import { APIContext } from '../../docs/tutorials/dynamic-enum'; + +const { MaterialEnumControl } = Unwrapped; + +type JsonSchemaWithDependenciesAndEndpoint = JsonSchema & { + dependened: string[]; + endpoint: string; +}; + +export const Country = ( + props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps +) => { + const { handleChange } = props; + const [options, setOptions] = useState([]); + const api = React.useContext(APIContext); + const schema = props.schema as JsonSchemaWithDependenciesAndEndpoint; + + const endponit = schema.endpoint; + const dependened: string[] = schema.dependened ? schema.dependened : []; + + useEffect(() => { + setOptions([]); + api.get(endponit).then((result) => { + setOptions(result); + }); + }, [api, endponit]); + + if (options.length === 0) { + return ; + } + + return ( + { + handleChange(path, value); + dependened.forEach((path) => { + handleChange(path, undefined); + }); + }} + options={options.map((option) => { + return { label: option, value: option }; + })} + /> + ); +}; \ No newline at end of file diff --git a/src/components/common/country/CountryControl.tsx b/src/components/common/country/CountryControl.tsx new file mode 100644 index 00000000..306005ba --- /dev/null +++ b/src/components/common/country/CountryControl.tsx @@ -0,0 +1,18 @@ +import { + TranslateProps, + withTranslateProps, + withJsonFormsEnumProps, +} from '@jsonforms/react'; +import { Country } from './Country'; +import { ControlProps, OwnPropsOfEnum } from '@jsonforms/core'; +import { WithOptionLabel } from '@jsonforms/material-renderers'; +import React from 'react'; + +export const CountryControl = ( + props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps +) => ; + +export default withJsonFormsEnumProps( + withTranslateProps(React.memo(CountryControl)), + false +); diff --git a/src/components/common/country/countryControlTester.ts b/src/components/common/country/countryControlTester.ts new file mode 100644 index 00000000..a6a7d559 --- /dev/null +++ b/src/components/common/country/countryControlTester.ts @@ -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/Region.tsx b/src/components/common/region/Region.tsx new file mode 100644 index 00000000..6810587c --- /dev/null +++ b/src/components/common/region/Region.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useState } from 'react'; +import { ControlProps, JsonSchema, OwnPropsOfEnum } from '@jsonforms/core'; +import { TranslateProps, useJsonForms } from '@jsonforms/react'; +import { CircularProgress } from '@mui/material'; +import { Unwrapped, WithOptionLabel } from '@jsonforms/material-renderers'; +import { APIContext } from '../../docs/tutorials/dynamic-enum'; +const { MaterialEnumControl } = Unwrapped; + +type JsonSchemaWithDependenciesAndEndpont = JsonSchema & { + dependened: string[]; + endpoint: string; +}; + +export const Region = ( + 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 endponit = schema.endpoint; + const dependened: string[] = schema.dependened ? schema.dependened : []; + + 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); + dependened.forEach((path) => { + handleChange(path, undefined); + }); + }} + options={options.map((option) => { + return { label: option, value: option }; + })} + /> + ); +}; diff --git a/src/components/common/region/RegionControl.tsx b/src/components/common/region/RegionControl.tsx new file mode 100644 index 00000000..0b1665ec --- /dev/null +++ b/src/components/common/region/RegionControl.tsx @@ -0,0 +1,18 @@ +import { + TranslateProps, + withJsonFormsEnumProps, + withTranslateProps, +} from '@jsonforms/react'; +import { Region } from './Region'; +import { ControlProps, OwnPropsOfEnum } from '@jsonforms/core'; +import { WithOptionLabel } from '@jsonforms/material-renderers'; +import React from 'react'; + +export const RegionControl = ( + props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps +) => ; + +export default withJsonFormsEnumProps( + withTranslateProps(React.memo(RegionControl)), + false +); diff --git a/src/components/common/region/regionControlTester.ts b/src/components/common/region/regionControlTester.ts new file mode 100644 index 00000000..d81ed49c --- /dev/null +++ b/src/components/common/region/regionControlTester.ts @@ -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..a3047759 --- /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", + "dependened": ["region", "city"] + }, + "region": { + "type": "string", + "endpoint": "regions", + "dependened": ["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 b31cbead..cdde757a 100644 --- a/src/sidebars/docs.js +++ b/src/sidebars/docs.js @@ -21,7 +21,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', {