diff --git a/src/generators.js b/src/generators.js index 68e34c00..ca460942 100644 --- a/src/generators.js +++ b/src/generators.js @@ -2,6 +2,7 @@ import AdminOnRestGenerator from "./generators/AdminOnRestGenerator"; import NextGenerator from "./generators/NextGenerator"; import ReactGenerator from "./generators/ReactGenerator"; import ReactNativeGenerator from "./generators/ReactNativeGenerator"; +import ReactTypescriptGenerator from "./generators/ReactTypescriptGenerator"; import TypescriptInterfaceGenerator from "./generators/TypescriptInterfaceGenerator"; import VueGenerator from "./generators/VueGenerator"; import VuetifyGenerator from "./generators/VuetifyGenerator"; @@ -24,6 +25,8 @@ export default function generators(generator = "react") { return wrap(ReactNativeGenerator); case "typescript": return wrap(TypescriptInterfaceGenerator); + case "react-typescript": + return wrap(ReactTypescriptGenerator); case "vue": return wrap(VueGenerator); case "vuetify": diff --git a/src/generators/ReactTypescriptGenerator.js b/src/generators/ReactTypescriptGenerator.js new file mode 100644 index 00000000..ff0b9612 --- /dev/null +++ b/src/generators/ReactTypescriptGenerator.js @@ -0,0 +1,217 @@ +import chalk from "chalk"; +import BaseGenerator from "./BaseGenerator"; + +export default class ReactTypescriptGenerator extends BaseGenerator { + constructor(params) { + super(params); + + this.registerTemplates("react-typescript/", [ + // actions + "actions/foo/create.ts", + "actions/foo/delete.ts", + "actions/foo/list.ts", + "actions/foo/update.ts", + "actions/foo/show.ts", + + // utils + "utils/dataAccess.ts", + "utils/types.ts", + + // reducers + "reducers/foo/create.ts", + "reducers/foo/delete.ts", + "reducers/foo/index.ts", + "reducers/foo/list.ts", + "reducers/foo/update.ts", + "reducers/foo/show.ts", + + // types + "types/foo/create.ts", + "types/foo/delete.ts", + "types/foo/list.ts", + "types/foo/show.ts", + "types/foo/update.ts", + + // interfaces + "interfaces/Collection.ts", + "interfaces/foo.ts", + + // components + "components/foo/Create.tsx", + "components/foo/Form.tsx", + "components/foo/index.tsx", + "components/foo/List.tsx", + "components/foo/Update.tsx", + "components/foo/Show.tsx", + + // routes + "routes/foo.tsx" + ]); + } + + help(resource) { + const titleLc = resource.title.toLowerCase(); + + console.log( + 'Code for the "%s" resource type has been generated!', + resource.title + ); + console.log( + "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 ${titleLc}Routes from './routes/${titleLc}'; + +// Add the reducer +combineReducers({ ${titleLc},/* ... */ }), + +// Add routes to +{ ${titleLc}Routes } +`) + ); + } + + generate(api, resource, dir) { + const lc = resource.title.toLowerCase(); + const titleUcFirst = this.ucFirst(resource.title); + const { fields, imports } = this.parseFields(resource); + + const context = { + name: resource.name, + lc, + uc: resource.title.toUpperCase(), + ucf: titleUcFirst, + titleUcFirst, + fields, + formFields: this.buildFields(fields), + imports, + hydraPrefix: this.hydraPrefix, + title: resource.title + }; + + // Create directories + // These directories may already exist + [ + `${dir}/utils`, + `${dir}/config`, + `${dir}/interfaces`, + `${dir}/routes`, + `${dir}/actions/${lc}`, + `${dir}/types/${lc}`, + `${dir}/components/${lc}`, + `${dir}/reducers/${lc}` + ].forEach(dir => this.createDir(dir)); + + [ + // actions + "actions/%s/create.ts", + "actions/%s/delete.ts", + "actions/%s/list.ts", + "actions/%s/update.ts", + "actions/%s/show.ts", + + // components + "components/%s/Create.tsx", + "components/%s/Form.tsx", + "components/%s/index.tsx", + "components/%s/List.tsx", + "components/%s/Update.tsx", + "components/%s/Show.tsx", + + // reducers + "reducers/%s/create.ts", + "reducers/%s/delete.ts", + "reducers/%s/index.ts", + "reducers/%s/list.ts", + "reducers/%s/update.ts", + "reducers/%s/show.ts", + + // types + "types/%s/create.ts", + "types/%s/delete.ts", + "types/%s/list.ts", + "types/%s/show.ts", + "types/%s/update.ts", + + // routes + "routes/%s.tsx" + ].forEach(pattern => this.createFileFromPattern(pattern, dir, lc, context)); + + // interface pattern should be camel cased + this.createFile( + "interfaces/foo.ts", + `${dir}/interfaces/${context.ucf}.ts`, + context + ); + + // copy with regular name + [ + // interfaces + "interfaces/Collection.ts", + + // utils + "utils/dataAccess.ts", + "utils/types.ts" + ].forEach(file => this.createFile(file, `${dir}/${file}`, context, false)); + + // API config + this.createEntrypoint(api.entrypoint, `${dir}/config/entrypoint.ts`); + } + + getDescription(field) { + return field.description ? field.description.replace(/"/g, "'") : ""; + } + + parseFields(resource) { + const fields = [ + ...resource.writableFields, + ...resource.readableFields + ].reduce((list, field) => { + if (list[field.name]) { + return list; + } + + return { + ...list, + [field.name]: { + notrequired: !field.required, + name: field.name, + type: this.getType(field), + description: this.getDescription(field), + readonly: false, + reference: field.reference + } + }; + }, {}); + + // Parse fields to add relevant imports, required for Typescript + const fieldsArray = Object.values(fields); + const imports = Object.values(fields).reduce( + (list, { reference, type }) => { + if (!reference) { + return list; + } + + return { + ...list, + [type]: { + type, + file: `./${type}` + } + }; + }, + {} + ); + + return { fields: fieldsArray, imports: Object.values(imports) }; + } + + ucFirst(target) { + return target.charAt(0).toUpperCase() + target.slice(1); + } +} diff --git a/src/generators/ReactTypescriptGenerator.test.js b/src/generators/ReactTypescriptGenerator.test.js new file mode 100644 index 00000000..31375f3d --- /dev/null +++ b/src/generators/ReactTypescriptGenerator.test.js @@ -0,0 +1,80 @@ +import { Api, Resource, Field } from "@api-platform/api-doc-parser/lib"; +import fs from "fs"; +import tmp from "tmp"; +import ReactTypescriptGenerator from "./ReactTypescriptGenerator"; + +test("Generate a Typescript React app", () => { + const generator = new ReactTypescriptGenerator({ + hydraPrefix: "hydra:", + templateDirectory: `${__dirname}/../../templates` + }); + const tmpobj = tmp.dirSync({ unsafeCleanup: true }); + + const fields = [ + new Field("bar", { + id: "http://schema.org/url", + range: "http://www.w3.org/2001/XMLSchema#string", + reference: null, + required: true, + description: "An URL" + }) + ]; + const resource = new Resource("abc", "http://example.com/foos", { + id: "abc", + title: "abc", + readableFields: fields, + writableFields: fields + }); + const api = new Api("http://example.com", { + entrypoint: "http://example.com:8080", + title: "My API", + resources: [resource] + }); + generator.generate(api, resource, tmpobj.name); + + [ + "/utils/dataAccess.ts", + "/utils/types.ts", + "/config/entrypoint.ts", + + "/interfaces/Abc.ts", + "/interfaces/Collection.ts", + + "/actions/abc/create.ts", + "/actions/abc/delete.ts", + "/actions/abc/list.ts", + "/actions/abc/show.ts", + "/actions/abc/update.ts", + + "/types/abc/create.ts", + "/types/abc/delete.ts", + "/types/abc/list.ts", + "/types/abc/show.ts", + "/types/abc/update.ts", + + "/components/abc/index.tsx", + "/components/abc/Create.tsx", + "/components/abc/Update.tsx", + + "/routes/abc.tsx", + + "/reducers/abc/create.ts", + "/reducers/abc/delete.ts", + "/reducers/abc/index.ts", + "/reducers/abc/list.ts", + "/reducers/abc/show.ts", + "/reducers/abc/update.ts" + ].forEach(file => expect(fs.existsSync(tmpobj.name + file)).toBe(true)); + + [ + "/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/); + }); + + tmpobj.removeCallback(); +}); diff --git a/templates/react-typescript/actions/foo/create.ts b/templates/react-typescript/actions/foo/create.ts new file mode 100644 index 00000000..5dfd1c98 --- /dev/null +++ b/templates/react-typescript/actions/foo/create.ts @@ -0,0 +1,55 @@ +import { SubmissionError } from 'redux-form'; +import { fetchApi } from '../../utils/dataAccess'; +import { TError, TDispatch } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { + {{{uc}}}_CREATE_ERROR, + {{{uc}}}_CREATE_LOADING, + {{{uc}}}_CREATE_SUCCESS, + IActionError, + IActionLoading, + IActionSuccess +} from '../../types/{{{lc}}}/create'; + +export function error(error: TError): IActionError { + return { type: {{{uc}}}_CREATE_ERROR, error }; +} + +export function loading(loading: boolean): IActionLoading { + return { type: {{{uc}}}_CREATE_LOADING, loading }; +} + +export function success(created: I{{{ucf}}} | null): IActionSuccess { + return { type: {{{uc}}}_CREATE_SUCCESS, created }; +} + +export function create(values: Partial) { + return (dispatch: TDispatch) => { + dispatch(loading(true)); + + return fetchApi('{{{name}}}', { method: 'POST', body: JSON.stringify(values) }) + .then(response => { + dispatch(loading(false)); + + return response.json(); + }) + .then(retrieved => dispatch(success(retrieved))) + .catch(e => { + dispatch(loading(false)); + + if (e instanceof SubmissionError) { + dispatch(error(e.errors._error)); + throw e; + } + + dispatch(error(e.message)); + }); + }; +} + +export function reset() { + return (dispatch: TDispatch) => { + dispatch(loading(false)); + dispatch(error(null)); + }; +} diff --git a/templates/react-typescript/actions/foo/delete.ts b/templates/react-typescript/actions/foo/delete.ts new file mode 100644 index 00000000..1a04a8f9 --- /dev/null +++ b/templates/react-typescript/actions/foo/delete.ts @@ -0,0 +1,39 @@ +import { fetchApi } from '../../utils/dataAccess'; +import { TError, TDispatch, IResource } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { + {{{uc}}}_DELETE_ERROR, + {{{uc}}}_DELETE_LOADING, + {{{uc}}}_DELETE_SUCCESS, + IActionError, + IActionLoading, + IActionSuccess +} from '../../types/{{{lc}}}/delete'; + +export function error(error: TError): IActionError { + return { type: {{{uc}}}_DELETE_ERROR, error }; +} + +export function loading(loading: boolean): IActionLoading { + return { type: {{{uc}}}_DELETE_LOADING, loading }; +} + +export function success(deleted: Partial & IResource | null): IActionSuccess { + return { type: {{{uc}}}_DELETE_SUCCESS, deleted }; +} + +export function del(item: Partial & IResource) { + return (dispatch: TDispatch) => { + dispatch(loading(true)); + + return fetchApi(item['@id'], { method: 'DELETE' }) + .then(() => { + dispatch(loading(false)); + dispatch(success(item)); + }) + .catch(e => { + dispatch(loading(false)); + dispatch(error(e.message)); + }); + }; +} diff --git a/templates/react-typescript/actions/foo/list.ts b/templates/react-typescript/actions/foo/list.ts new file mode 100644 index 00000000..f8ca70cd --- /dev/null +++ b/templates/react-typescript/actions/foo/list.ts @@ -0,0 +1,101 @@ +import { + fetchApi, + normalize, + extractHubURL, + mercureSubscribe as subscribe +} from '../../utils/dataAccess'; +import { success as deleteSuccess } from './delete'; +import { TError, TDispatch, IResource } from '../../utils/types' +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { IPagedCollection } from '../../interfaces/Collection' +import { + {{{uc}}}_LIST_ERROR, + {{{uc}}}_LIST_LOADING, + {{{uc}}}_LIST_MERCURE_DELETED, + {{{uc}}}_LIST_MERCURE_MESSAGE, + {{{uc}}}_LIST_MERCURE_OPEN, + {{{uc}}}_LIST_RESET, + {{{uc}}}_LIST_SUCCESS, + IActionError, + IActionLoading, + IActionSuccess, + IActionMercureOpen +} from '../../types/{{{lc}}}/list' + +export function error(error: TError): IActionError { + return { type: {{{uc}}}_LIST_ERROR, error }; +} + +export function loading(loading: boolean): IActionLoading { + return { type: {{{uc}}}_LIST_LOADING, loading }; +} + +export function success(retrieved: IPagedCollection): IActionSuccess { + return { type: {{{uc}}}_LIST_SUCCESS, retrieved }; +} + +export function list(page = '{{{name}}}') { + return (dispatch: TDispatch) => { + dispatch(loading(true)); + dispatch(error('')); + + fetchApi(page) + .then(response => + response + .json() + .then(retrieved => ({ retrieved, hubURL: extractHubURL(response) })) + ) + .then(({ retrieved, hubURL }) => { + retrieved = normalize(retrieved); + + dispatch(loading(false)); + dispatch(success(retrieved)); + + if (hubURL && retrieved['hydra:member'].length) + dispatch( + mercureSubscribe( + hubURL, + retrieved['hydra:member'].map((i: I{{{ucf}}}) => i['@id']) + ) + ); + }) + .catch(e => { + dispatch(loading(false)); + dispatch(error(e.message)); + }); + }; +} + +export function reset(eventSource: EventSource | null) { + return (dispatch: TDispatch) => { + if (eventSource) eventSource.close(); + + dispatch({ type: {{{uc}}}_LIST_RESET }); + dispatch(deleteSuccess(null)); + }; +} + +export function mercureSubscribe(hubURL: URL, topics: string[]) { + return (dispatch: TDispatch) => { + const eventSource = subscribe(hubURL, topics); + dispatch(mercureOpen(eventSource)); + eventSource.addEventListener('message', event => + dispatch(mercureMessage(normalize(JSON.parse(event.data)))) + ); + }; +} + +export function mercureOpen(eventSource: EventSource): IActionMercureOpen { + return { type: {{{uc}}}_LIST_MERCURE_OPEN, eventSource }; +} + +export function mercureMessage(retrieved: IResource | I{{{ucf}}}) { + return (dispatch: TDispatch) => { + if (1 === Object.keys(retrieved).length) { + dispatch({ type: {{{uc}}}_LIST_MERCURE_DELETED, retrieved }); + return; + } + + dispatch({ type: {{{uc}}}_LIST_MERCURE_MESSAGE, retrieved }); + }; +} diff --git a/templates/react-typescript/actions/foo/show.ts b/templates/react-typescript/actions/foo/show.ts new file mode 100644 index 00000000..4d4e3a6a --- /dev/null +++ b/templates/react-typescript/actions/foo/show.ts @@ -0,0 +1,92 @@ +import { + fetchApi, + extractHubURL, + normalize, + mercureSubscribe as subscribe +} from '../../utils/dataAccess'; +import { TError, TDispatch, IResource } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { + {{{uc}}}_SHOW_ERROR, + {{{uc}}}_SHOW_LOADING, + {{{uc}}}_SHOW_SUCCESS, + {{{uc}}}_SHOW_RESET, + {{{uc}}}_SHOW_MERCURE_OPEN, + {{{uc}}}_SHOW_MERCURE_DELETED, + {{{uc}}}_SHOW_MERCURE_MESSAGE, + IActionError, + IActionLoading, + IActionSuccess +} from '../../types/{{{lc}}}/show'; + +export function error(error: TError): IActionError { + return { type: {{{uc}}}_SHOW_ERROR, error }; +} + +export function loading(loading: boolean): IActionLoading { + return { type: {{{uc}}}_SHOW_LOADING, loading }; +} + +export function success(retrieved: I{{{ucf}}}): IActionSuccess { + return { type: {{{uc}}}_SHOW_SUCCESS, retrieved }; +} + +export function retrieve(id: string) { + return (dispatch: TDispatch) => { + dispatch(loading(true)); + + return fetchApi(id) + .then(response => + response + .json() + .then(retrieved => ({ retrieved, hubURL: extractHubURL(response) })) + ) + .then(({ retrieved, hubURL }) => { + retrieved = normalize(retrieved); + + dispatch(loading(false)); + dispatch(success(retrieved)); + + if (hubURL) dispatch(mercureSubscribe(hubURL, retrieved['@id'])); + }) + .catch(e => { + dispatch(loading(false)); + dispatch(error(e.message)); + }); + }; +} + +export function reset(eventSource: EventSource | null) { + return (dispatch: TDispatch) => { + if (eventSource) eventSource.close(); + + dispatch({ type: {{{uc}}}_SHOW_RESET }); + dispatch(error(null)); + dispatch(loading(false)); + }; +} + +export function mercureSubscribe(hubURL: URL, topic: string) { + return (dispatch: TDispatch) => { + const eventSource = subscribe(hubURL, [topic]); + dispatch(mercureOpen(eventSource)); + eventSource.addEventListener('message', event => + dispatch(mercureMessage(normalize(JSON.parse(event.data)))) + ); + }; +} + +export function mercureOpen(eventSource: EventSource) { + return { type: {{{uc}}}_SHOW_MERCURE_OPEN, eventSource }; +} + +export function mercureMessage(retrieved: IResource | I{{{ucf}}}) { + return (dispatch: TDispatch) => { + if (1 === Object.keys(retrieved).length) { + dispatch({ type: {{{uc}}}_SHOW_MERCURE_DELETED, retrieved }); + return; + } + + dispatch({ type: {{{uc}}}_SHOW_MERCURE_MESSAGE, retrieved }); + }; +} diff --git a/templates/react-typescript/actions/foo/update.ts b/templates/react-typescript/actions/foo/update.ts new file mode 100644 index 00000000..2ba07d76 --- /dev/null +++ b/templates/react-typescript/actions/foo/update.ts @@ -0,0 +1,152 @@ +import { SubmissionError } from 'redux-form'; +import { + fetchApi, + extractHubURL, + normalize, + mercureSubscribe as subscribe +} from '../../utils/dataAccess'; +import { success as createSuccess } from './create'; +import { loading, error } from './delete'; +import { TError, TDispatch, IResource } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { + {{{uc}}}_UPDATE_RETRIEVE_ERROR, + {{{uc}}}_UPDATE_RETRIEVE_LOADING, + {{{uc}}}_UPDATE_RETRIEVE_SUCCESS, + {{{uc}}}_UPDATE_UPDATE_ERROR, + {{{uc}}}_UPDATE_UPDATE_LOADING, + {{{uc}}}_UPDATE_UPDATE_SUCCESS, + {{{uc}}}_UPDATE_RESET, + {{{uc}}}_UPDATE_MERCURE_OPEN, + {{{uc}}}_UPDATE_MERCURE_DELETED, + {{{uc}}}_UPDATE_MERCURE_MESSAGE, + IActionRetrieveError, + IActionRetrieveLoading, + IActionRetrieveSuccess, + IActionUpdateError, + IActionUpdateLoading, + IActionUpdateSuccess, + IActionMercureOpen +} from '../../types/{{{lc}}}/update' + +export function retrieveError(retrieveError: TError): IActionRetrieveError { + return { type: {{{uc}}}_UPDATE_RETRIEVE_ERROR, retrieveError }; +} + +export function retrieveLoading(retrieveLoading: boolean): IActionRetrieveLoading { + return { type: {{{uc}}}_UPDATE_RETRIEVE_LOADING, retrieveLoading }; +} + +export function retrieveSuccess(retrieved: I{{{ucf}}}): IActionRetrieveSuccess { + return { type: {{{uc}}}_UPDATE_RETRIEVE_SUCCESS, retrieved }; +} + +export function retrieve(id: string) { + return (dispatch: TDispatch) => { + dispatch(retrieveLoading(true)); + + return fetchApi(id) + .then(response => + response + .json() + .then(retrieved => ({ retrieved, hubURL: extractHubURL(response) })) + ) + .then(({ retrieved, hubURL }) => { + retrieved = normalize(retrieved); + + dispatch(retrieveLoading(false)); + dispatch(retrieveSuccess(retrieved)); + + if (hubURL) dispatch(mercureSubscribe(hubURL, retrieved['@id'])); + }) + .catch(e => { + dispatch(retrieveLoading(false)); + dispatch(retrieveError(e.message)); + }); + }; +} + +export function updateError(updateError: TError): IActionUpdateError { + return { type: {{{uc}}}_UPDATE_UPDATE_ERROR, updateError }; +} + +export function updateLoading(updateLoading: boolean): IActionUpdateLoading { + return { type: {{{uc}}}_UPDATE_UPDATE_LOADING, updateLoading }; +} + +export function updateSuccess(updated: I{{{ucf}}}): IActionUpdateSuccess { + return { type: {{{uc}}}_UPDATE_UPDATE_SUCCESS, updated }; +} + +export function update(item: IResource, values: Partial) { + return (dispatch: TDispatch) => { + dispatch(updateError(null)); + dispatch(createSuccess(null)); + dispatch(updateLoading(true)); + + return fetchApi(item['@id'], { + method: 'PUT', + headers: new Headers({ 'Content-Type': 'application/ld+json' }), + body: JSON.stringify(values) + }) + .then(response => + response + .json() + .then(retrieved => ({ retrieved, hubURL: extractHubURL(response) })) + ) + .then(({ retrieved, hubURL }) => { + retrieved = normalize(retrieved); + + dispatch(updateLoading(false)); + dispatch(updateSuccess(retrieved)); + + if (hubURL) dispatch(mercureSubscribe(hubURL, retrieved['@id'])); + }) + .catch(e => { + dispatch(updateLoading(false)); + + if (e instanceof SubmissionError) { + dispatch(updateError(e.errors._error)); + throw e; + } + + dispatch(updateError(e.message)); + }); + }; +} + +export function reset(eventSource: EventSource | null) { + return (dispatch: TDispatch) => { + if (eventSource) eventSource.close(); + + dispatch({ type: {{{uc}}}_UPDATE_RESET }); + dispatch(error(null)); + dispatch(loading(false)); + dispatch(createSuccess(null)); + }; +} + +export function mercureSubscribe(hubURL: URL, topic: string) { + return (dispatch: TDispatch) => { + const eventSource = subscribe(hubURL, [topic]); + dispatch(mercureOpen(eventSource)); + eventSource.addEventListener('message', event => + dispatch(mercureMessage(normalize(JSON.parse(event.data)))) + ); + }; +} + +export function mercureOpen(eventSource: EventSource): IActionMercureOpen { + return { type: {{{uc}}}_UPDATE_MERCURE_OPEN, eventSource }; +} + +export function mercureMessage(retrieved: IResource | I{{{ucf}}}) { + return (dispatch: TDispatch) => { + if (1 === Object.keys(retrieved).length) { + dispatch({ type: {{{uc}}}_UPDATE_MERCURE_DELETED, retrieved }); + return; + } + + dispatch({ type: {{{uc}}}_UPDATE_MERCURE_MESSAGE, retrieved }); + }; +} diff --git a/templates/react-typescript/components/foo/Create.tsx b/templates/react-typescript/components/foo/Create.tsx new file mode 100644 index 00000000..35488702 --- /dev/null +++ b/templates/react-typescript/components/foo/Create.tsx @@ -0,0 +1,69 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { Link, Redirect } from 'react-router-dom'; +import { TError, TDispatch } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { create, reset } from '../../actions/{{{lc}}}/create'; +import { ICreateState } from '../../types/{{{lc}}}/create'; +import Form from './Form'; + +interface ICreateProps { + created: I{{{ucf}}} | null; + loading: boolean; + error: TError; +} + +interface ICreateActions { + reset: () => any; + create: ({{{lc}}}: Partial) => any; +} + +const mapStateToProps: (state: { {{{lc}}}: { create: ICreateState } }) => ICreateProps = state => { + const { created, error, loading } = state.{{{lc}}}.create; + return { created, error, loading }; +}; + +const mapDispatchToProps: (dispatch: TDispatch) => ICreateActions = dispatch => ({ + create: values => dispatch(create(values)), + reset: () => dispatch(reset()) +}); + +class Create extends Component { + componentWillUnmount() { + this.props.reset(); + } + + render() { + if (this.props.created) + return ( + + ); + + return ( +
+

New {{{title}}}

+ + {this.props.loading && ( +
+ Loading... +
+ )} + {this.props.error && ( +
+
+ )} + +
+ + Back to list + +
+ ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Create); diff --git a/templates/react-typescript/components/foo/Form.tsx b/templates/react-typescript/components/foo/Form.tsx new file mode 100644 index 00000000..141f9a9e --- /dev/null +++ b/templates/react-typescript/components/foo/Form.tsx @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; +import { Field, InjectedFormProps, reduxForm } from 'redux-form'; + +class Form extends Component { + renderField: React.FC = 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: string) => parseFloat(v)}{{/if}} + /> +{{/each}} + + + + ); + } +} + +export default reduxForm({ + form: '{{{lc}}}', + enableReinitialize: true, + keepDirtyOnReinitialize: true +})(Form); diff --git a/templates/react-typescript/components/foo/List.tsx b/templates/react-typescript/components/foo/List.tsx new file mode 100644 index 00000000..e1784dab --- /dev/null +++ b/templates/react-typescript/components/foo/List.tsx @@ -0,0 +1,179 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { RouteComponentProps } from 'react-router'; +import { Link } from 'react-router-dom'; +import { TError, TDispatch } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { IPagedCollection } from '../../interfaces/Collection'; +import { list, reset } from '../../actions/{{{lc}}}/list'; +import { IListState } from '../../types/{{{lc}}}/list'; + +interface IListProps { + retrieved: IPagedCollection | null; + loading: boolean; + error: TError; + eventSource: EventSource | null; +} + +interface IListActions { + list: (page?: string) => any; + reset: (eventSource: EventSource | null) => any; +} + +const mapStateToProps: (state: { {{{lc}}}: { list: IListState } }) => IListProps = state => { + const { + retrieved, + loading, + error, + eventSource + } = state.{{{lc}}}.list; + return { retrieved, loading, error, eventSource }; +}; + +const mapDispatchToProps: (dispatch: TDispatch) => IListActions = dispatch => ({ + list: page => dispatch(list(page)), + reset: eventSource => dispatch(reset(eventSource)) +}); + +type TListProps = RouteComponentProps & IListProps & IListActions; + +class List extends Component { + componentDidMount() { + this.props.list( + this.props.match.params.page && + decodeURIComponent(this.props.match.params.page) + ); + } + + componentWillReceiveProps(nextProps: TListProps) { + if (this.props.match.params.page !== nextProps.match.params.page) + nextProps.list( + nextProps.match.params.page && + decodeURIComponent(nextProps.match.params.page) + ); + } + + componentWillUnmount() { + this.props.reset(this.props.eventSource); + } + + render() { + return ( +
+

{{{title}}} List

+ + {this.props.loading && ( +
Loading...
+ )} + {this.props.error && ( +
{this.props.error}
+ )} + +

+ + Create + +

+ + + + + +{{#each fields}} + +{{/each}} + + + + {this.props.retrieved && this.props.retrieved['hydra:member'] && + this.props.retrieved['hydra:member'].map(item => ( + + +{{#each fields}} + +{{/each}} + + + + ))} + +
id{{name}} +
+ + {item['@id']} + + {{#if reference}}{this.renderLinks('{{{reference.name}}}', item['{{{name}}}'])}{{else}}{item['{{{name}}}']}{{/if}} + + + +
+ + {this.pagination()} +
+ ); + } + + pagination() { + const view = this.props.retrieved && this.props.retrieved['hydra:view']; + if (!view) return; + + const { + 'hydra:first': first, + 'hydra:previous': previous, + 'hydra:next': next, + 'hydra:last': last + } = view; + + return ( + + ); + } + + renderLinks = (type: string, items?: string | string[]) => { + if (!items) return null; + if (Array.isArray(items)) { + return items.map((item, i) => ( +
{this.renderLinks(type, item)}
+ )); + } + + return ( + {items} + ); + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(List); diff --git a/templates/react-typescript/components/foo/Show.tsx b/templates/react-typescript/components/foo/Show.tsx new file mode 100644 index 00000000..abf5a2d5 --- /dev/null +++ b/templates/react-typescript/components/foo/Show.tsx @@ -0,0 +1,143 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { RouteComponentProps } from 'react-router'; +import { Link, Redirect } from 'react-router-dom'; +import { TError, TDispatch } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { retrieve, reset } from '../../actions/{{{lc}}}/show'; +import { del } from '../../actions/{{{lc}}}/delete'; +import { IDeleteState } from '../../types/{{{lc}}}/delete'; +import { IShowState } from '../../types/{{{lc}}}/show'; + +interface IShowProps { + retrieved: I{{{ucf}}} | null; + loading: boolean; + error: TError; + eventSource: EventSource | null; + deleteError: TError; + deleteLoading: boolean; + deleted: I{{{ucf}}} | null; +} + +interface IShowActions { + retrieve: (id: string) => any; + reset: (eventSource: EventSource | null) => any; + del: ({{{lc}}}: I{{{ucf}}} | null) => any; +} + +type TStore = { + {{{lc}}}: { + show: IShowState, + del: IDeleteState, + } +} + +const mapStateToProps: (state: TStore) => IShowProps = 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: TDispatch) => IShowActions = dispatch => ({ + retrieve: id => dispatch(retrieve(id)), + del: item => item && dispatch(del(item)), + reset: eventSource => dispatch(reset(eventSource)) +}); + +type TShowProps = RouteComponentProps & IShowProps & IShowActions; + +class Show extends Component { + 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 {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}}{item['{{{name}}}']}{{/if}}
+ )} + + Back to list + + {item && ( + + + + )} + +
+ ); + } + + renderLinks = (type: string, items?: string | string[]) => { + if (!items) return null; + if (Array.isArray(items)) { + return items.map((item, i) => ( +
{this.renderLinks(type, item)}
+ )); + } + + return ( + + {items} + + ); + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Show); diff --git a/templates/react-typescript/components/foo/Update.tsx b/templates/react-typescript/components/foo/Update.tsx new file mode 100644 index 00000000..f76c8400 --- /dev/null +++ b/templates/react-typescript/components/foo/Update.tsx @@ -0,0 +1,142 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { RouteComponentProps } from 'react-router'; +import { Link, Redirect } from 'react-router-dom'; +import { TError, TDispatch } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import Form from './Form'; +import { retrieve, update, reset } from '../../actions/{{{lc}}}/update'; +import { del } from '../../actions/{{{lc}}}/delete'; +import { IUpdateState } from '../../types/{{{lc}}}/update'; +import { IDeleteState } from '../../types/{{{lc}}}/delete'; +import { ICreateState } from '../../types/{{{lc}}}/create'; + +interface IUpdateProps { + retrieved: I{{{ucf}}} | null; + retrieveLoading: boolean; + retrieveError: TError; + updateLoading: boolean; + updateError: TError; + deleteLoading: boolean; + deleteError: TError; + created: I{{{ucf}}} | null; + updated: I{{{ucf}}} | null; + deleted: I{{{ucf}}} | null; + eventSource: EventSource | null; +} + +interface IUpdateActions { + retrieve: (id: string) => any; + reset: (eventSource: EventSource | null) => any; + del: ({{{lc}}}: I{{{ucf}}} | null) => any; + update: ({{{lc}}}: I{{{ucf}}} | null, values: Partial) => any; +} + +type TStore = { + {{{lc}}}: { + update: IUpdateState, + del: IDeleteState, + create: ICreateState, + } +} + +const mapStateToProps: (state: TStore) => IUpdateProps = 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: TDispatch) => IUpdateActions = dispatch => ({ + retrieve: id => dispatch(retrieve(id)), + update: (item, values) => item && dispatch(update(item, values)), + del: item => item && dispatch(del(item)), + reset: eventSource => dispatch(reset(eventSource)) +}); + +type TUpdateProps = RouteComponentProps & IUpdateProps & IUpdateActions; + +class Update extends Component { + 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 {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 + + +
+ ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Update); diff --git a/templates/react-typescript/components/foo/index.tsx b/templates/react-typescript/components/foo/index.tsx new file mode 100644 index 00000000..e936e55e --- /dev/null +++ b/templates/react-typescript/components/foo/index.tsx @@ -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-typescript/interfaces/Collection.ts b/templates/react-typescript/interfaces/Collection.ts new file mode 100644 index 00000000..c5632712 --- /dev/null +++ b/templates/react-typescript/interfaces/Collection.ts @@ -0,0 +1,20 @@ +export interface IPagination { + '{{{hydraPrefix}}}first'?: string; + '{{{hydraPrefix}}}previous'?: string; + '{{{hydraPrefix}}}next'?: string; + '{{{hydraPrefix}}}last'?: string; +} + +export interface IPagedCollection { + '@context'?: string; + '@id'?: string; + '@type'?: string; + '{{{hydraPrefix}}}firstPage'?: string; + '{{{hydraPrefix}}}itemsPerPage'?: number; + '{{{hydraPrefix}}}lastPage'?: string; + '{{{hydraPrefix}}}member'?: T[]; + '{{{hydraPrefix}}}nextPage'?: string; + '{{{hydraPrefix}}}search'?: object; + '{{{hydraPrefix}}}totalItems'?: number; + '{{{hydraPrefix}}}view'?: IPagination; +} diff --git a/templates/react-typescript/interfaces/foo.ts b/templates/react-typescript/interfaces/foo.ts new file mode 100644 index 00000000..52ec64f0 --- /dev/null +++ b/templates/react-typescript/interfaces/foo.ts @@ -0,0 +1,8 @@ +import { IResource } from '../utils/types' + +export interface I{{{ucf}}} extends IResource { + id?: string; +{{#each fields}} + {{#if readonly}}readonly{{/if}} {{{name}}}?: {{{type}}}; +{{/each}} +} diff --git a/templates/react-typescript/reducers/foo/create.ts b/templates/react-typescript/reducers/foo/create.ts new file mode 100644 index 00000000..4d014477 --- /dev/null +++ b/templates/react-typescript/reducers/foo/create.ts @@ -0,0 +1,43 @@ +import { combineReducers } from 'redux'; +import { TError } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { + {{{uc}}}_CREATE_ERROR, + {{{uc}}}_CREATE_LOADING, + {{{uc}}}_CREATE_SUCCESS, + IActionError, + IActionLoading, + IActionSuccess +} from '../../types/{{{lc}}}/create'; + +export function error(state: TError = null, action: IActionError) { + switch (action.type) { + case {{{uc}}}_CREATE_ERROR: + return action.error; + + default: + return state; + } +} + +export function loading(state: boolean = false, action: IActionLoading) { + switch (action.type) { + case {{{uc}}}_CREATE_LOADING: + return action.loading; + + default: + return state; + } +} + +export function created(state: I{{{ucf}}} | null = null, action: IActionSuccess) { + switch (action.type) { + case {{{uc}}}_CREATE_SUCCESS: + return action.created; + + default: + return state; + } +} + +export default combineReducers({ error, loading, created }); diff --git a/templates/react-typescript/reducers/foo/delete.ts b/templates/react-typescript/reducers/foo/delete.ts new file mode 100644 index 00000000..df11f61c --- /dev/null +++ b/templates/react-typescript/reducers/foo/delete.ts @@ -0,0 +1,43 @@ +import { combineReducers } from 'redux'; +import { TError } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { + {{{uc}}}_DELETE_ERROR, + {{{uc}}}_DELETE_LOADING, + {{{uc}}}_DELETE_SUCCESS, + IActionError, + IActionLoading, + IActionSuccess +} from '../../types/{{{lc}}}/delete'; + +export function error(state: TError = null, action: IActionError) { + switch (action.type) { + case {{{uc}}}_DELETE_ERROR: + return action.error; + + default: + return state; + } +} + +export function loading(state: boolean = false, action: IActionLoading) { + switch (action.type) { + case {{{uc}}}_DELETE_LOADING: + return action.loading; + + default: + return state; + } +} + +export function deleted(state: I{{{ucf}}} | null = null, action: IActionSuccess) { + switch (action.type) { + case {{{uc}}}_DELETE_SUCCESS: + return action.deleted; + + default: + return state; + } +} + +export default combineReducers({ error, loading, deleted }); diff --git a/templates/react-typescript/reducers/foo/index.ts b/templates/react-typescript/reducers/foo/index.ts new file mode 100644 index 00000000..d57c1adb --- /dev/null +++ b/templates/react-typescript/reducers/foo/index.ts @@ -0,0 +1,8 @@ +import { combineReducers } from 'redux'; +import list from './list'; +import create from './create'; +import update from './update'; +import del from './delete'; +import show from './show'; + +export default combineReducers({ list, create, update, del, show }); diff --git a/templates/react-typescript/reducers/foo/list.ts b/templates/react-typescript/reducers/foo/list.ts new file mode 100644 index 00000000..2feb2fc2 --- /dev/null +++ b/templates/react-typescript/reducers/foo/list.ts @@ -0,0 +1,101 @@ +import { combineReducers } from 'redux'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { IPagedCollection } from '../../interfaces/Collection' +import { TError } from '../../utils/types' +import { + {{{uc}}}_LIST_ERROR, + {{{uc}}}_LIST_LOADING, + {{{uc}}}_LIST_MERCURE_DELETED, + {{{uc}}}_LIST_MERCURE_MESSAGE, + {{{uc}}}_LIST_MERCURE_OPEN, + {{{uc}}}_LIST_RESET, + {{{uc}}}_LIST_SUCCESS, + IActionError, + IActionLoading, + IActionSuccess, + IActionReset, + IActionMercureOpen, + IActionMercureDeleted, + IActionMercureMessage +} from '../../types/{{{lc}}}/list' + +export function error(state: TError = null, action: IActionError | IActionMercureDeleted | IActionReset) { + switch (action.type) { + case {{{uc}}}_LIST_ERROR: + return action.error; + + case {{{uc}}}_LIST_MERCURE_DELETED: + return `${action.retrieved['@id']} has been deleted by another user.`; + + case {{{uc}}}_LIST_RESET: + return null; + + default: + return state; + } +} + +export function loading(state: boolean = false, action: IActionLoading | IActionReset) { + switch (action.type) { + case {{{uc}}}_LIST_LOADING: + return action.loading; + + case {{{uc}}}_LIST_RESET: + return false; + + default: + return state; + } +} + +export function retrieved(state: IPagedCollection | null = null, action: IActionSuccess | IActionMercureMessage | IActionMercureDeleted | IActionReset) { + switch (action.type) { + case {{{uc}}}_LIST_SUCCESS: + return action.retrieved; + + case {{{uc}}}_LIST_RESET: + return null; + + case {{{uc}}}_LIST_MERCURE_MESSAGE: + if (!state || !Array.isArray(state['hydra:member'])) { + return state; + } + + return { + ...state, + 'hydra:member': state['hydra:member'].map(item => + item['@id'] === action.retrieved['@id'] ? action.retrieved : item + ) + }; + + case {{{uc}}}_LIST_MERCURE_DELETED: + if (!state || !Array.isArray(state['hydra:member'])) { + return state; + } + + return { + ...state, + 'hydra:member': state['hydra:member'].filter( + item => item['@id'] !== action.retrieved['@id'] + ) + }; + + default: + return state; + } +} + +export function eventSource(state: EventSource | null = null, action: IActionMercureOpen | IActionReset) { + switch (action.type) { + case {{{uc}}}_LIST_MERCURE_OPEN: + return action.eventSource; + + case {{{uc}}}_LIST_RESET: + return null; + + default: + return state; + } +} + +export default combineReducers({ error, loading, retrieved, eventSource }); diff --git a/templates/react-typescript/reducers/foo/show.ts b/templates/react-typescript/reducers/foo/show.ts new file mode 100644 index 00000000..e7218933 --- /dev/null +++ b/templates/react-typescript/reducers/foo/show.ts @@ -0,0 +1,77 @@ +import { combineReducers } from 'redux'; +import { TError } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { + {{{uc}}}_SHOW_ERROR, + {{{uc}}}_SHOW_LOADING, + {{{uc}}}_SHOW_SUCCESS, + {{{uc}}}_SHOW_MERCURE_DELETED, + {{{uc}}}_SHOW_MERCURE_MESSAGE, + {{{uc}}}_SHOW_MERCURE_OPEN, + {{{uc}}}_SHOW_RESET, + IActionError, + IActionLoading, + IActionSuccess, + IActionReset, + IActionMercureOpen, + IActionMercureDeleted, + IActionMercureMessage +} from '../../types/{{{lc}}}/show'; + +export function error(state: TError = null, action: IActionError | IActionMercureDeleted | IActionReset) { + switch (action.type) { + case {{{uc}}}_SHOW_ERROR: + return action.error; + + case {{{uc}}}_SHOW_MERCURE_DELETED: + return `${action.retrieved['@id']} has been deleted by another user.`; + + case {{{uc}}}_SHOW_RESET: + return null; + + default: + return state; + } +} + +export function loading(state: boolean = false, action: IActionLoading | IActionReset) { + switch (action.type) { + case {{{uc}}}_SHOW_LOADING: + return action.loading; + + case {{{uc}}}_SHOW_RESET: + return false; + + default: + return state; + } +} + +export function retrieved(state: I{{{ucf}}} | null = null, action: IActionSuccess | IActionMercureMessage | IActionReset) { + switch (action.type) { + case {{{uc}}}_SHOW_SUCCESS: + case {{{uc}}}_SHOW_MERCURE_MESSAGE: + return action.retrieved; + + case {{{uc}}}_SHOW_RESET: + return null; + + default: + return state; + } +} + +export function eventSource(state: EventSource | null = null, action: IActionReset | IActionMercureOpen) { + switch (action.type) { + case {{{uc}}}_SHOW_MERCURE_OPEN: + return action.eventSource; + + case {{{uc}}}_SHOW_RESET: + return null; + + default: + return state; + } +} + +export default combineReducers({ error, loading, retrieved, eventSource }); diff --git a/templates/react-typescript/reducers/foo/update.ts b/templates/react-typescript/reducers/foo/update.ts new file mode 100644 index 00000000..7eabea95 --- /dev/null +++ b/templates/react-typescript/reducers/foo/update.ts @@ -0,0 +1,130 @@ +import { combineReducers } from 'redux'; +import { TError } from '../../utils/types'; +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { + {{{uc}}}_UPDATE_MERCURE_DELETED, + {{{uc}}}_UPDATE_MERCURE_MESSAGE, + {{{uc}}}_UPDATE_MERCURE_OPEN, + {{{uc}}}_UPDATE_RESET, + {{{uc}}}_UPDATE_RETRIEVE_ERROR, + {{{uc}}}_UPDATE_RETRIEVE_LOADING, + {{{uc}}}_UPDATE_RETRIEVE_SUCCESS, + {{{uc}}}_UPDATE_UPDATE_ERROR, + {{{uc}}}_UPDATE_UPDATE_LOADING, + {{{uc}}}_UPDATE_UPDATE_SUCCESS, + IActionRetrieveError, + IActionRetrieveLoading, + IActionRetrieveSuccess, + IActionUpdateError, + IActionUpdateLoading, + IActionUpdateSuccess, + IActionReset, + IActionMercureOpen, + IActionMercureDeleted, + IActionMercureMessage +} from '../../types/{{{lc}}}/update'; + +export function retrieveError(state: TError = null, action: IActionRetrieveError | IActionMercureDeleted | IActionReset) { + switch (action.type) { + case {{{uc}}}_UPDATE_RETRIEVE_ERROR: + return action.retrieveError; + + case {{{uc}}}_UPDATE_MERCURE_DELETED: + return `${action.retrieved['@id']} has been deleted by another user.`; + + case {{{uc}}}_UPDATE_RESET: + return null; + + default: + return state; + } +} + +export function retrieveLoading(state: boolean = false, action: IActionRetrieveLoading | IActionReset) { + switch (action.type) { + case {{{uc}}}_UPDATE_RETRIEVE_LOADING: + return action.retrieveLoading; + + case {{{uc}}}_UPDATE_RESET: + return false; + + default: + return state; + } +} + +export function retrieved(state: I{{{ucf}}} | null = null, action: IActionRetrieveSuccess | IActionMercureMessage | IActionReset) { + switch (action.type) { + case {{{uc}}}_UPDATE_RETRIEVE_SUCCESS: + case {{{uc}}}_UPDATE_MERCURE_MESSAGE: + return action.retrieved; + + case {{{uc}}}_UPDATE_RESET: + return null; + + default: + return state; + } +} + +export function updateError(state: TError = null, action: IActionUpdateError | IActionReset) { + switch (action.type) { + case {{{uc}}}_UPDATE_UPDATE_ERROR: + return action.updateError; + + case {{{uc}}}_UPDATE_RESET: + return null; + + default: + return state; + } +} + +export function updateLoading(state: boolean = false, action: IActionUpdateLoading | IActionReset) { + switch (action.type) { + case {{{uc}}}_UPDATE_UPDATE_LOADING: + return action.updateLoading; + + case {{{uc}}}_UPDATE_RESET: + return false; + + default: + return state; + } +} + +export function updated(state: I{{{ucf}}} | null = null, action: IActionUpdateSuccess | IActionReset) { + switch (action.type) { + case {{{uc}}}_UPDATE_UPDATE_SUCCESS: + return action.updated; + + case {{{uc}}}_UPDATE_RESET: + return null; + + default: + return state; + } +} + +export function eventSource(state: EventSource | null = null, action: IActionMercureOpen | IActionReset) { + switch (action.type) { + case {{{uc}}}_UPDATE_MERCURE_OPEN: + return action.eventSource; + + case {{{uc}}}_UPDATE_RESET: + return null; + + default: + return state; + } +} + +export default combineReducers({ + retrieveError, + retrieveLoading, + retrieved, + updateError, + updateLoading, + updated, + eventSource +}); diff --git a/templates/react-typescript/routes/foo.tsx b/templates/react-typescript/routes/foo.tsx new file mode 100644 index 00000000..c9d1306b --- /dev/null +++ b/templates/react-typescript/routes/foo.tsx @@ -0,0 +1,11 @@ +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-typescript/types/foo/create.ts b/templates/react-typescript/types/foo/create.ts new file mode 100644 index 00000000..67eebddc --- /dev/null +++ b/templates/react-typescript/types/foo/create.ts @@ -0,0 +1,26 @@ +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { TError } from '../../utils/types'; + +export const {{{uc}}}_CREATE_ERROR = '{{{uc}}}_CREATE_ERROR'; +export interface IActionError { + type: typeof {{{uc}}}_CREATE_ERROR; + error: TError; +} + +export const {{{uc}}}_CREATE_LOADING = '{{{uc}}}_CREATE_LOADING'; +export interface IActionLoading { + type: typeof {{{uc}}}_CREATE_LOADING; + loading: boolean; +} + +export const {{{uc}}}_CREATE_SUCCESS = '{{{uc}}}_CREATE_SUCCESS'; +export interface IActionSuccess { + type: typeof {{{uc}}}_CREATE_SUCCESS; + created: I{{{ucf}}} | null; +} + +export interface ICreateState { + error: TError; + loading: boolean; + created: I{{{ucf}}} | null; +} diff --git a/templates/react-typescript/types/foo/delete.ts b/templates/react-typescript/types/foo/delete.ts new file mode 100644 index 00000000..eea9dda9 --- /dev/null +++ b/templates/react-typescript/types/foo/delete.ts @@ -0,0 +1,26 @@ +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { TError } from '../../utils/types'; + +export const {{{uc}}}_DELETE_ERROR = '{{{uc}}}_DELETE_ERROR'; +export interface IActionError { + type: typeof {{{uc}}}_DELETE_ERROR; + error: TError; +} + +export const {{{uc}}}_DELETE_LOADING = '{{{uc}}}_DELETE_LOADING'; +export interface IActionLoading { + type: typeof {{{uc}}}_DELETE_LOADING; + loading: boolean; +} + +export const {{{uc}}}_DELETE_SUCCESS = '{{{uc}}}_DELETE_SUCCESS'; +export interface IActionSuccess { + type: typeof {{{uc}}}_DELETE_SUCCESS; + deleted: I{{{ucf}}} | null; +} + +export interface IDeleteState { + error: TError; + loading: boolean; + deleted: I{{{ucf}}} | null; +} diff --git a/templates/react-typescript/types/foo/list.ts b/templates/react-typescript/types/foo/list.ts new file mode 100644 index 00000000..795f3b5c --- /dev/null +++ b/templates/react-typescript/types/foo/list.ts @@ -0,0 +1,51 @@ +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { IPagedCollection } from '../../interfaces/Collection'; +import { TError, IResource } from '../../utils/types'; + +export const {{{uc}}}_LIST_ERROR = '{{{uc}}}_LIST_ERROR'; +export interface IActionError { + type: typeof {{{uc}}}_LIST_ERROR; + error: TError; +} + +export const {{{uc}}}_LIST_LOADING = '{{{uc}}}_LIST_LOADING'; +export interface IActionLoading { + type: typeof {{{uc}}}_LIST_LOADING; + loading: boolean; +} + +export const {{{uc}}}_LIST_SUCCESS = '{{{uc}}}_LIST_SUCCESS'; +export interface IActionSuccess { + type: typeof {{{uc}}}_LIST_SUCCESS; + retrieved: IPagedCollection; +} + +export const {{{uc}}}_LIST_RESET = '{{{uc}}}_LIST_RESET'; +export interface IActionReset { + type: typeof {{{uc}}}_LIST_RESET; +} + +export const {{{uc}}}_LIST_MERCURE_OPEN = '{{{uc}}}_LIST_MERCURE_OPEN'; +export interface IActionMercureOpen { + type: typeof {{{uc}}}_LIST_MERCURE_OPEN; + eventSource: EventSource; +} + +export const {{{uc}}}_LIST_MERCURE_DELETED = '{{{uc}}}_LIST_MERCURE_DELETED'; +export interface IActionMercureDeleted { + type: typeof {{{uc}}}_LIST_MERCURE_DELETED; +retrieved: IResource; +} + +export const {{{uc}}}_LIST_MERCURE_MESSAGE = '{{{uc}}}_LIST_MERCURE_MESSAGE'; +export interface IActionMercureMessage { + type: typeof {{{uc}}}_LIST_MERCURE_MESSAGE; + retrieved: I{{{ucf}}}; +} + +export interface IListState { + error: TError; + loading: boolean; + retrieved: IPagedCollection | null; + eventSource: EventSource | null; +} diff --git a/templates/react-typescript/types/foo/show.ts b/templates/react-typescript/types/foo/show.ts new file mode 100644 index 00000000..86c5f892 --- /dev/null +++ b/templates/react-typescript/types/foo/show.ts @@ -0,0 +1,50 @@ +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { TError, IResource } from '../../utils/types'; + +export const {{{uc}}}_SHOW_ERROR = '{{{uc}}}_SHOW_ERROR'; +export interface IActionError { + type: typeof {{{uc}}}_SHOW_ERROR; + error: TError; +} + +export const {{{uc}}}_SHOW_LOADING = '{{{uc}}}_SHOW_LOADING'; +export interface IActionLoading { + type: typeof {{{uc}}}_SHOW_LOADING; + loading: boolean; +} + +export const {{{uc}}}_SHOW_SUCCESS = '{{{uc}}}_SHOW_SUCCESS'; +export interface IActionSuccess { + type: typeof {{{uc}}}_SHOW_SUCCESS; + retrieved: I{{{ucf}}}; +} + +export const {{{uc}}}_SHOW_RESET = '{{{uc}}}_SHOW_RESET'; +export interface IActionReset { + type: typeof {{{uc}}}_SHOW_RESET; +} + +export const {{{uc}}}_SHOW_MERCURE_OPEN = '{{{uc}}}_SHOW_MERCURE_OPEN'; +export interface IActionMercureOpen { + type: typeof {{{uc}}}_SHOW_MERCURE_OPEN; + eventSource: EventSource; +} + +export const {{{uc}}}_SHOW_MERCURE_DELETED = '{{{uc}}}_SHOW_MERCURE_DELETED'; +export interface IActionMercureDeleted { + type: typeof {{{uc}}}_SHOW_MERCURE_DELETED; + retrieved: IResource; +} + +export const {{{uc}}}_SHOW_MERCURE_MESSAGE = '{{{uc}}}_SHOW_MERCURE_MESSAGE'; +export interface IActionMercureMessage { + type: typeof {{{uc}}}_SHOW_MERCURE_MESSAGE; + retrieved: I{{{ucf}}}; +} + +export interface IShowState { + error: TError; + loading: boolean; + retrieved: I{{{ucf}}} | null; + eventSource: EventSource | null; +} diff --git a/templates/react-typescript/types/foo/update.ts b/templates/react-typescript/types/foo/update.ts new file mode 100644 index 00000000..4f6e1d12 --- /dev/null +++ b/templates/react-typescript/types/foo/update.ts @@ -0,0 +1,71 @@ +import { I{{{ucf}}} } from '../../interfaces/{{{ucf}}}'; +import { TError, IResource } from '../../utils/types'; + +export const {{{uc}}}_UPDATE_RETRIEVE_ERROR = '{{{uc}}}_UPDATE_RETRIEVE_ERROR'; +export interface IActionRetrieveError { + type: typeof {{{uc}}}_UPDATE_RETRIEVE_ERROR; + retrieveError: TError; +} + +export const {{{uc}}}_UPDATE_RETRIEVE_LOADING = '{{{uc}}}_UPDATE_RETRIEVE_LOADING'; +export interface IActionRetrieveLoading { + type: typeof {{{uc}}}_UPDATE_RETRIEVE_LOADING; + retrieveLoading: boolean; +} + +export const {{{uc}}}_UPDATE_RETRIEVE_SUCCESS = '{{{uc}}}_UPDATE_RETRIEVE_SUCCESS'; +export interface IActionRetrieveSuccess { + type: typeof {{{uc}}}_UPDATE_RETRIEVE_SUCCESS; + retrieved: I{{{ucf}}}; +} + +export const {{{uc}}}_UPDATE_UPDATE_ERROR = '{{{uc}}}_UPDATE_UPDATE_ERROR'; +export interface IActionUpdateError { + type: typeof {{{uc}}}_UPDATE_UPDATE_ERROR; + updateError: TError; +} + +export const {{{uc}}}_UPDATE_UPDATE_LOADING = '{{{uc}}}_UPDATE_UPDATE_LOADING'; +export interface IActionUpdateLoading { + type: typeof {{{uc}}}_UPDATE_UPDATE_LOADING; + updateLoading: boolean; +} + +export const {{{uc}}}_UPDATE_UPDATE_SUCCESS = '{{{uc}}}_UPDATE_UPDATE_SUCCESS'; +export interface IActionUpdateSuccess { + type: typeof {{{uc}}}_UPDATE_UPDATE_SUCCESS; + updated: I{{{ucf}}}; +} + +export const {{{uc}}}_UPDATE_RESET = '{{{uc}}}_UPDATE_RESET'; +export interface IActionReset { + type: typeof {{{uc}}}_UPDATE_RESET; +} + +export const {{{uc}}}_UPDATE_MERCURE_OPEN = '{{{uc}}}_UPDATE_MERCURE_OPEN'; +export interface IActionMercureOpen { + type: typeof {{{uc}}}_UPDATE_MERCURE_OPEN; + eventSource: EventSource; +} + +export const {{{uc}}}_UPDATE_MERCURE_DELETED = '{{{uc}}}_UPDATE_MERCURE_DELETED'; +export interface IActionMercureDeleted { + type: typeof {{{uc}}}_UPDATE_MERCURE_DELETED; + retrieved: IResource; +} + +export const {{{uc}}}_UPDATE_MERCURE_MESSAGE = '{{{uc}}}_UPDATE_MERCURE_MESSAGE'; +export interface IActionMercureMessage { + type: typeof {{{uc}}}_UPDATE_MERCURE_MESSAGE; + retrieved: I{{{ucf}}}; +} + +export interface IUpdateState { + retrieveError: TError; + retrieveLoading: boolean; + retrieved: I{{{ucf}}} | null; + updateError: TError; + updateLoading: boolean; + updated: I{{{ucf}}} | null; + eventSource: EventSource | null; +} diff --git a/templates/react-typescript/utils/dataAccess.ts b/templates/react-typescript/utils/dataAccess.ts new file mode 100644 index 00000000..098a631e --- /dev/null +++ b/templates/react-typescript/utils/dataAccess.ts @@ -0,0 +1,90 @@ +import { ENTRYPOINT } from '../config/entrypoint'; +import { SubmissionError } from 'redux-form'; + +const MIME_TYPE = 'application/ld+json'; + +export function fetchApi (id: string, options: RequestInit = {}) { + if (!(options.headers instanceof Headers)) options.headers = new Headers(options.headers); + if (null === options.headers.get('Accept')) + options.headers.set('Accept', MIME_TYPE); + + if ( + 'undefined' !== options.body && + !(options.body instanceof FormData) && + null === options.headers.get('Content-Type') + ) + options.headers.set('Content-Type', MIME_TYPE); + + return fetch(String(new URL(id, ENTRYPOINT)), options).then(response => { + if (response.ok) return response; + + return response.json().then( + json => { + const error = + json['hydra:description'] || + json['hydra:title'] || + 'An error occurred.'; + if (!json.violations) throw Error(error); + + 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 + }, {_error: error} as {_error: string; [key: string]: string; }); + + throw new SubmissionError(errors); + }, + () => { + throw new Error(response.statusText || 'An error occurred.'); + } + ); + }); +} + +export function mercureSubscribe(url: URL, topics: string[]) { + topics.forEach(topic => + url.searchParams.append('topic', String(new URL(topic, ENTRYPOINT))) + ); + + return new EventSource(url.toString()); +} + +export function normalize(data: A) { + if (data.hasOwnProperty('hydra:member') && data['hydra:member']) { + // Normalize items in collections + data['hydra:member'] = data['hydra:member'].map(item => normalize(item)); + + return data; + } + + // Flatten nested documents + return Object + .entries(data) + .reduce( + (a, [key, value]) => { + a[key] = Array.isArray(value) + ? value.map(v => v && v.hasOwnProperty('@id') ? v['@id'] : v) + : value && value.hasOwnProperty('@id') ? value['@id'] : value + + return a + }, + {} as any, + ); +} + +export function extractHubURL(response: Response) { + 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; +} diff --git a/templates/react-typescript/utils/types.ts b/templates/react-typescript/utils/types.ts new file mode 100644 index 00000000..dd103344 --- /dev/null +++ b/templates/react-typescript/utils/types.ts @@ -0,0 +1,10 @@ +import { AnyAction } from 'redux'; +import { ThunkDispatch as Dispatch } from 'redux-thunk'; + +export type TError = Error | string | null; + +export type TDispatch = Dispatch<{}, {}, AnyAction>; + +export interface IResource { + '@id': string; +}