From 4645fb57ad8a9b443321677ba3cd3fe9c2e8cb33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kr=C3=BCger?= Date: Wed, 27 Mar 2024 15:27:12 +0100 Subject: [PATCH 1/3] refactor: move extraInformation to meta (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: move extraInformation to meta The *Params datatypes have a meta field that can be filled with any value as necesarry for the dataprovider, see UpdateParams here https://github.com/marmelab/react-admin/blob/master/packages/ra-core/src/types.ts#L189-L194 It's better to use this value instead of polluting the data object * remove passing unnecessary tranport parameter --------- Co-authored-by: Paweł Suwiński --- src/CreateGuesser.tsx | 3 ++- src/EditGuesser.tsx | 7 ++----- src/hydra/dataProvider.test.ts | 12 ++++++------ src/hydra/dataProvider.ts | 5 ++--- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/CreateGuesser.tsx b/src/CreateGuesser.tsx index 4faacded..4ecff21d 100644 --- a/src/CreateGuesser.tsx +++ b/src/CreateGuesser.tsx @@ -98,7 +98,8 @@ export const IntrospectedCreateGuesser = ({ const response = await create( resource, { - data: { ...data, extraInformation: { hasFileField } }, + data, + meta: { hasFileField }, }, { returnPromise: true }, ); diff --git a/src/EditGuesser.tsx b/src/EditGuesser.tsx index 7c21cb47..af018f3f 100644 --- a/src/EditGuesser.tsx +++ b/src/EditGuesser.tsx @@ -122,7 +122,8 @@ export const IntrospectedEditGuesser = ({ resource, { id, - data: { ...data, extraInformation: { hasFileField } }, + data, + meta: { hasFileField }, }, { returnPromise: true }, ); @@ -199,10 +200,6 @@ export const IntrospectedEditGuesser = ({ mutationMode={mutationMode} redirect={redirectTo} component={viewComponent} - transform={(data: Partial) => ({ - ...data, - extraInformation: { hasFileField }, - })} {...props}> { data: { bar: 'baz', foo: 'foo', - extraInformation: { - hasFileField: true, - }, + }, + meta: { + hasFileField: true, }, }); const url = mockFetchHydra.mock.calls?.[0]?.[0] ?? new URL('https://foo'); @@ -360,9 +360,9 @@ describe('Transform a React Admin request to an Hydra request', () => { foo: 'foo', bar: 'baz', qux: null, - extraInformation: { - hasFileField: true, - }, + }, + meta: { + hasFileField: true, }, previousData: { id: '/entrypoint/resource/1', diff --git a/src/hydra/dataProvider.ts b/src/hydra/dataProvider.ts index 3ece47db..55479610 100644 --- a/src/hydra/dataProvider.ts +++ b/src/hydra/dataProvider.ts @@ -351,9 +351,8 @@ function dataProvider( } }); let extraInformation: { hasFileField?: boolean } = {}; - if ('data' in params && params.data.extraInformation) { - extraInformation = params.data.extraInformation; - delete params.data.extraInformation; + if ('meta' in params) { + extraInformation = params.meta; } const updateHttpMethod = extraInformation.hasFileField ? 'POST' : 'PUT'; From 1d4e833e0559f8e20488455ceb614448a962ee98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Suwi=C5=84ski?= Date: Fri, 5 Apr 2024 08:01:28 +0200 Subject: [PATCH 2/3] useOnSubmit hook for Create/ EditGuessers (#542) * useOnSubmit hook * switch to useOnSubmit hook * export useOnSubmit * fix isCreate cond, tests --- src/CreateGuesser.tsx | 100 +++-------------------------- src/EditGuesser.tsx | 124 +++-------------------------------- src/index.ts | 2 + src/types.ts | 7 ++ src/useOnSubmit.test.tsx | 119 ++++++++++++++++++++++++++++++++++ src/useOnSubmit.ts | 135 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 204 deletions(-) create mode 100644 src/useOnSubmit.test.tsx create mode 100644 src/useOnSubmit.ts diff --git a/src/CreateGuesser.tsx b/src/CreateGuesser.tsx index 4ecff21d..40a7e4c6 100644 --- a/src/CreateGuesser.tsx +++ b/src/CreateGuesser.tsx @@ -1,23 +1,18 @@ -import React, { useCallback } from 'react'; -import type { PropsWithChildren, ReactNode } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { Create, - FileInput, FormTab, SimpleForm, TabbedForm, - useCreate, - useNotify, - useRedirect, useResourceContext, } from 'react-admin'; -import type { HttpError, RaRecord } from 'react-admin'; import type { Field, Resource } from '@api-platform/api-doc-parser'; import InputGuesser from './InputGuesser.js'; import Introspecter from './Introspecter.js'; import useDisplayOverrideCode from './useDisplayOverrideCode.js'; +import useOnSubmit from './useOnSubmit.js'; import type { CreateGuesserProps, IntrospectedCreateGuesserProps, @@ -63,9 +58,14 @@ export const IntrospectedCreateGuesser = ({ children, ...props }: IntrospectedCreateGuesserProps) => { - const [create] = useCreate(); - const notify = useNotify(); - const redirect = useRedirect(); + const save = useOnSubmit({ + resource, + schemaAnalyzer, + fields, + mutationOptions, + transform, + redirectTo, + }); const displayOverrideCode = useDisplayOverrideCode(); @@ -77,86 +77,6 @@ export const IntrospectedCreateGuesser = ({ displayOverrideCode(getOverrideCode(schema, writableFields)); } - const hasFileFieldElement = (elements: Array): boolean => - elements.some( - (child) => - React.isValidElement(child) && - (child.type === FileInput || - hasFileFieldElement( - React.Children.toArray((child.props as PropsWithChildren).children), - )), - ); - const hasFileField = hasFileFieldElement(inputChildren); - - const save = useCallback( - async (values: Partial) => { - let data = values; - if (transform) { - data = transform(values); - } - try { - const response = await create( - resource, - { - data, - meta: { hasFileField }, - }, - { returnPromise: true }, - ); - const success = - mutationOptions?.onSuccess ?? - ((newRecord: RaRecord) => { - notify('ra.notification.created', { - type: 'info', - messageArgs: { smart_count: 1 }, - }); - redirect(redirectTo, resource, newRecord.id, newRecord); - }); - success(response, { data: response }, {}); - return undefined; - } catch (mutateError) { - const submissionErrors = schemaAnalyzer.getSubmissionErrors( - mutateError as HttpError, - ); - const failure = - mutationOptions?.onError ?? - ((error: string | Error) => { - let message = 'ra.notification.http_error'; - if (!submissionErrors) { - message = - typeof error === 'string' ? error : error.message || message; - } - let errorMessage; - if (typeof error === 'string') { - errorMessage = error; - } else if (error?.message) { - errorMessage = error.message; - } - notify(message, { - type: 'warning', - messageArgs: { _: errorMessage }, - }); - }); - failure(mutateError as string | Error, { data: values }, {}); - if (submissionErrors) { - return submissionErrors; - } - return {}; - } - }, - [ - create, - hasFileField, - resource, - mutationOptions, - notify, - redirect, - redirectTo, - schemaAnalyzer, - transform, - ], - ); - const hasFormTab = inputChildren.some( (child) => typeof child === 'object' && 'type' in child && child.type === FormTab, diff --git a/src/EditGuesser.tsx b/src/EditGuesser.tsx index af018f3f..5f7ffd4b 100644 --- a/src/EditGuesser.tsx +++ b/src/EditGuesser.tsx @@ -1,26 +1,20 @@ -import React, { useCallback } from 'react'; -import type { PropsWithChildren, ReactNode } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { Edit, - FileInput, FormTab, SimpleForm, TabbedForm, - useNotify, - useRedirect, useResourceContext, - useUpdate, } from 'react-admin'; -import type { HttpError, RaRecord } from 'react-admin'; import { useParams } from 'react-router-dom'; import type { Field, Resource } from '@api-platform/api-doc-parser'; import InputGuesser from './InputGuesser.js'; import Introspecter from './Introspecter.js'; -import getIdentifierValue from './getIdentifierValue.js'; import useMercureSubscription from './useMercureSubscription.js'; import useDisplayOverrideCode from './useDisplayOverrideCode.js'; +import useOnSubmit from './useOnSubmit.js'; import type { EditGuesserProps, IntrospectedEditGuesserProps, @@ -69,12 +63,16 @@ export const IntrospectedEditGuesser = ({ }: IntrospectedEditGuesserProps) => { const { id: routeId } = useParams<'id'>(); const id = decodeURIComponent(routeId ?? ''); + const save = useOnSubmit({ + resource, + schemaAnalyzer, + fields, + mutationOptions, + transform, + redirectTo, + }); useMercureSubscription(resource, id); - const [update] = useUpdate(); - const notify = useNotify(); - const redirect = useRedirect(); - const displayOverrideCode = useDisplayOverrideCode(); let inputChildren = React.Children.toArray(children); @@ -84,108 +82,6 @@ export const IntrospectedEditGuesser = ({ )); displayOverrideCode(getOverrideCode(schema, writableFields)); } - const hasFileFieldElement = (elements: Array): boolean => - elements.some( - (child) => - React.isValidElement(child) && - (child.type === FileInput || - hasFileFieldElement( - React.Children.toArray((child.props as PropsWithChildren).children), - )), - ); - const hasFileField = hasFileFieldElement(inputChildren); - - const save = useCallback( - async (values: Partial) => { - if (id === undefined) { - return undefined; - } - let data = values; - if (transform) { - data = transform(values); - } - // Identifiers need to be formatted in case they have not been modified in the form. - Object.entries(values).forEach(([key, value]) => { - const identifierValue = getIdentifierValue( - schemaAnalyzer, - resource, - fields, - key, - value, - ); - if (identifierValue !== value) { - data[key] = identifierValue; - } - }); - try { - const response = await update( - resource, - { - id, - data, - meta: { hasFileField }, - }, - { returnPromise: true }, - ); - const success = - mutationOptions?.onSuccess ?? - ((updatedRecord: RaRecord) => { - notify('ra.notification.updated', { - type: 'info', - messageArgs: { smart_count: 1 }, - }); - redirect(redirectTo, resource, updatedRecord.id, updatedRecord); - }); - success(response, { id, data: response, previousData: values }, {}); - return undefined; - } catch (mutateError) { - const submissionErrors = schemaAnalyzer.getSubmissionErrors( - mutateError as HttpError, - ); - const failure = - mutationOptions?.onError ?? - ((error: string | Error) => { - let message = 'ra.notification.http_error'; - if (!submissionErrors) { - message = - typeof error === 'string' ? error : error.message || message; - } - let errorMessage; - if (typeof error === 'string') { - errorMessage = error; - } else if (error?.message) { - errorMessage = error.message; - } - notify(message, { - type: 'warning', - messageArgs: { _: errorMessage }, - }); - }); - failure( - mutateError as string | Error, - { id, data: values, previousData: values }, - {}, - ); - if (submissionErrors) { - return submissionErrors; - } - return {}; - } - }, - [ - fields, - hasFileField, - id, - mutationOptions, - notify, - redirect, - redirectTo, - resource, - schemaAnalyzer, - transform, - update, - ], - ); const hasFormTab = inputChildren.some( (child) => diff --git a/src/index.ts b/src/index.ts index 973f5c71..5232bafa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import ShowGuesser from './ShowGuesser.js'; import useIntrospect from './useIntrospect.js'; import useIntrospection from './useIntrospection.js'; import useMercureSubscription from './useMercureSubscription.js'; +import useOnSubmit from './useOnSubmit.js'; export { AdminGuesser, @@ -26,6 +27,7 @@ export { useIntrospect, useIntrospection, useMercureSubscription, + useOnSubmit, }; export { HydraAdmin, diff --git a/src/types.ts b/src/types.ts index 3a619a9c..c8a8023a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -504,3 +504,10 @@ export type IntrospecterProps = ( | ShowGuesserProps ) & BaseIntrospecterProps; + +export type UseOnSubmitProps = Pick< + IntrospectedGuesserProps, + 'schemaAnalyzer' | 'resource' | 'fields' +> & + Pick & + PickRename; diff --git a/src/useOnSubmit.test.tsx b/src/useOnSubmit.test.tsx new file mode 100644 index 00000000..3d1343e8 --- /dev/null +++ b/src/useOnSubmit.test.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { jest } from '@jest/globals'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { render, waitFor } from '@testing-library/react'; +import type { CreateResult, RaRecord, UpdateResult } from 'react-admin'; +import { DataProviderContext, testDataProvider } from 'react-admin'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import useOnSubmit from './useOnSubmit.js'; +import schemaAnalyzer from './hydra/schemaAnalyzer.js'; +import { API_FIELDS_DATA } from './__fixtures__/parsedData.js'; + +const dataProvider = testDataProvider({ + create: jest.fn(() => Promise.resolve({ data: { id: 1 } } as CreateResult)), + update: jest.fn(() => Promise.resolve({ data: { id: 1 } } as UpdateResult)), +}); + +const onSubmitProps = { + fields: API_FIELDS_DATA, + resource: 'books', + schemaAnalyzer: schemaAnalyzer(), +}; + +jest.mock('./getIdentifierValue.js'); + +test.each([ + { + name: 'Book name 1', + authors: ['Author 1', 'Author 2'], + cover: { rawFile: new File(['content'], 'cover.png') }, + }, + { + name: 'Book name 2', + authors: ['Author 1', 'Author 2'], + covers: [ + { rawFile: new File(['content1'], 'cover1.png') }, + { rawFile: new File(['content2'], 'cover2.png') }, + ], + }, +])( + 'Call create with file input ($name)', + async (values: Omit) => { + let save; + const Dummy = () => { + const onSubmit = useOnSubmit(onSubmitProps); + save = onSubmit; + return ; + }; + render( + + + + + } /> + } /> + } /> + + + + , + ); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + save(values); + await waitFor(() => { + expect(dataProvider.create).toHaveBeenCalledWith('books', { + data: values, + meta: { + hasFileField: true, + }, + previousData: undefined, + }); + }); + }, +); + +test.each([ + { + id: '1', + name: 'Book name 1', + authors: ['Author 1', 'Author 2'], + }, + { + id: '2', + name: 'Book name 2', + authors: ['Author 1', 'Author 2'], + }, +])('Call update without file inputs ($name)', async (values: RaRecord) => { + let save; + const Dummy = () => { + const onSubmit = useOnSubmit(onSubmitProps); + save = onSubmit; + return ; + }; + render( + + + + + } /> + } /> + + + + , + ); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + save(values); + await waitFor(() => { + expect(dataProvider.update).toHaveBeenCalledWith('books', { + id: values.id, + data: values, + meta: { + hasFileField: false, + }, + previousData: undefined, + }); + }); +}); diff --git a/src/useOnSubmit.ts b/src/useOnSubmit.ts new file mode 100644 index 00000000..0313b44b --- /dev/null +++ b/src/useOnSubmit.ts @@ -0,0 +1,135 @@ +import { useCallback } from 'react'; +import { useCreate, useNotify, useRedirect, useUpdate } from 'react-admin'; +import type { HttpError, RaRecord } from 'react-admin'; +import { useParams } from 'react-router-dom'; +import getIdentifierValue from './getIdentifierValue.js'; +import type { SubmissionErrors, UseOnSubmitProps } from './types.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const findFile = (values: any[]): object | undefined => + values.find((value) => + Array.isArray(value) + ? findFile(value) + : typeof value === 'object' && value.rawFile instanceof File, + ); + +const useOnSubmit = ({ + resource, + schemaAnalyzer, + fields, + mutationOptions, + transform, + redirectTo = 'list', +}: UseOnSubmitProps): (( + values: Partial, +) => Promise) => { + const { id: routeId } = useParams<'id'>(); + const id = decodeURIComponent(routeId ?? ''); + const [create] = useCreate(); + const [update] = useUpdate(); + const notify = useNotify(); + const redirect = useRedirect(); + + return useCallback( + async (values: Partial) => { + const isCreate = id === ''; + const data = transform ? transform(values) : values; + + // Identifiers need to be formatted in case they have not been modified in the form. + if (!isCreate) { + Object.entries(values).forEach(([key, value]) => { + const identifierValue = getIdentifierValue( + schemaAnalyzer, + resource, + fields, + key, + value, + ); + if (identifierValue !== value) { + data[key] = identifierValue; + } + }); + } + try { + const response = await (isCreate ? create : update)( + resource, + { + ...(isCreate ? {} : { id }), + data, + meta: { hasFileField: !!findFile(Object.values(values)) }, + }, + { returnPromise: true }, + ); + const success = + mutationOptions?.onSuccess ?? + ((record: RaRecord) => { + notify('ra.notification.updated', { + type: 'info', + messageArgs: { smart_count: 1 }, + }); + redirect(redirectTo, resource, record.id, record); + }); + success( + response, + { + data: response, + ...(isCreate ? {} : { id, previousData: values }), + }, + {}, + ); + + return undefined; + } catch (mutateError) { + const submissionErrors = schemaAnalyzer.getSubmissionErrors( + mutateError as HttpError, + ); + const failure = + mutationOptions?.onError ?? + ((error: string | Error) => { + let message = 'ra.notification.http_error'; + if (!submissionErrors) { + message = + typeof error === 'string' ? error : error.message || message; + } + let errorMessage; + if (typeof error === 'string') { + errorMessage = error; + } else if (error?.message) { + errorMessage = error.message; + } + notify(message, { + type: 'warning', + messageArgs: { _: errorMessage }, + }); + }); + failure( + mutateError as string | Error, + { + data: values, + ...(isCreate ? {} : { id, previousData: values }), + }, + {}, + ); + if (submissionErrors) { + return submissionErrors; + } + return {}; + } + }, + [ + fields, + id, + mutationOptions, + notify, + redirect, + redirectTo, + resource, + schemaAnalyzer, + transform, + create, + update, + ], + ); +}; + +export default useOnSubmit; From 5f80c05577413af5037baaf4a9619785780912a4 Mon Sep 17 00:00:00 2001 From: Andriy Oprysko Date: Fri, 5 Apr 2024 14:24:46 +0300 Subject: [PATCH 3/3] fix: make it possible to pass all supported props to AdminContext from AdminGuesser (#545) Thanks! --- src/AdminGuesser.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/AdminGuesser.tsx b/src/AdminGuesser.tsx index 5c335fc9..1f3b42bd 100644 --- a/src/AdminGuesser.tsx +++ b/src/AdminGuesser.tsx @@ -113,8 +113,13 @@ const AdminGuesser = ({ // Props for AdminResourcesGuesser includeDeprecated = false, // Admin props + basename, + store, dataProvider, i18nProvider, + authProvider, + queryClient, + defaultTheme, layout = Layout, loginPage = LoginPage, loading: loadingPage, @@ -168,9 +173,14 @@ const AdminGuesser = ({ + lightTheme={lightTheme} + defaultTheme={defaultTheme}>