diff --git a/package.json b/package.json index b9fca0c7..e2eb3d12 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "test-gen-openapi3": "rm -rf ./tmp && yarn build && ENTRYPOINT=https://demo.api-platform.com/docs.json FORMAT=openapi3 ./testgen.sh", "test-gen-custom": "rm -rf ./tmp && yarn build && babel src/generators/ReactGenerator.js src/generators/BaseGenerator.js -d ./tmp/gens && cp -r ./templates/react ./templates/react-common ./templates/entrypoint.js ./tmp/gens && ./lib/index.js https://demo.api-platform.com ./tmp/react-custom -g \"$(pwd)/tmp/gens/ReactGenerator.js\" -t ./tmp/gens", "test-gen-env": "rm -rf ./tmp && yarn build && API_PLATFORM_CLIENT_GENERATOR_ENTRYPOINT=https://demo.api-platform.com API_PLATFORM_CLIENT_GENERATOR_OUTPUT=./tmp ./lib/index.js", - "test-react-app": "rm -rf ./tmp/app && mkdir -p ./tmp/app && yarn create react-app ./tmp/app/reactapp && yarn --cwd ./tmp/app/reactapp add react-router-dom@5 redux redux-thunk react-redux redux-form connected-react-router && cp -R ./tmp/react/* ./tmp/app/reactapp/src && cp ./templates/react/index.js ./tmp/app/reactapp/src && start-server-and-test 'BROWSER=none yarn --cwd ./tmp/app/reactapp start' http://127.0.0.1:3000/books/ 'yarn playwright test'", + "test-react-app": "rm -rf ./tmp/app && mkdir -p ./tmp/app && yarn create react-app --template typescript ./tmp/app/reactapp && yarn --cwd ./tmp/app/reactapp add react-router-dom react-hook-form && cp -R ./tmp/react/* ./tmp/app/reactapp/src && cp ./templates/react/index.tsx ./tmp/app/reactapp/src && start-server-and-test 'BROWSER=none yarn --cwd ./tmp/app/reactapp start' http://127.0.0.1:3000/books/ 'yarn playwright test'", "test-next-app": "rm -rf ./tmp/app && mkdir -p ./tmp/app && yarn create next-app --typescript ./tmp/app/next && yarn --cwd ./tmp/app/next add isomorphic-unfetch formik react-query && cp -R ./tmp/next/* ./tmp/app/next && rm ./tmp/app/next/pages/index.tsx && rm -rf ./tmp/app/next/pages/api && yarn --cwd ./tmp/app/next build && start-server-and-test 'yarn --cwd ./tmp/app/next start' http://127.0.0.1:3000/books/ 'yarn playwright test'", "test-vue-app": "rm -rf ./tmp/app && mkdir -p ./tmp/app && cd ./tmp/app && npm init -y vue@2 -- --router vue && cd ../.. && yarn --cwd ./tmp/app/vue add vuex@3 vuex-map-fields lodash && cp -R ./tmp/vue/* ./tmp/app/vue/src && cp ./templates/vue/main.js ./tmp/app/vue/src && yarn --cwd ./tmp/app/vue build && start-server-and-test 'yarn --cwd ./tmp/app/vue vite preview --host 127.0.0.1 --port 3000' http://127.0.0.1:3000/books/ 'yarn playwright test'", "test-nuxt-app": "rm -rf ./tmp/app && mkdir -p ./tmp/app && yarn create nuxt-app --answers \"'{\\\"name\\\":\\\"nuxt\\\",\\\"language\\\":\\\"js\\\",\\\"pm\\\":\\\"yarn\\\",\\\"ui\\\":\\\"vuetify\\\",\\\"features\\\":[],\\\"linter\\\":[],\\\"test\\\":\\\"none\\\",\\\"mode\\\":\\\"spa\\\",\\\"target\\\":\\\"static\\\",\\\"devTools\\\":[],\\\"vcs\\\":\\\"none\\\"}'\" ./tmp/app/nuxt && yarn --cwd ./tmp/app/nuxt add moment lodash vuelidate vuex-map-fields && cp -R ./tmp/nuxt/* ./tmp/app/nuxt && NUXT_TELEMETRY_DISABLED=1 yarn --cwd ./tmp/app/nuxt generate && start-server-and-test 'yarn --cwd ./tmp/app/nuxt start --hostname 127.0.0.1' http://127.0.0.1:3000/books/ 'yarn playwright test'" diff --git a/src/generators/ReactGenerator.js b/src/generators/ReactGenerator.js index 5a3f9848..d1431610 100644 --- a/src/generators/ReactGenerator.js +++ b/src/generators/ReactGenerator.js @@ -1,47 +1,49 @@ import chalk from "chalk"; +import handlebars from "handlebars"; +import hbhComparison from "handlebars-helpers/lib/comparison.js"; import BaseGenerator from "./BaseGenerator.js"; -export default class extends BaseGenerator { +export default class ReactGenerator extends BaseGenerator { constructor(params) { super(params); - this.registerTemplates("common/", [ + this.registerTemplates("react/", [ // utils - "utils/mercure.js", - ]); - - this.registerTemplates("react-common/", [ - // actions - "actions/foo/create.js", - "actions/foo/delete.js", - "actions/foo/list.js", - "actions/foo/update.js", - "actions/foo/show.js", - - // utils - "utils/dataAccess.js", - - // reducers - "reducers/foo/create.js", - "reducers/foo/delete.js", - "reducers/foo/index.js", - "reducers/foo/list.js", - "reducers/foo/update.js", - "reducers/foo/show.js", - ]); + "utils/dataAccess.ts", + "utils/types.ts", + + // hooks + "hooks/create.ts", + "hooks/delete.ts", + "hooks/fetch.ts", + "hooks/index.ts", + "hooks/list.ts", + "hooks/mercure.ts", + "hooks/retrieve.ts", + "hooks/update.ts", + "hooks/show.ts", + + // interfaces + "interfaces/Collection.ts", + "interfaces/foo.ts", - this.registerTemplates(`react/`, [ // components - "components/foo/Create.js", - "components/foo/Form.js", - "components/foo/index.js", - "components/foo/List.js", - "components/foo/Update.js", - "components/foo/Show.js", + "components/foo/Create.tsx", + "components/foo/Form.tsx", + "components/foo/index.ts", + "components/foo/List.tsx", + "components/foo/Update.tsx", + "components/foo/type.ts", + "components/foo/Show.tsx", + "components/Field.tsx", + "components/Links.tsx", + "components/Pagination.tsx", // routes - "routes/foo.js", + "routes/foo.tsx", ]); + + handlebars.registerHelper("compare", hbhComparison.compare); } help(resource) { @@ -52,20 +54,14 @@ export default class extends BaseGenerator { resource.title ); console.log( - "Paste the following definitions in your application configuration (`client/src/index.js` by default):" + "Paste the following definitions in your application configuration (`client/src/index.tsx` by default):" ); console.log( chalk.green(` -// import reducers -import ${titleLc} from './reducers/${titleLc}/'; - -//import routes +// import routes import ${titleLc}Routes from './routes/${titleLc}'; -// Add the reducer -combineReducers({ ${titleLc},/* ... */ }), - -// Add routes to +// Add routes to { ${titleLc}Routes } `) ); @@ -74,70 +70,87 @@ combineReducers({ ${titleLc},/* ... */ }), generate(api, resource, dir) { const lc = resource.title.toLowerCase(); const ucf = this.ucFirst(resource.title); + const fields = this.parseFields(resource); const context = { - title: resource.title, name: resource.name, lc, uc: resource.title.toUpperCase(), ucf, - fields: this.parseFields(resource), - formFields: this.buildFields(resource.writableFields), + fields, + formFields: this.buildFields(fields), + hasRelations: fields.some((field) => field.reference || field.embedded), + hasManyRelations: fields.some( + (field) => field.isReferences || field.isEmbeddeds + ), hydraPrefix: this.hydraPrefix, + title: resource.title, }; // Create directories // These directories may already exist - [`${dir}/utils`, `${dir}/config`, `${dir}/routes`].forEach((dir) => - this.createDir(dir, false) - ); - [ - `${dir}/actions/${lc}`, + `${dir}/utils`, + `${dir}/config`, + `${dir}/interfaces`, + `${dir}/routes`, `${dir}/components/${lc}`, - `${dir}/reducers/${lc}`, - ].forEach((dir) => this.createDir(dir)); + `${dir}/hooks`, + ].forEach((dir) => this.createDir(dir, false)); [ - // actions - "actions/%s/create.js", - "actions/%s/delete.js", - "actions/%s/list.js", - "actions/%s/update.js", - "actions/%s/show.js", - // components - "components/%s/Create.js", - "components/%s/Form.js", - "components/%s/index.js", - "components/%s/List.js", - "components/%s/Update.js", - "components/%s/Show.js", - - // reducers - "reducers/%s/create.js", - "reducers/%s/delete.js", - "reducers/%s/index.js", - "reducers/%s/list.js", - "reducers/%s/update.js", - "reducers/%s/show.js", + "components/%s/Create.tsx", + "components/%s/Form.tsx", + "components/%s/index.ts", + "components/%s/List.tsx", + "components/%s/Update.tsx", + "components/%s/type.ts", + "components/%s/Show.tsx", // routes - "routes/%s.js", + "routes/%s.tsx", ].forEach((pattern) => this.createFileFromPattern(pattern, dir, lc, context) ); - // utils + // interface pattern should be camel cased this.createFile( - "utils/dataAccess.js", - `${dir}/utils/dataAccess.js`, - context, - false + "interfaces/foo.ts", + `${dir}/interfaces/${context.ucf}.ts`, + context + ); + + // copy with regular name + [ + // interfaces + "interfaces/Collection.ts", + + // components + "components/Field.tsx", + "components/Links.tsx", + "components/Pagination.tsx", + + // hooks + "hooks/create.ts", + "hooks/delete.ts", + "hooks/fetch.ts", + "hooks/index.ts", + "hooks/list.ts", + "hooks/mercure.ts", + "hooks/retrieve.ts", + "hooks/update.ts", + "hooks/show.ts", + + // utils + "utils/dataAccess.ts", + "utils/types.ts", + ].forEach((file) => + this.createFile(file, `${dir}/${file}`, context, false) ); - this.createFile("utils/mercure.js", `${dir}/utils/mercure.js`); - this.createEntrypoint(api.entrypoint, `${dir}/config/entrypoint.js`); + // API config + this.createEntrypoint(api.entrypoint, `${dir}/config/entrypoint.ts`); } getDescription(field) { @@ -160,6 +173,7 @@ combineReducers({ ${titleLc},/* ... */ }), ...list, [field.name]: { ...field, + type: this.getType(field), description: this.getDescription(field), readonly: false, isReferences, @@ -169,7 +183,7 @@ combineReducers({ ${titleLc},/* ... */ }), }; }, {}); - return fields; + return Object.values(fields); } ucFirst(target) { diff --git a/src/generators/ReactGenerator.test.js b/src/generators/ReactGenerator.test.js index a81f9ca7..5c598b75 100644 --- a/src/generators/ReactGenerator.test.js +++ b/src/generators/ReactGenerator.test.js @@ -37,33 +37,40 @@ test("Generate a React app", () => { generator.generate(api, resource, tmpobj.name); [ - "/utils/dataAccess.js", - "/config/entrypoint.js", + "/utils/dataAccess.ts", + "/utils/types.ts", + "/config/entrypoint.ts", - "/actions/abc/create.js", - "/actions/abc/delete.js", - "/actions/abc/list.js", - "/actions/abc/show.js", - "/actions/abc/update.js", + "/interfaces/Abc.ts", + "/interfaces/Collection.ts", - "/components/abc/index.js", - "/components/abc/Create.js", - "/components/abc/Update.js", + "/components/abc/index.ts", + "/components/abc/Create.tsx", + "/components/abc/Update.tsx", + "/components/abc/type.ts", - "/routes/abc.js", + "/components/Field.tsx", + "/components/Links.tsx", + "/components/Pagination.tsx", - "/reducers/abc/create.js", - "/reducers/abc/delete.js", - "/reducers/abc/index.js", - "/reducers/abc/list.js", - "/reducers/abc/show.js", - "/reducers/abc/update.js", + "/routes/abc.tsx", + + "/hooks/create.ts", + "/hooks/delete.ts", + "/hooks/fetch.ts", + "/hooks/index.ts", + "/hooks/list.ts", + "/hooks/mercure.ts", + "/hooks/retrieve.ts", + "/hooks/show.ts", + "/hooks/update.ts", ].forEach((file) => expect(fs.existsSync(tmpobj.name + file)).toBe(true)); [ - "/components/abc/Form.js", - "/components/abc/List.js", - "/components/abc/Show.js", + "/components/abc/Form.tsx", + "/components/abc/List.tsx", + "/components/abc/Show.tsx", + "/interfaces/Abc.ts", ].forEach((file) => { expect(fs.existsSync(tmpobj.name + file)).toBe(true); expect(fs.readFileSync(tmpobj.name + file, "utf8")).toMatch(/bar/); diff --git a/templates/next/components/foo/Form.tsx b/templates/next/components/foo/Form.tsx index 0eebceb7..b922f3a3 100644 --- a/templates/next/components/foo/Form.tsx +++ b/templates/next/components/foo/Form.tsx @@ -59,9 +59,9 @@ export const Form: FunctionComponent = ({ {{{lc}}} }) => { ...{{lc}}, {{#each fields}} {{#if isEmbeddeds}} - {{name}}: {{../lc}}.{{name}}?.map((emb: any) => emb['@id']) ?? "", + {{name}}: {{../lc}}["{{name}}"]?.map((emb: any) => emb['@id']) ?? [], {{else if embedded}} - {{name}}: {{../lc}}.{{name}}?.['@id'] ?? "", + {{name}}: {{../lc}}["{{name}}"]?.['@id'] ?? "", {{/if}} {{/each}} } : diff --git a/templates/next/components/foo/List.tsx b/templates/next/components/foo/List.tsx index c5afb234..7e5d0ab4 100644 --- a/templates/next/components/foo/List.tsx +++ b/templates/next/components/foo/List.tsx @@ -34,7 +34,9 @@ export const List: FunctionComponent = ({ {{{lc}}}s }) => ( {{#each fields}} - {{#if reference}} + {{#if isReferences}} + ({ href: getPath(ref, '/{{{lowercase reference.title}}}s/[id]'), name: ref })) } /> + {{else if reference}} {{else if isEmbeddeds}} ({ href: getPath(emb['@id'], '/{{{lowercase embedded.title}}}s/[id]'), name: emb['@id'] })) } /> diff --git a/templates/next/components/foo/Show.tsx b/templates/next/components/foo/Show.tsx index b48059c4..855c13ca 100644 --- a/templates/next/components/foo/Show.tsx +++ b/templates/next/components/foo/Show.tsx @@ -48,12 +48,14 @@ export const Show: FunctionComponent = ({ {{{lc}}}, text }) => { {{name}} - {{#if reference}} - - {{else if isEmbeddeds}} - ({ href: getPath(emb['@id'], '/{{{lowercase embedded.title}}}s/[id]'), name: emb['@id'] })) } /> - {{else if embedded}} - + {{#if isReferences}} + ({ href: getPath(ref, '/{{{lowercase reference.title}}}s/[id]'), name: ref })) } /> + {{else if reference}} + + {{else if isEmbeddeds}} + ({ href: getPath(emb['@id'], '/{{{lowercase embedded.title}}}s/[id]'), name: emb['@id'] })) } /> + {{else if embedded}} + {{else if (compare type "==" "Date") }} { {{{../lc}}}['{{{name}}}']?.toLocaleString() } {{else}} diff --git a/templates/react/components/Field.tsx b/templates/react/components/Field.tsx new file mode 100644 index 00000000..f5eb731b --- /dev/null +++ b/templates/react/components/Field.tsx @@ -0,0 +1,45 @@ +import { DeepMap, FieldError, FieldValues, Path, UseFormRegister } from "react-hook-form"; + +interface FieldProps { + register: UseFormRegister; + name: Path; + placeholder: string; + type: string; + step?: string; + required?: boolean; + errors: Partial>; +} + +const Field = ({ register, name, placeholder, type, step, required = false, errors }: FieldProps) => { + const inputProps: { className: string; "aria-invalid"?: boolean } = { + className: "form-control", + }; + + if (errors[name]) { + inputProps.className += " is-invalid"; + inputProps["aria-invalid"] = true; + } + + if (!errors[name]) { + inputProps.className += " is-valid"; + } + + return ( +
+ + + {errors[name] &&
{errors[name]?.message}
} +
+ ); +} + +export default Field; diff --git a/templates/react/components/Links.tsx b/templates/react/components/Links.tsx new file mode 100644 index 00000000..d34b8d2f --- /dev/null +++ b/templates/react/components/Links.tsx @@ -0,0 +1,27 @@ +import { Link } from "react-router-dom"; + +interface LinksProps { + items: string | string[] | { href: string; name: string } | { href: string; name: string }[]; +} + +const Links = ({ items }: LinksProps) => { + if (Array.isArray(items)) { + return ( + <> + {items.map((item, index) => ( +
+ +
+ ))} + + ); + } + + return ( + + {typeof items === "string" ? items : items.name} + + ); +} + +export default Links; diff --git a/templates/react/components/Pagination.tsx b/templates/react/components/Pagination.tsx new file mode 100644 index 00000000..e38b5b9b --- /dev/null +++ b/templates/react/components/Pagination.tsx @@ -0,0 +1,57 @@ +import { Link } from "react-router-dom"; +import { PagedCollection } from "../interfaces/Collection"; + +interface PaginationProps { + retrieved: PagedCollection | null; +} + +const Pagination = ({retrieved}: PaginationProps) => { + const view = retrieved && retrieved["hydra:view"]; + if (!view) { + return null; + } + + const { + "hydra:first": first, + "hydra:previous": previous, + "hydra:next": next, + "hydra:last": last, + } = view; + + return ( + + ); +} + +export default Pagination; diff --git a/templates/react/components/foo/Create.js b/templates/react/components/foo/Create.js deleted file mode 100644 index a70c143f..00000000 --- a/templates/react/components/foo/Create.js +++ /dev/null @@ -1,64 +0,0 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { Link, Redirect } from 'react-router-dom'; -import PropTypes from 'prop-types'; -import Form from './Form'; -import { create, reset } from '../../actions/{{{lc}}}/create'; - -class Create extends Component { - static propTypes = { - error: PropTypes.string, - loading: PropTypes.bool.isRequired, - created: PropTypes.object, - create: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired - }; - - componentWillUnmount() { - this.props.reset(); - } - - render() { - if (this.props.created) - return ( - - ); - - return ( -
-

Create {{{title}}}

- - {this.props.loading && ( -
- Loading... -
- )} - {this.props.error && ( -
-
- )} - -
- - Back to list - -
- ); - } -} - -const mapStateToProps = state => { - const { created, error, loading } = state.{{{lc}}}.create; - return { created, error, loading }; -}; - -const mapDispatchToProps = dispatch => ({ - create: values => dispatch(create(values)), - reset: () => dispatch(reset()) -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Create); diff --git a/templates/react/components/foo/Create.tsx b/templates/react/components/foo/Create.tsx new file mode 100644 index 00000000..0f876f41 --- /dev/null +++ b/templates/react/components/foo/Create.tsx @@ -0,0 +1,63 @@ +import { Link, Navigate } from "react-router-dom"; +import { useCreate } from "../../hooks"; +import Form from "./Form"; +import { TError } from "../../utils/types"; +import TResource from "./type"; + +interface CreateProps { + created: TResource | null; + create: (item: Partial) => any; + error: TError; + reset: () => void; + loading: boolean; +} + +const CreateView = ({create, created, error, reset, loading}: CreateProps) => { + if (created) { + return ( + + ); + } + + return ( +
+

Create {{{title}}}

+ + {loading && ( +
+ Loading... +
+ )} + {error && ( +
+
+ )} + + + + Back to list + +
+ ); +} + +const Create = () => { + const {created, loading, error, reset, create} = useCreate({"@id": "{{{name}}}"}); + + return ( + + ); +} + +export default Create; diff --git a/templates/react/components/foo/Form.js b/templates/react/components/foo/Form.js deleted file mode 100644 index fa1f310a..00000000 --- a/templates/react/components/foo/Form.js +++ /dev/null @@ -1,73 +0,0 @@ -import React, { Component } from 'react'; -import { Field, reduxForm } from 'redux-form'; -import PropTypes from 'prop-types'; - -class Form extends Component { - static propTypes = { - handleSubmit: PropTypes.func.isRequired, - error: PropTypes.string - }; - - renderField = data => { - data.input.className = 'form-control'; - - const isInvalid = data.meta.touched && !!data.meta.error; - if (isInvalid) { - data.input.className += ' is-invalid'; - data.input['aria-invalid'] = true; - } - - if (this.props.error && data.meta.touched && !data.meta.error) { - data.input.className += ' is-valid'; - } - - return ( -
- - - {isInvalid &&
{data.meta.error}
} -
- ); - }; - - render() { - return ( - - {{#each formFields}} - (v === '' ? [] : v.split(','))}{{/unless}}{{/if}}{{#if number}} - normalize={v => parseFloat(v)}{{/if}} - /> - {{/each}} - - - - ); - } -} - -export default reduxForm({ - form: '{{{lc}}}', - enableReinitialize: true, - keepDirtyOnReinitialize: true -})(Form); diff --git a/templates/react/components/foo/Form.tsx b/templates/react/components/foo/Form.tsx new file mode 100644 index 00000000..b931768e --- /dev/null +++ b/templates/react/components/foo/Form.tsx @@ -0,0 +1,76 @@ +import { useEffect } from "react"; +import { useForm, SubmitHandler } from "react-hook-form"; +import Field from "../Field"; +{{#if hasManyRelations}}import { normalizeLinks } from "../../utils/dataAccess";{{/if}} +import TResource from "./type"; +import { SubmissionError, TError } from "../../utils/types"; + +interface FormProps { + onSubmit: (item: Partial) => any; + initialValues?: Partial; + error?: TError; + reset: () => void; +} + +const Form = ({onSubmit, error, reset, initialValues}: FormProps) => { + const { register, setError, handleSubmit, formState: { errors } } = useForm({ + defaultValues: initialValues ? { + ...initialValues, + {{#each formFields}} + {{#if isEmbeddeds}} + {{name}}: initialValues["{{name}}"]?.map((emb: any) => emb['@id']) ?? [], + {{else if embedded}} + {{name}}: initialValues["{{name}}"]?.['@id'] ?? "", + {{/if}} + {{/each}} + } : undefined, + }); + + useEffect(() => { + if (error instanceof SubmissionError) { + Object.keys(error.errors).forEach((errorPath) => { + if (errors[errorPath as keyof TResource]) { + return; + } + setError(errorPath as keyof TResource, { type: 'server', message: error.errors[errorPath] }); + }); + + reset(); + } + }, [error, errors, reset, setError]); + + const onFormSubmit: SubmitHandler = (data) => { + onSubmit( + { + ...data, + {{#each formFields ~}} + {{#if isRelations ~}} + {{{name}}}: normalizeLinks(data["{{{name}}}"]), + {{/if ~}} + {{/each}} + }, + ); + }; + + return ( +
+ {{#each formFields}} + + {{/each}} + + + + ); +} + +export default Form; diff --git a/templates/react/components/foo/List.js b/templates/react/components/foo/List.js deleted file mode 100644 index c69efe9b..00000000 --- a/templates/react/components/foo/List.js +++ /dev/null @@ -1,190 +0,0 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; -import PropTypes from 'prop-types'; -import { list, reset } from '../../actions/{{{lc}}}/list'; - -class List extends Component { - static propTypes = { - retrieved: PropTypes.object, - loading: PropTypes.bool.isRequired, - error: PropTypes.string, - eventSource: PropTypes.instanceOf(EventSource), - deletedItem: PropTypes.object, - list: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired - }; - - componentDidMount() { - this.props.list( - this.props.match.params.page && - decodeURIComponent(this.props.match.params.page) - ); - } - - componentDidUpdate(prevProps) { - if (this.props.match.params.page !== prevProps.match.params.page) - this.props.list( - this.props.match.params.page && - decodeURIComponent(this.props.match.params.page) - ); - } - - componentWillUnmount() { - this.props.reset(this.props.eventSource); - } - - render() { - return ( -
-

{{{title}}} List

- - {this.props.loading && ( -
Loading...
- )} - {this.props.deletedItem && ( -
- {this.props.deletedItem['@id']} deleted. -
- )} - {this.props.error && ( -
{this.props.error}
- )} - -

- - Create - -

- - - - - - {{#each fields}} - - {{/each}} - - - - {this.props.retrieved && - this.props.retrieved['hydra:member'].map(item => ( - - - {{#each fields}} - - {{/each}} - - - - ))} - -
id{{name}} -
- - {item['@id']} - - - {{#if reference}} - {this.renderLinks('{{{reference.name}}}', item['{{{name}}}'])} - {{else if isEmbeddeds}} - {this.renderLinks('{{{reference.name}}}', item['{{{name}}}'].map((emb) => emb['@id']))} - {{else if embedded}} - {this.renderLinks('{{{reference.name}}}', item['{{{name}}}']['@id'])} - {{else}} - {item['{{{name}}}']} - {{/if}} - - - - -
- - {this.pagination()} -
- ); - } - - pagination() { - const view = this.props.retrieved && this.props.retrieved['hydra:view']; - if (!view || !view['hydra:first']) return; - - const { - 'hydra:first': first, - 'hydra:previous': previous, - 'hydra:next': next, - 'hydra:last': last - } = view; - - return ( - - ); - } - - renderLinks = (type, items) => { - if (Array.isArray(items)) { - return items.map((item, i) => ( -
{this.renderLinks(type, item)}
- )); - } - - return ( - {items} - ); - }; -} - -const mapStateToProps = state => { - const { - retrieved, - loading, - error, - eventSource, - deletedItem - } = state.{{{lc}}}.list; - return { retrieved, loading, error, eventSource, deletedItem }; -}; - -const mapDispatchToProps = dispatch => ({ - list: page => dispatch(list(page)), - reset: eventSource => dispatch(reset(eventSource)) -}); - -export default connect(mapStateToProps, mapDispatchToProps)(List); diff --git a/templates/react/components/foo/List.tsx b/templates/react/components/foo/List.tsx new file mode 100644 index 00000000..e846fcb0 --- /dev/null +++ b/templates/react/components/foo/List.tsx @@ -0,0 +1,103 @@ +import { Link, useParams } from "react-router-dom"; +import Links from "../Links"; +import Pagination from "../Pagination"; +import { useRetrieve } from "../../hooks"; +import { PagedCollection } from "../../interfaces/Collection"; +import TResource from "./type"; +import { TError } from "../../utils/types"; + +interface ListProps { + retrieved: PagedCollection | null; + loading: boolean; + error: TError; +} + +const ListView = ({error, loading, retrieved}: ListProps) => { + const items = (retrieved && retrieved["hydra:member"]) || []; + + return ( +
+

{{{title}}} List

+ + {loading && ( +
Loading...
+ )} + {error && ( +
{error.message}
+ )} + +

+ + Create + +

+ + + + + + {{#each fields}} + + {{/each}} + + + + {items.map(item => ( + + + {{#each fields}} + + {{/each}} + + + + ))} + +
id{{name}} +
+ + + {{#if isReferences}} + ({ href: `/{{{reference.name}}}/show/${encodeURIComponent(ref)}`, name: ref })) } /> + {{else if reference}} + + {{else if isEmbeddeds}} + ({ href: `/{{{embedded.name}}}/show/${encodeURIComponent(emb["@id"])}`, name: emb["@id"] }))}/> + {{else if embedded}} + + {{else}} + {item['{{{name}}}']} + {{/if}} + + + + +
+ + +
+ ); +} + +const List = () => { + const { page } = useParams<{ page?: string }>(); + const id = (page && decodeURIComponent(page)) || "{{{name}}}"; + + const {retrieved, loading, error} = useRetrieve>(id); + + return ( + + ); +} + +export default List; diff --git a/templates/react/components/foo/Show.js b/templates/react/components/foo/Show.js deleted file mode 100644 index e0907aa9..00000000 --- a/templates/react/components/foo/Show.js +++ /dev/null @@ -1,136 +0,0 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { Link, Redirect } from 'react-router-dom'; -import PropTypes from 'prop-types'; -import { retrieve, reset } from '../../actions/{{{lc}}}/show'; -import { del } from '../../actions/{{{lc}}}/delete'; - -class Show extends Component { - static propTypes = { - retrieved: PropTypes.object, - loading: PropTypes.bool.isRequired, - error: PropTypes.string, - eventSource: PropTypes.instanceOf(EventSource), - retrieve: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired, - deleteError: PropTypes.string, - deleteLoading: PropTypes.bool.isRequired, - deleted: PropTypes.object, - del: PropTypes.func.isRequired - }; - - componentDidMount() { - this.props.retrieve(decodeURIComponent(this.props.match.params.id)); - } - - componentWillUnmount() { - this.props.reset(this.props.eventSource); - } - - del = () => { - if (window.confirm('Are you sure you want to delete this item?')) - this.props.del(this.props.retrieved); - }; - - render() { - if (this.props.deleted) return ; - - const item = this.props.retrieved; - - return ( -
-

Show {{{ucf}}} {item && item['@id']}

- - {this.props.loading && ( -
- Loading... -
- )} - {this.props.error && ( -
-
- )} - {this.props.deleteError && ( -
-
- )} - - {item && ( - - - - - - - - - {{#each fields}} - - - - - {{/each}} - -
FieldValue
{{name}} - {{#if reference}} - {this.renderLinks('{{{reference.name}}}', item['{{{name}}}'])} - {{else if isEmbeddeds}} - {this.renderLinks('{{{reference.name}}}', item['{{{name}}}'].map((emb) => emb['@id']))} - {{else if embedded}} - {this.renderLinks('{{{reference.name}}}', item['{{{name}}}']['@id'])} - {{else}} - {item['{{{name}}}']} - {{/if}} -
- )} - - Back to list - {" "} - {item && ( - - Edit - - )} - -
- ); - } - - renderLinks = (type, items) => { - if (Array.isArray(items)) { - return items.map((item, i) => ( -
{this.renderLinks(type, item)}
- )); - } - - return ( - - {items} - - ); - }; -} - -const mapStateToProps = state => ({ - retrieved: state.{{{lc}}}.show.retrieved, - error: state.{{{lc}}}.show.error, - loading: state.{{{lc}}}.show.loading, - eventSource: state.{{{lc}}}.show.eventSource, - deleteError: state.{{{lc}}}.del.error, - deleteLoading: state.{{{lc}}}.del.loading, - deleted: state.{{{lc}}}.del.deleted -}); - -const mapDispatchToProps = dispatch => ({ - retrieve: id => dispatch(retrieve(id)), - del: item => dispatch(del(item)), - reset: eventSource => dispatch(reset(eventSource)) -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Show); diff --git a/templates/react/components/foo/Show.tsx b/templates/react/components/foo/Show.tsx new file mode 100644 index 00000000..c869c7de --- /dev/null +++ b/templates/react/components/foo/Show.tsx @@ -0,0 +1,111 @@ +import { Link, Navigate, useParams } from "react-router-dom"; +{{#if hasRelations}}import Links from "../Links";{{/if}} +import { useRetrieve, useDelete } from "../../hooks"; +import TResource from "./type"; +import { TError } from "../../utils/types"; + +interface ShowProps { + retrieved: TResource | null; + loading: boolean; + error: TError; + deleteError: TError; + deleted: TResource | null; + del: (item: TResource) => any; +} + +const ShowView = ({del, deleteError, deleted, error, loading, retrieved: item}: ShowProps) => { + if (deleted) { + return ; + } + + const delWithConfirm = () => { + if (item && window.confirm("Are you sure you want to delete this item?")) { + del(item); + } + }; + + return ( +
+

Show {{{ucf}}} {item && item["@id"]}

+ + {loading && ( +
+ Loading... +
+ )} + {error && ( +
+
+ )} + {deleteError && ( +
+
+ )} + + {item && ( + + + + + + + + + {{#each fields}} + + + + + {{/each}} + +
FieldValue
{{name}} + {{#if isReferences}} + ({ href: `/{{{reference.name}}}/show/${encodeURIComponent(ref)}`, name: ref })) } /> + {{else if reference}} + + {{else if isEmbeddeds}} + ({ href: `/{{{embedded.name}}}/show/${encodeURIComponent(emb["@id"])}`, name: emb["@id"] }))}/> + {{else if embedded}} + + {{else}} + {item['{{{name}}}']}retrieved + {{/if}} +
+ )} + + Back to list + + {item && ( + + + + )} + +
+ ); +} + +const Show = () => { + const { id } = useParams<{ id: string }>(); + const {retrieved, loading, error} = useRetrieve(decodeURIComponent(id || "")); + const {deleted, error: deleteError, del} = useDelete(); + + return ( + + ); +} + +export default Show; diff --git a/templates/react/components/foo/Update.js b/templates/react/components/foo/Update.js deleted file mode 100644 index e46ebdb7..00000000 --- a/templates/react/components/foo/Update.js +++ /dev/null @@ -1,123 +0,0 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { Link, Redirect } from 'react-router-dom'; -import PropTypes from 'prop-types'; -import Form from './Form'; -import { retrieve, update, reset } from '../../actions/{{{lc}}}/update'; -import { del } from '../../actions/{{{lc}}}/delete'; - -class Update extends Component { - static propTypes = { - retrieved: PropTypes.object, - retrieveLoading: PropTypes.bool.isRequired, - retrieveError: PropTypes.string, - updateLoading: PropTypes.bool.isRequired, - updateError: PropTypes.string, - deleteLoading: PropTypes.bool.isRequired, - deleteError: PropTypes.string, - updated: PropTypes.object, - deleted: PropTypes.object, - eventSource: PropTypes.instanceOf(EventSource), - retrieve: PropTypes.func.isRequired, - update: PropTypes.func.isRequired, - del: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired - }; - - componentDidMount() { - this.props.retrieve(decodeURIComponent(this.props.match.params.id)); - } - - componentWillUnmount() { - this.props.reset(this.props.eventSource); - } - - del = () => { - if (window.confirm('Are you sure you want to delete this item?')) - this.props.del(this.props.retrieved); - }; - - render() { - if (this.props.deleted) return ; - - const item = this.props.updated ? this.props.updated : this.props.retrieved; - - return ( -
-

Edit {{{ucf}}} {item && item['@id']}

- - {this.props.created && ( -
- {this.props.created['@id']} created. -
- )} - {this.props.updated && ( -
- {this.props.updated['@id']} updated. -
- )} - {(this.props.retrieveLoading || - this.props.updateLoading || - this.props.deleteLoading) && ( -
- Loading... -
- )} - {this.props.retrieveError && ( -
-
- )} - {this.props.updateError && ( -
-
- )} - {this.props.deleteError && ( -
-
- )} - - {item && ( -
this.props.update(item, values)} - initialValues={item} - /> - )} - - Back to list - - -
- ); - } -} - -const mapStateToProps = state => ({ - retrieved: state.{{{lc}}}.update.retrieved, - retrieveError: state.{{{lc}}}.update.retrieveError, - retrieveLoading: state.{{{lc}}}.update.retrieveLoading, - updateError: state.{{{lc}}}.update.updateError, - updateLoading: state.{{{lc}}}.update.updateLoading, - deleteError: state.{{{lc}}}.del.error, - deleteLoading: state.{{{lc}}}.del.loading, - eventSource: state.{{{lc}}}.update.eventSource, - created: state.{{{lc}}}.create.created, - deleted: state.{{{lc}}}.del.deleted, - updated: state.{{{lc}}}.update.updated -}); - -const mapDispatchToProps = dispatch => ({ - retrieve: id => dispatch(retrieve(id)), - update: (item, values) => dispatch(update(item, values)), - del: item => dispatch(del(item)), - reset: eventSource => dispatch(reset(eventSource)) -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Update); diff --git a/templates/react/components/foo/Update.tsx b/templates/react/components/foo/Update.tsx new file mode 100644 index 00000000..a16a9e54 --- /dev/null +++ b/templates/react/components/foo/Update.tsx @@ -0,0 +1,121 @@ +import { Link, Navigate, useParams } from "react-router-dom"; +import Form from "./Form"; +import { useDelete, useRetrieve, useUpdate } from "../../hooks"; +import TResource from "./type"; +import { TError } from "../../utils/types"; + +interface UpdateProps { + retrieved: TResource | null; + retrieveLoading: boolean; + retrieveError: TError; + updateLoading: boolean; + updateError: TError; + deleteLoading: boolean; + deleteError: TError; + created: TResource | null; + updated: TResource | null; + deleted: TResource | null; + del: (item: TResource) => any; + update: (item: TResource, values: Partial) => any; + reset: () => void; +} + +const UpdateView = ({created, del, deleteError, deleteLoading, deleted, retrieveError, retrieveLoading, retrieved, update, updateError, updateLoading, updated, reset}: UpdateProps) => { + if (deleted) { + return ; + } + + const item = updated ? updated : retrieved; + const delWithConfirm = () => { + if (retrieved && window.confirm("Are you sure you want to delete this item?")) { + del(retrieved); + } + }; + + return ( +
+

Edit {{{ucf}}} {item && item["@id"]}

+ + {created && ( +
+ {created["@id"]} created. +
+ )} + {updated && ( +
+ {updated["@id"]} updated. +
+ )} + {(retrieveLoading || + updateLoading || + deleteLoading) && ( +
+ Loading... +
+ )} + {retrieveError && ( +
+
+ )} + {updateError && ( +
+
+ )} + {deleteError && ( +
+
+ )} + + {item && ( + { + reset(); + update(item, values); + }} + error={updateError} + reset={reset} + initialValues={item} + /> + )} + + Back to list + + +
+ ); +} + +const Update = () => { + const { id } = useParams<{ id: string }>(); + const {retrieved, loading: retrieveLoading, error: retrieveError} = useRetrieve(decodeURIComponent(id || "")); + const {updated, update, reset, loading: updateLoading, error: updateError} = useUpdate(); + const {deleted, loading: deleteLoading, error: deleteError, del} = useDelete(); + + return ( + + ); +} + +export default Update; diff --git a/templates/react/components/foo/index.js b/templates/react/components/foo/index.js deleted file mode 100644 index e936e55e..00000000 --- a/templates/react/components/foo/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import Create from './Create'; -import List from './List'; -import Update from './Update'; -import Show from './Show'; - -export { Create, List, Update, Show }; diff --git a/templates/react/components/foo/index.ts b/templates/react/components/foo/index.ts new file mode 100644 index 00000000..ba69054f --- /dev/null +++ b/templates/react/components/foo/index.ts @@ -0,0 +1,6 @@ +import Create from "./Create"; +import List from "./List"; +import Update from "./Update"; +import Show from "./Show"; + +export { Create, List, Update, Show }; diff --git a/templates/react/components/foo/type.ts b/templates/react/components/foo/type.ts new file mode 100644 index 00000000..f71cd09e --- /dev/null +++ b/templates/react/components/foo/type.ts @@ -0,0 +1,5 @@ +import { {{{ucf}}} } from "../../interfaces/{{{ucf}}}"; + +type TResource = {{{ucf}}}; + +export default TResource; diff --git a/templates/react/hooks/create.ts b/templates/react/hooks/create.ts new file mode 100644 index 00000000..424217c5 --- /dev/null +++ b/templates/react/hooks/create.ts @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { ApiResource, TError } from "../utils/types"; +import useFetch from "./fetch"; + +interface ICreateStore { + error: TError; + loading: boolean; + created: Resource | null; + reset: () => void; + create: (values: Partial) => Promise; +} + +const useCreate = (params: { "@id": string; }): ICreateStore => { + const {fetch} = useFetch(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [created, setCreated] = useState(null); + + return { + error, + loading, + created, + reset() { + setLoading(false); + setError(null); + }, + create(values) { + setLoading(true); + + return fetch(params["@id"], {method: "POST", body: JSON.stringify(values)}) + .then(({json}) => json) + .then(retrieved => setCreated(retrieved)) + .catch(e => { + setError(e); + }) + .finally(() => setLoading(false)); + }, + }; +} + +export default useCreate; diff --git a/templates/react/hooks/delete.ts b/templates/react/hooks/delete.ts new file mode 100644 index 00000000..176bff63 --- /dev/null +++ b/templates/react/hooks/delete.ts @@ -0,0 +1,36 @@ +import { useState } from "react"; +import { ApiResource, TError } from "../utils/types"; +import useFetch from "./fetch"; + +interface IDeleteStore { + error: TError; + loading: boolean; + deleted: Resource | null; + setDeleted: (item: Resource | null) => void; + del: (item: Resource) => Promise; +} + +const useDelete = (): IDeleteStore => { + const {fetch} = useFetch(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [deleted, setDeleted] = useState(null); + + return { + loading, + error, + deleted, + setDeleted, + del(item: Resource) { + setLoading(true); + + return fetch(item["@id"], {method: "DELETE"}) + .then(({json}) => json) + .then(() => setDeleted(item)) + .catch(e => setError(e)) + .finally(() => setLoading(false)); + }, + }; +} + +export default useDelete; diff --git a/templates/react/hooks/fetch.ts b/templates/react/hooks/fetch.ts new file mode 100644 index 00000000..edb2f79a --- /dev/null +++ b/templates/react/hooks/fetch.ts @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { ENTRYPOINT } from "../config/entrypoint"; +import { SubmissionErrors, SubmissionError } from "../utils/types"; + +interface FetchResponse { + readonly response: Response; + readonly json: any; +} + +export interface IFetchStore { + fetch: (input: RequestInfo, init?: RequestInit) => Promise; + setAuth: (auth: string) => void; +} + +const normalizeUrl = (url: string) => { + return String(new URL(url, ENTRYPOINT)); +} + +const normalizeInput = (input: RequestInfo): RequestInfo => { + if (typeof input === "string") { + return normalizeUrl(input); + } + + return {...input, url: normalizeUrl(input.url)}; +} + +const MIME_TYPE = "application/ld+json"; + +const normalizeHeaders = (options: RequestInit): RequestInit => { + if (!(options.headers instanceof Headers)) { + options.headers = new Headers(options.headers); + } + + return options; +} + +const normalizeContentType = (options: RequestInit): RequestInit => { + if ( + "undefined" !== options.body + && !(options.body instanceof FormData) + && (options.headers instanceof Headers) + && null === options.headers.get("Content-Type") + ) { + options.headers.set("Content-Type", MIME_TYPE); + } + + return options; +} + +const normalizeAuth = (auth: string) => { + return (options: RequestInit): RequestInit => { + if ( + auth + && (options.headers instanceof Headers) + && null === options.headers.get("Authorization") + ) { + options.headers.set("Authorization", auth); + } + + return options; + }; +} + +// Error handling +const regularHandler = (response: Response, json: any) => { + const error = + json["hydra:description"] || + json["hydra:title"] || + json["message"] || + "An error occurred."; + + throw new Error(error); +} + +const submissionHandler = (response: Response, json: any) => { + if (!json.violations) { + return; + } + + const error = + json["hydra:description"] || + json["hydra:title"] || + json["message"] || + "An error occurred."; + + const violations: { propertyPath: string; message: string; }[] = json.violations; + + const errors = violations + .reduce((errors, violation) => { + if (errors[violation.propertyPath]) { + errors[violation.propertyPath] += "\n" + violation.message; + } else { + errors[violation.propertyPath] = violation.message; + } + + return errors; + }, {} as SubmissionErrors); + + throw new SubmissionError(error, errors); +} + +const useFetch = (): IFetchStore => { + const [auth, setAuth] = useState(""); + + return { + setAuth, + + fetch(input, init = {}) { + input = normalizeInput(input); + init = [ + normalizeHeaders, + normalizeContentType, + normalizeAuth(auth), + ].reduce((init, normalize) => normalize(init), init); + + if (init.method === 'DELETE') { + return fetch(input, init) + .then( + response => ({ response, json: null }) + ) + } + + return fetch(input, init) + .then( + response => response + .json() + .then<{ response: Response; json: object; }>(json => ({response, json})) + .catch(() => { + throw new Error(response.statusText || "An error occurred."); + }) + , + ) + .then((data) => { + if (!data.response.ok) { + submissionHandler(data.response, data.json); + regularHandler(data.response, data.json); + } + + return data; + }) + }, + }; +} + +export default useFetch; diff --git a/templates/react/hooks/index.ts b/templates/react/hooks/index.ts new file mode 100644 index 00000000..9bf9d32c --- /dev/null +++ b/templates/react/hooks/index.ts @@ -0,0 +1,17 @@ +import useCreate from "./create"; +import useDelete from "./delete"; +import useFetch from "./fetch"; +import useList from "./list"; +import useRetrieve from "./retrieve"; +import useShow from "./show"; +import useUpdate from "./update"; + +export { + useCreate, + useDelete, + useFetch, + useList, + useRetrieve, + useShow, + useUpdate, +}; diff --git a/templates/react/hooks/list.ts b/templates/react/hooks/list.ts new file mode 100644 index 00000000..b0e2bbfd --- /dev/null +++ b/templates/react/hooks/list.ts @@ -0,0 +1,28 @@ +import { ApiResource, TError } from "../utils/types"; +import { PagedCollection } from "../interfaces/Collection"; +import useShow from "./show"; + +interface IListStore { + error: TError; + loading: boolean; + retrieved: PagedCollection | null; + // eventSource: EventSource | null; + reset: (/*eventSource: EventSource | null*/) => any; + list: (page?: string) => Promise; +} + +const useList = (params: { "@id": string; }): IListStore => { + const {error, loading, retrieved, retrieve, reset} = useShow>(); + + return { + error, + loading, + retrieved, + reset, + list(page = params["@id"]) { + return retrieve(page); + }, + }; +} + +export default useList; diff --git a/templates/react/hooks/mercure.ts b/templates/react/hooks/mercure.ts new file mode 100644 index 00000000..768c973a --- /dev/null +++ b/templates/react/hooks/mercure.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState } from "react"; +import { ENTRYPOINT } from "../config/entrypoint"; +import { ApiResource } from "../utils/types"; +import { PagedCollection } from "../interfaces/Collection"; + +interface IMercureStore { + deleted: Resource | null; + message: Resource | null; + onResponse: (response: Response) => void; +} + +const subscribe = (url: URL, topics: string[] | string): EventSource => { + (Array.isArray(topics) ? topics : [topics]).forEach( + topic => url.searchParams.append("topic", String(new URL(topic, ENTRYPOINT))), + ); + + return new EventSource(url.toString()); +} + +export const extractHubURL = (response: Response): URL | null => { + const linkHeader = response.headers.get("Link"); + if (!linkHeader) { + return null; + } + + const matches = linkHeader.match( + /<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/, + ); + + return matches && matches[1] ? new URL(matches[1], ENTRYPOINT) : null; +} + +const useMercure = (retrieved: Resource | null): IMercureStore => { + const [eventSource, setEventSource] = useState(null); + const [deleted, setDeleted] = useState(null); + const [message, setMessage] = useState(null); + const [hubURL, setHubURL] = useState(null); + + const onMessage = useCallback( + (retrieved: Resource) => { + if (1 === Object.keys(retrieved).length) { + setDeleted(retrieved); + return; + } + + setMessage(retrieved); + }, + [], + ); + + useEffect(() => { + if (eventSource) { + // Listen events + eventSource.addEventListener( + "message", + event => onMessage(JSON.parse(event.data)), + ); + } + + return () => { + // Cleanup event source on unmount + if (eventSource) { + eventSource.close(); + } + }; + //eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventSource]); + + useEffect( + () => { + const collection = retrieved as PagedCollection; + + if (hubURL && retrieved) { + if (collection["hydra:member"]) { + setEventSource(subscribe(hubURL, collection["hydra:member"].map(item => item["@id"]))); + } else { + setEventSource(subscribe(hubURL, retrieved["@id"])); + } + } else { + setEventSource(null); + } + }, + [retrieved, hubURL], + ); + + return { + deleted, + message, + onResponse (response: Response) { + setHubURL(extractHubURL(response)); + }, + }; +} + +export default useMercure; diff --git a/templates/react/hooks/retrieve.ts b/templates/react/hooks/retrieve.ts new file mode 100644 index 00000000..87905632 --- /dev/null +++ b/templates/react/hooks/retrieve.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useShow } from "./index"; +import { ApiResource } from "../utils/types"; + +const useRetrieve = (id: string) => { + const show = useShow(); + const {reset, retrieve} = show; + + useEffect(() => { + retrieve(id); + + return () => reset(); + //eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + return show; +} + +export default useRetrieve; diff --git a/templates/react/hooks/show.ts b/templates/react/hooks/show.ts new file mode 100644 index 00000000..78d63f2d --- /dev/null +++ b/templates/react/hooks/show.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { ApiResource, TError } from "../utils/types"; +import { PagedCollection } from "../interfaces/Collection"; +import useFetch from "./fetch"; +import useMercure from "./mercure"; + +interface IShowStore { + error: TError; + loading: boolean; + retrieved: Resource | null; + retrieve: (id: string) => any; + reset: (/*eventSource: EventSource | null*/) => any; +} + +const useShow = (): IShowStore => { + const {fetch} = useFetch(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [retrieved, setRetrieved] = useState(null); + const {deleted, message, onResponse} = useMercure(retrieved); + + const subscribeMercure = ({response, json}: {response: Response; json: any;}) => { + onResponse(response); + + return json; + }; + + useEffect(() => { + if (deleted) { + setError(new Error(`${deleted["@id"]} has been deleted by another user.`)); + } + }, [deleted]); + + useEffect(() => { + if (message) { + const collection = (retrieved as PagedCollection); + if (collection && collection['hydra:member']) { + const item = collection['hydra:member'].find((i) => i["@id"] === message["@id"]); + if (item && retrieved) { + Object.assign(item, message); + setRetrieved({ ...retrieved }); + } + return; + } + setRetrieved(message); + } + }, [message, setRetrieved]); // eslint-disable-line react-hooks/exhaustive-deps + + return { + error, + loading, + retrieved, + reset () { + setError(null); + setLoading(false); + setRetrieved(null); + }, + retrieve (id) { + setLoading(true); + setError(null); + + return fetch(id) + .then(subscribeMercure) + .then((retrieved) => setRetrieved(retrieved)) + .catch(e => setError(e)) + .finally(() => setLoading(false)); + }, + }; +} + +export default useShow; diff --git a/templates/react/hooks/update.ts b/templates/react/hooks/update.ts new file mode 100644 index 00000000..656fc85e --- /dev/null +++ b/templates/react/hooks/update.ts @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { ApiResource, TError } from "../utils/types"; +import useFetch from "./fetch"; +import useMercure from "./mercure"; + +interface IUpdateStore { + error: TError; + loading: boolean; + updated: Resource | null; + reset: () => any; + update: (item: Resource, values: Partial) => Promise; +} + +const useUpdate = (): IUpdateStore => { + const {fetch} = useFetch(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [updated, setUpdated] = useState(null); + const {deleted, message, onResponse} = useMercure(updated); + + const subscribeMercure = ({response, json}: {response: Response; json: any;}) => { + onResponse(response); + + return json; + }; + + useEffect(() => { + if (deleted) { + setError(new Error(`${deleted["@id"]} has been deleted by another user.`)); + } + }, [deleted]); + + useEffect(() => { + if (message) { + setUpdated(message); + } + }, [message]); + + return { + loading, + error, + updated, + reset() { + setError(null); + setLoading(false); + setUpdated(null); + }, + update (item: Resource, values: Partial) { + setError(null); + setLoading(true); + + const options = { + method: "PUT", + headers: new Headers({"Content-Type": "application/ld+json"}), + body: JSON.stringify(values), + }; + + return fetch(item["@id"], options) + .then(subscribeMercure) + .then((updated) => setUpdated(updated)) + .catch(e => { + setError(e); + }) + .finally(() => setLoading(false)); + }, + }; +} + +export default useUpdate; diff --git a/templates/react/index.js b/templates/react/index.js deleted file mode 100644 index adf79e7d..00000000 --- a/templates/react/index.js +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import reportWebVitals from './reportWebVitals'; - -import App from './App'; - -import { createBrowserHistory } from 'history'; -import { Route, Switch } from 'react-router-dom'; -import { createStore, combineReducers, applyMiddleware } from 'redux'; -import thunk from 'redux-thunk'; -import { Provider } from 'react-redux'; -import { - ConnectedRouter, - connectRouter, - routerMiddleware -} from 'connected-react-router'; -import { reducer as form } from 'redux-form'; - -import book from './reducers/book/'; -import bookRoutes from './routes/book'; - -const history = createBrowserHistory(); -const store = createStore( - combineReducers({ - router: connectRouter(history), - form, - book, - }), - applyMiddleware(routerMiddleware(history), thunk) -); - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - - - - { bookRoutes } -

Not Found

} /> -
-
-
-
-); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/templates/react/index.tsx b/templates/react/index.tsx new file mode 100644 index 00000000..c1a0b6cb --- /dev/null +++ b/templates/react/index.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import reportWebVitals from './reportWebVitals'; + +import App from './App'; + +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; + +import bookRoutes from './routes/book'; +import reviewRoutes from './routes/review'; + +const NotFound = () => ( +

Not Found

+); + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); +root.render( + + + + } /> + { bookRoutes } + { reviewRoutes } + } /> + + + +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/templates/react/interfaces/Collection.ts b/templates/react/interfaces/Collection.ts new file mode 100644 index 00000000..be7932e1 --- /dev/null +++ b/templates/react/interfaces/Collection.ts @@ -0,0 +1,21 @@ +import { ApiResource } from "../utils/types"; + +export interface Pagination { + "hydra:first"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + "hydra:last"?: string; +} + +export interface PagedCollection extends ApiResource { + "@context"?: string; + "@type"?: string; + "hydra:firstPage"?: string; + "hydra:itemsPerPage"?: number; + "hydra:lastPage"?: string; + "hydra:member"?: T[]; + "hydra:nextPage"?: string; + "hydra:search"?: object; + "hydra:totalItems"?: number; + "hydra:view"?: Pagination; +} diff --git a/templates/react/interfaces/foo.ts b/templates/react/interfaces/foo.ts new file mode 100644 index 00000000..b4e90689 --- /dev/null +++ b/templates/react/interfaces/foo.ts @@ -0,0 +1,7 @@ +import { ApiResource } from "../utils/types"; + +export interface {{{ucf}}} extends ApiResource { +{{#each fields}} + {{#if readonly}}readonly{{/if}} {{{name}}}?: {{#if (compare type "==" "Date")}}string{{else}}{{{type}}}{{/if}}; +{{/each}} +} diff --git a/templates/react/routes/foo.js b/templates/react/routes/foo.js deleted file mode 100644 index c9d1306b..00000000 --- a/templates/react/routes/foo.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { Route } from 'react-router-dom'; -import { List, Create, Update, Show } from '../components/{{{lc}}}/'; - -export default [ - , - , - , - , - -]; diff --git a/templates/react/routes/foo.tsx b/templates/react/routes/foo.tsx new file mode 100644 index 00000000..412d313f --- /dev/null +++ b/templates/react/routes/foo.tsx @@ -0,0 +1,12 @@ +import { Route } from "react-router-dom"; +import { List, Create, Update, Show } from "../components/{{{lc}}}/"; + +const routes = [ + } key="create" />, + } key="update" />, + } key="show" />, + } key="list" />, + } key="page" />, +]; + +export default routes; diff --git a/templates/react/utils/dataAccess.ts b/templates/react/utils/dataAccess.ts new file mode 100644 index 00000000..f1ec1365 --- /dev/null +++ b/templates/react/utils/dataAccess.ts @@ -0,0 +1,11 @@ +export const normalizeLinks = (value: string | string[] | undefined): string[] => { + if (!value) { + return []; + } + + if (typeof value === "string") { + return value.split(","); + } + + return value; +} diff --git a/templates/react/utils/types.ts b/templates/react/utils/types.ts new file mode 100644 index 00000000..4c76ac3c --- /dev/null +++ b/templates/react/utils/types.ts @@ -0,0 +1,22 @@ +export type TError = SubmissionError | Error | null; + +export interface ApiResource { + "@id": string; +} + +export interface SubmissionErrors { + [p: string]: string; +} + +export class SubmissionError extends Error { + private readonly _errors: SubmissionErrors + + constructor(message: string, errors: SubmissionErrors) { + super(message); + this._errors = errors; + } + + public get errors(): SubmissionErrors { + return this._errors; + } +}