From 6a5d5a2405ac969e7d727abc56f830a5b0422a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Tue, 5 Nov 2024 21:57:36 +0100 Subject: [PATCH 01/31] Add dataProvider.getSchema to read the database schema --- packages/ra-supabase-core/package.json | 1 + packages/ra-supabase-core/src/dataProvider.ts | 14 ++++++++++- packages/ra-supabase-core/src/index.ts | 1 + packages/ra-supabase-core/src/useAPISchema.ts | 23 +++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 packages/ra-supabase-core/src/useAPISchema.ts diff --git a/packages/ra-supabase-core/package.json b/packages/ra-supabase-core/package.json index d45211e..00cbda9 100644 --- a/packages/ra-supabase-core/package.json +++ b/packages/ra-supabase-core/package.json @@ -25,6 +25,7 @@ "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.2", + "openapi-types": "^12.1.3", "ra-core": "^5.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/ra-supabase-core/src/dataProvider.ts b/packages/ra-supabase-core/src/dataProvider.ts index 8a879d3..3f1c964 100644 --- a/packages/ra-supabase-core/src/dataProvider.ts +++ b/packages/ra-supabase-core/src/dataProvider.ts @@ -6,6 +6,7 @@ import postgrestRestProvider, { } from '@raphiniert/ra-data-postgrest'; import { createClient } from '@supabase/supabase-js'; import type { SupabaseClient } from '@supabase/supabase-js'; +import type { OpenAPIV2 } from 'openapi-types'; /** * A function that returns a dataProvider for Supabase. @@ -40,7 +41,18 @@ export const supabaseDataProvider = ({ schema, ...rest, }; - return postgrestRestProvider(config); + return { + supabaseClient: (url: string, options?: any) => + httpClient(`${config.apiUrl}/${url}`, options), + getSchema: async (): Promise => { + const { json } = await httpClient(`${config.apiUrl}/`, {}); + if (!json || !json.swagger) { + throw new Error('The Open API schema is not readable'); + } + return json; + }, + ...postgrestRestProvider(config), + }; }; /** diff --git a/packages/ra-supabase-core/src/index.ts b/packages/ra-supabase-core/src/index.ts index 51dc7a5..129a920 100644 --- a/packages/ra-supabase-core/src/index.ts +++ b/packages/ra-supabase-core/src/index.ts @@ -1,5 +1,6 @@ export * from './authProvider'; export * from './dataProvider'; +export * from './useAPISchema'; export * from './useRedirectIfAuthenticated'; export * from './useResetPassword'; export * from './useSetPassword'; diff --git a/packages/ra-supabase-core/src/useAPISchema.ts b/packages/ra-supabase-core/src/useAPISchema.ts new file mode 100644 index 0000000..7347120 --- /dev/null +++ b/packages/ra-supabase-core/src/useAPISchema.ts @@ -0,0 +1,23 @@ +import { useDataProvider } from 'react-admin'; +import { useQuery } from '@tanstack/react-query'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import type { OpenAPIV2 } from 'openapi-types'; + +export const useAPISchema = ({ + options, +}: { + options?: Partial>; +} = {}) => { + const dataProvider = useDataProvider(); + if (!dataProvider.getSchema) { + throw new Error( + "The data provider doesn't have access to the database schema" + ); + } + return useQuery({ + queryKey: ['getSchema'], + queryFn: () => dataProvider.getSchema() as Promise, + staleTime: 1000 * 60, // 1 minute + ...options, + }); +}; From 5f403652a59486fedb88a1e90f651e4d1d26a262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Tue, 5 Nov 2024 21:58:18 +0100 Subject: [PATCH 02/31] Add CRUD guessers --- .../src/guessers/CreateGuesser.tsx | 126 ++++++++++++++++ .../src/guessers/EditGuesser.tsx | 130 +++++++++++++++++ .../src/guessers/ListGuesser.tsx | 135 ++++++++++++++++++ .../src/guessers/ShowGuesser.tsx | 116 +++++++++++++++ .../src/guessers/index.ts | 4 + .../src/guessers/inferElementFromType.ts | 61 ++++++++ .../ra-supabase-ui-materialui/src/index.ts | 1 + 7 files changed, 573 insertions(+) create mode 100644 packages/ra-supabase-ui-materialui/src/guessers/CreateGuesser.tsx create mode 100644 packages/ra-supabase-ui-materialui/src/guessers/EditGuesser.tsx create mode 100644 packages/ra-supabase-ui-materialui/src/guessers/ListGuesser.tsx create mode 100644 packages/ra-supabase-ui-materialui/src/guessers/ShowGuesser.tsx create mode 100644 packages/ra-supabase-ui-materialui/src/guessers/index.ts create mode 100644 packages/ra-supabase-ui-materialui/src/guessers/inferElementFromType.ts diff --git a/packages/ra-supabase-ui-materialui/src/guessers/CreateGuesser.tsx b/packages/ra-supabase-ui-materialui/src/guessers/CreateGuesser.tsx new file mode 100644 index 0000000..5fdeb3a --- /dev/null +++ b/packages/ra-supabase-ui-materialui/src/guessers/CreateGuesser.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import type { ReactNode } from 'react'; +import { useAPISchema } from 'ra-supabase-core'; +import { + useResourceContext, + Loading, + CreateBase, + CreateView, + InferredElement, + editFieldTypes, +} from 'react-admin'; +import type { CreateProps, CreateViewProps } from 'react-admin'; +import { capitalize, singularize } from 'inflection'; + +import { inferElementFromType } from './inferElementFromType'; + +export const CreateGuesser = (props: CreateProps & { enableLog?: boolean }) => { + const { + mutationOptions, + resource, + record, + transform, + redirect, + disableAuthentication, + ...rest + } = props; + return ( + + + + ); +}; + +export const CreateGuesserView = ( + props: CreateViewProps & { + enableLog?: boolean; + } +) => { + const { data: schema, error, isPending } = useAPISchema(); + const resource = useResourceContext(); + const [child, setChild] = React.useState(null); + if (!resource) { + throw new Error('CreateGuesser must be used withing a ResourceContext'); + } + const { enableLog = process.env.NODE_ENV === 'development', ...rest } = + props; + + React.useEffect(() => { + if (isPending || error) { + return; + } + const resourceDefinition = schema.definitions?.[resource]; + const requiredFields = resourceDefinition?.required || []; + if (!resourceDefinition || !resourceDefinition.properties) { + throw new Error( + `The resource ${resource} is not defined in the API schema` + ); + } + if (!resourceDefinition || !resourceDefinition.properties) { + return; + } + const inferredInputs = Object.keys(resourceDefinition.properties) + .filter((source: string) => source !== 'id') + .map((source: string) => + inferElementFromType({ + name: source, + types: editFieldTypes, + description: + resourceDefinition.properties![source].description, + type: (resourceDefinition.properties && + resourceDefinition.properties[source] && + typeof resourceDefinition.properties[source].type === + 'string' + ? resourceDefinition.properties![source].type + : 'string') as string, + requiredFields, + }) + ); + const inferredForm = new InferredElement( + editFieldTypes.form, + null, + inferredInputs + ); + setChild(inferredForm.getElement()); + if (!enableLog) return; + + const representation = inferredForm.getRepresentation(); + + const components = ['Create'] + .concat( + Array.from( + new Set( + Array.from(representation.matchAll(/<([^/\s>]+)/g)) + .map(match => match[1]) + .filter(component => component !== 'span') + ) + ) + ) + .sort(); + + // eslint-disable-next-line no-console + console.log( + `Guessed Create: + +import { ${components.join(', ')} } from 'react-admin'; + +export const ${capitalize(singularize(resource))}Create = () => ( + +${representation} + +);` + ); + }, [resource, isPending, error, schema, enableLog]); + + if (isPending) return ; + if (error) return

Error: {error.message}

; + + return {child}; +}; diff --git a/packages/ra-supabase-ui-materialui/src/guessers/EditGuesser.tsx b/packages/ra-supabase-ui-materialui/src/guessers/EditGuesser.tsx new file mode 100644 index 0000000..9918850 --- /dev/null +++ b/packages/ra-supabase-ui-materialui/src/guessers/EditGuesser.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import type { ReactNode } from 'react'; +import { useAPISchema } from 'ra-supabase-core'; +import { + useResourceContext, + Loading, + EditBase, + EditView, + InferredElement, + editFieldTypes, +} from 'react-admin'; +import type { EditProps, EditViewProps } from 'react-admin'; +import { capitalize, singularize } from 'inflection'; + +import { inferElementFromType } from './inferElementFromType'; + +export const EditGuesser = (props: EditProps & { enableLogs?: boolean }) => { + const { + resource, + id, + mutationMode, + mutationOptions, + queryOptions, + redirect, + transform, + disableAuthentication, + ...rest + } = props; + return ( + + + + ); +}; + +export const EditGuesserView = ( + props: EditViewProps & { + enableLog?: boolean; + } +) => { + const { data: schema, error, isPending } = useAPISchema(); + const resource = useResourceContext(); + const [child, setChild] = React.useState(null); + if (!resource) { + throw new Error('EditGuesser must be used withing a ResourceContext'); + } + const { enableLog = process.env.NODE_ENV === 'development', ...rest } = + props; + + React.useEffect(() => { + if (isPending || error) { + return; + } + const resourceDefinition = schema.definitions?.[resource]; + const requiredFields = resourceDefinition?.required || []; + if (!resourceDefinition || !resourceDefinition.properties) { + throw new Error( + `The resource ${resource} is not defined in the API schema` + ); + } + if (!resourceDefinition || !resourceDefinition.properties) { + return; + } + const inferredInputs = Object.keys(resourceDefinition.properties) + .filter((source: string) => source !== 'id') + .map((source: string) => + inferElementFromType({ + name: source, + types: editFieldTypes, + description: + resourceDefinition.properties![source].description, + type: (resourceDefinition.properties && + resourceDefinition.properties[source] && + typeof resourceDefinition.properties[source].type === + 'string' + ? resourceDefinition.properties![source].type + : 'string') as string, + requiredFields, + }) + ); + const inferredForm = new InferredElement( + editFieldTypes.form, + null, + inferredInputs + ); + setChild(inferredForm.getElement()); + if (!enableLog) return; + + const representation = inferredForm.getRepresentation(); + + const components = ['Edit'] + .concat( + Array.from( + new Set( + Array.from(representation.matchAll(/<([^/\s>]+)/g)) + .map(match => match[1]) + .filter(component => component !== 'span') + ) + ) + ) + .sort(); + + // eslint-disable-next-line no-console + console.log( + `Guessed Edit: + +import { ${components.join(', ')} } from 'react-admin'; + +export const ${capitalize(singularize(resource))}Edit = () => ( + +${representation} + +);` + ); + }, [resource, isPending, error, schema, enableLog]); + + if (isPending) return ; + if (error) return

Error: {error.message}

; + + return {child}; +}; diff --git a/packages/ra-supabase-ui-materialui/src/guessers/ListGuesser.tsx b/packages/ra-supabase-ui-materialui/src/guessers/ListGuesser.tsx new file mode 100644 index 0000000..8efd398 --- /dev/null +++ b/packages/ra-supabase-ui-materialui/src/guessers/ListGuesser.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import type { ReactNode } from 'react'; +import { useAPISchema } from 'ra-supabase-core'; +import { + useResourceContext, + Loading, + ListBase, + ListView, + InferredElement, + listFieldTypes, +} from 'react-admin'; +import type { ListProps, ListViewProps } from 'react-admin'; +import { capitalize, singularize } from 'inflection'; + +import { inferElementFromType } from './inferElementFromType'; + +export const ListGuesser = (props: ListProps & { enableLog?: boolean }) => { + const { + debounce, + disableAuthentication, + disableSyncWithLocation, + exporter, + filter, + filterDefaultValues, + perPage, + queryOptions, + resource, + sort, + storeKey, + ...rest + } = props; + return ( + + + + ); +}; + +export const ListGuesserView = ( + props: ListViewProps & { + enableLog?: boolean; + } +) => { + const { data: schema, error, isPending } = useAPISchema(); + const resource = useResourceContext(); + const [child, setChild] = React.useState(null); + if (!resource) { + throw new Error('ListGuesser must be used withing a ResourceContext'); + } + const { enableLog = process.env.NODE_ENV === 'development', ...rest } = + props; + + React.useEffect(() => { + if (isPending || error) { + return; + } + const resourceDefinition = schema.definitions?.[resource]; + const requiredFields = resourceDefinition?.required || []; + if (!resourceDefinition || !resourceDefinition.properties) { + throw new Error( + `The resource ${resource} is not defined in the API schema` + ); + } + if (!resourceDefinition || !resourceDefinition.properties) { + return; + } + const inferredInputs = Object.keys(resourceDefinition.properties).map( + (source: string) => + inferElementFromType({ + name: source, + types: listFieldTypes, + description: + resourceDefinition.properties![source].description, + type: (resourceDefinition.properties && + resourceDefinition.properties[source] && + typeof resourceDefinition.properties[source].type === + 'string' + ? resourceDefinition.properties![source].type + : 'string') as string, + requiredFields, + }) + ); + const inferredForm = new InferredElement( + listFieldTypes.table, + null, + inferredInputs + ); + setChild(inferredForm.getElement()); + if (!enableLog) return; + + const representation = inferredForm.getRepresentation(); + + const components = ['List'] + .concat( + Array.from( + new Set( + Array.from(representation.matchAll(/<([^/\s>]+)/g)) + .map(match => match[1]) + .filter(component => component !== 'span') + ) + ) + ) + .sort(); + + // eslint-disable-next-line no-console + console.log( + `Guessed List: + +import { ${components.join(', ')} } from 'react-admin'; + +export const ${capitalize(singularize(resource))}List = () => ( + +${representation} + +);` + ); + }, [resource, isPending, error, schema, enableLog]); + + if (isPending) return ; + if (error) return

Error: {error.message}

; + + return {child}; +}; diff --git a/packages/ra-supabase-ui-materialui/src/guessers/ShowGuesser.tsx b/packages/ra-supabase-ui-materialui/src/guessers/ShowGuesser.tsx new file mode 100644 index 0000000..55574ca --- /dev/null +++ b/packages/ra-supabase-ui-materialui/src/guessers/ShowGuesser.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import type { ReactNode } from 'react'; +import { useAPISchema } from 'ra-supabase-core'; +import { + useResourceContext, + Loading, + ShowBase, + ShowView, + InferredElement, + showFieldTypes, +} from 'react-admin'; +import type { ShowProps, ShowViewProps } from 'react-admin'; +import { capitalize, singularize } from 'inflection'; + +import { inferElementFromType } from './inferElementFromType'; + +export const ShowGuesser = (props: ShowProps & { enableLog?: boolean }) => { + const { id, disableAuthentication, queryOptions, resource, ...rest } = + props; + return ( + + + + ); +}; + +export const ShowGuesserView = ( + props: ShowViewProps & { + enableLog?: boolean; + } +) => { + const { data: schema, error, isPending } = useAPISchema(); + const resource = useResourceContext(); + const [child, setChild] = React.useState(null); + if (!resource) { + throw new Error('ShowGuesser must be used withing a ResourceContext'); + } + const { enableLog = process.env.NODE_ENV === 'development', ...rest } = + props; + + React.useEffect(() => { + if (isPending || error) { + return; + } + const resourceDefinition = schema.definitions?.[resource]; + const requiredFields = resourceDefinition?.required || []; + if (!resourceDefinition || !resourceDefinition.properties) { + throw new Error( + `The resource ${resource} is not defined in the API schema` + ); + } + if (!resourceDefinition || !resourceDefinition.properties) { + return; + } + const inferredInputs = Object.keys(resourceDefinition.properties).map( + (source: string) => + inferElementFromType({ + name: source, + types: showFieldTypes, + description: + resourceDefinition.properties![source].description, + type: (resourceDefinition.properties && + resourceDefinition.properties[source] && + typeof resourceDefinition.properties[source].type === + 'string' + ? resourceDefinition.properties![source].type + : 'string') as string, + requiredFields, + }) + ); + const inferredForm = new InferredElement( + showFieldTypes.show, + null, + inferredInputs + ); + setChild(inferredForm.getElement()); + if (!enableLog) return; + + const representation = inferredForm.getRepresentation(); + + const components = ['Show'] + .concat( + Array.from( + new Set( + Array.from(representation.matchAll(/<([^/\s>]+)/g)) + .map(match => match[1]) + .filter(component => component !== 'span') + ) + ) + ) + .sort(); + + // eslint-disable-next-line no-console + console.log( + `Guessed Show: + +import { ${components.join(', ')} } from 'react-admin'; + +export const ${capitalize(singularize(resource))}Show = () => ( + +${representation} + +);` + ); + }, [resource, isPending, error, schema, enableLog]); + + if (isPending) return ; + if (error) return

Error: {error.message}

; + + return {child}; +}; diff --git a/packages/ra-supabase-ui-materialui/src/guessers/index.ts b/packages/ra-supabase-ui-materialui/src/guessers/index.ts new file mode 100644 index 0000000..5b81b0a --- /dev/null +++ b/packages/ra-supabase-ui-materialui/src/guessers/index.ts @@ -0,0 +1,4 @@ +export * from './CreateGuesser'; +export * from './EditGuesser'; +export * from './ListGuesser'; +export * from './ShowGuesser'; diff --git a/packages/ra-supabase-ui-materialui/src/guessers/inferElementFromType.ts b/packages/ra-supabase-ui-materialui/src/guessers/inferElementFromType.ts new file mode 100644 index 0000000..00b8368 --- /dev/null +++ b/packages/ra-supabase-ui-materialui/src/guessers/inferElementFromType.ts @@ -0,0 +1,61 @@ +import { InferredElement, required } from 'react-admin'; +import type { InferredTypeMap } from 'react-admin'; +import { pluralize } from 'inflection'; + +const hasType = (type, types) => typeof types[type] !== 'undefined'; + +export const inferElementFromType = ({ + name, + description, + type, + requiredFields, + types, +}: { + name: string; + types: InferredTypeMap; + description?: string; + type?: string; + requiredFields?: string[]; +}) => { + if (name === 'id' && hasType('id', types)) { + return new InferredElement(types.id, { source: 'id' }); + } + const validate = requiredFields?.includes(name) ? [required()] : undefined; + if ( + description?.startsWith('Note:\nThis is a Foreign Key to') && + hasType('reference', types) + ) { + const reference = description.split('`')[1].split('.')[0]; + return new InferredElement(types.reference, { + source: name, + reference, + }); + } + if ( + name.substring(name.length - 4) === '_ids' && + hasType('reference', types) + ) { + const reference = pluralize(name.substr(0, name.length - 4)); + return new InferredElement(types.referenceArray, { + source: name, + reference, + }); + } + if (type === 'array') { + // FIXME instrospect further + return new InferredElement(types.string, { + source: name, + validate, + }); + } + if (type && hasType(type, types)) { + return new InferredElement(types[type], { + source: name, + validate, + }); + } + return new InferredElement(types.string, { + source: name, + validate, + }); +}; diff --git a/packages/ra-supabase-ui-materialui/src/index.ts b/packages/ra-supabase-ui-materialui/src/index.ts index 90f859e..394878f 100644 --- a/packages/ra-supabase-ui-materialui/src/index.ts +++ b/packages/ra-supabase-ui-materialui/src/index.ts @@ -1,4 +1,5 @@ export * from './AuthLayout'; +export * from './guessers'; export * from './LoginForm'; export * from './LoginPage'; export * from './ForgotPasswordForm'; From bc7670d59ebe737840453a415309871348832ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Tue, 5 Nov 2024 21:58:45 +0100 Subject: [PATCH 03/31] Change imports from to react-admin --- .../ra-supabase-ui-materialui/package.json | 4 ++-- .../src/AuthLayout.tsx | 4 +--- .../src/ForgotPasswordForm.tsx | 13 ++++++++++--- .../src/ForgotPasswordPage.tsx | 3 ++- .../src/LoginForm.tsx | 18 ++++++++++++++---- .../src/LoginPage.tsx | 8 ++++---- .../src/SetPasswordForm.tsx | 12 +++++++++--- .../src/SetPasswordPage.tsx | 3 ++- .../src/SocialAuthButton.tsx | 3 ++- 9 files changed, 46 insertions(+), 22 deletions(-) diff --git a/packages/ra-supabase-ui-materialui/package.json b/packages/ra-supabase-ui-materialui/package.json index 57d07cb..3b9bafb 100644 --- a/packages/ra-supabase-ui-materialui/package.json +++ b/packages/ra-supabase-ui-materialui/package.json @@ -22,7 +22,7 @@ "@mui/material": "^5.15.20", "@supabase/supabase-js": "^2.43.5", "ra-core": "^5.0.1", - "ra-ui-materialui": "^5.0.1", + "react-admin": "^5.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router": "^6.23.1" @@ -32,7 +32,7 @@ "@mui/material": "^5.0.0", "@supabase/supabase-js": "^2.0.0", "ra-core": "^5.0.1", - "ra-ui-materialui": "^5.0.1", + "react-admin": "^5.0.1", "react": "^18.0.0", "react-dom": "^18.0.0", "react-router": "^6.23.1" diff --git a/packages/ra-supabase-ui-materialui/src/AuthLayout.tsx b/packages/ra-supabase-ui-materialui/src/AuthLayout.tsx index 8d85b4c..6ef30cc 100644 --- a/packages/ra-supabase-ui-materialui/src/AuthLayout.tsx +++ b/packages/ra-supabase-ui-materialui/src/AuthLayout.tsx @@ -16,9 +16,7 @@ import { styled, } from '@mui/material'; import LockIcon from '@mui/icons-material/Lock'; - -import { TitleComponent } from 'ra-core'; -import { defaultTheme, Notification } from 'ra-ui-materialui'; +import { defaultTheme, Notification, TitleComponent } from 'react-admin'; /** * A standalone login page, to serve as authentication gate to the admin diff --git a/packages/ra-supabase-ui-materialui/src/ForgotPasswordForm.tsx b/packages/ra-supabase-ui-materialui/src/ForgotPasswordForm.tsx index ac47865..edafac7 100644 --- a/packages/ra-supabase-ui-materialui/src/ForgotPasswordForm.tsx +++ b/packages/ra-supabase-ui-materialui/src/ForgotPasswordForm.tsx @@ -1,8 +1,15 @@ +import * as React from 'react'; import { CardActions, Stack, styled, Typography } from '@mui/material'; -import { Form, required, useNotify, useTranslate } from 'ra-core'; import { useResetPassword } from 'ra-supabase-core'; -import { Link, SaveButton, TextInput } from 'ra-ui-materialui'; -import * as React from 'react'; +import { + Form, + required, + useNotify, + useTranslate, + Link, + SaveButton, + TextInput, +} from 'react-admin'; /** * A component that renders a form for resetting the user password. diff --git a/packages/ra-supabase-ui-materialui/src/ForgotPasswordPage.tsx b/packages/ra-supabase-ui-materialui/src/ForgotPasswordPage.tsx index bae0522..325b6ea 100644 --- a/packages/ra-supabase-ui-materialui/src/ForgotPasswordPage.tsx +++ b/packages/ra-supabase-ui-materialui/src/ForgotPasswordPage.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { ReactNode } from 'react'; +import type { ReactNode } from 'react'; + import { AuthLayout } from './AuthLayout'; import { ForgotPasswordForm } from './ForgotPasswordForm'; diff --git a/packages/ra-supabase-ui-materialui/src/LoginForm.tsx b/packages/ra-supabase-ui-materialui/src/LoginForm.tsx index bde58f4..ccbffe1 100644 --- a/packages/ra-supabase-ui-materialui/src/LoginForm.tsx +++ b/packages/ra-supabase-ui-materialui/src/LoginForm.tsx @@ -1,8 +1,18 @@ -import { CardActions, styled } from '@mui/material'; -import { Form, required, useLogin, useNotify, useTranslate } from 'ra-core'; -import { Link, PasswordInput, SaveButton, TextInput } from 'ra-ui-materialui'; import * as React from 'react'; -import { ComponentProps } from 'react'; +import type { ComponentProps } from 'react'; +import { CardActions, styled } from '@mui/material'; +import { + Form, + required, + useLogin, + useNotify, + useTranslate, + Link, + PasswordInput, + SaveButton, + TextInput, +} from 'react-admin'; + import { ForgotPasswordPage } from './ForgotPasswordPage'; /** diff --git a/packages/ra-supabase-ui-materialui/src/LoginPage.tsx b/packages/ra-supabase-ui-materialui/src/LoginPage.tsx index 6616c36..784324f 100644 --- a/packages/ra-supabase-ui-materialui/src/LoginPage.tsx +++ b/packages/ra-supabase-ui-materialui/src/LoginPage.tsx @@ -1,10 +1,8 @@ import * as React from 'react'; -import { ReactNode } from 'react'; - -import { AuthLayout } from './AuthLayout'; -import { LoginForm } from './LoginForm'; +import type { ReactNode } from 'react'; import { Provider } from '@supabase/supabase-js'; import { Divider, Stack } from '@mui/material'; + import { AppleButton, AzureButton, @@ -23,6 +21,8 @@ import { TwitterButton, WorkosButton, } from './SocialAuthButton'; +import { AuthLayout } from './AuthLayout'; +import { LoginForm } from './LoginForm'; /** * A component that renders a login page to login to the application through Supabase. It renders a LoginForm by default. It support social login providers. diff --git a/packages/ra-supabase-ui-materialui/src/SetPasswordForm.tsx b/packages/ra-supabase-ui-materialui/src/SetPasswordForm.tsx index 1f45477..aa5322d 100644 --- a/packages/ra-supabase-ui-materialui/src/SetPasswordForm.tsx +++ b/packages/ra-supabase-ui-materialui/src/SetPasswordForm.tsx @@ -1,8 +1,14 @@ +import * as React from 'react'; import { CardActions, styled, Typography } from '@mui/material'; -import { Form, required, useNotify, useTranslate } from 'ra-core'; +import { + Form, + required, + useNotify, + useTranslate, + PasswordInput, + SaveButton, +} from 'react-admin'; import { useSetPassword, useSupabaseAccessToken } from 'ra-supabase-core'; -import { PasswordInput, SaveButton } from 'ra-ui-materialui'; -import * as React from 'react'; /** * A component that renders a form for setting the current user password through Supabase. diff --git a/packages/ra-supabase-ui-materialui/src/SetPasswordPage.tsx b/packages/ra-supabase-ui-materialui/src/SetPasswordPage.tsx index 0b527af..fc57948 100644 --- a/packages/ra-supabase-ui-materialui/src/SetPasswordPage.tsx +++ b/packages/ra-supabase-ui-materialui/src/SetPasswordPage.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { ReactNode } from 'react'; +import type { ReactNode } from 'react'; + import { AuthLayout } from './AuthLayout'; import { SetPasswordForm } from './SetPasswordForm'; diff --git a/packages/ra-supabase-ui-materialui/src/SocialAuthButton.tsx b/packages/ra-supabase-ui-materialui/src/SocialAuthButton.tsx index f8c385c..bc1993c 100644 --- a/packages/ra-supabase-ui-materialui/src/SocialAuthButton.tsx +++ b/packages/ra-supabase-ui-materialui/src/SocialAuthButton.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { Provider } from '@supabase/supabase-js'; import { Button, ButtonProps } from '@mui/material'; -import { useLogin, useNotify, useTranslate } from 'ra-core'; +import { useLogin, useNotify, useTranslate } from 'react-admin'; + import { AppleIcon, AzureIcon, From 711ed56d131a6e77a032425c8c0709bdc426cf17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Tue, 5 Nov 2024 21:58:55 +0100 Subject: [PATCH 04/31] Update yarn.lock --- yarn.lock | 36 ++++++++---------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/yarn.lock b/yarn.lock index 24b799d..47c8404 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9074,6 +9074,11 @@ open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openapi-types@^12.1.3: + version "12.1.3" + resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" + integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -10661,16 +10666,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10770,14 +10766,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11650,7 +11639,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -11668,15 +11657,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 27fe5a8665d00e0d71461dc8b9348a55aa00c269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Tue, 5 Nov 2024 21:59:11 +0100 Subject: [PATCH 05/31] Add documentation for guessers --- packages/ra-supabase/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/ra-supabase/README.md b/packages/ra-supabase/README.md index 7ba1df5..978d0e3 100644 --- a/packages/ra-supabase/README.md +++ b/packages/ra-supabase/README.md @@ -231,6 +231,29 @@ const dataProvider = supabaseDataProvider(config); As users authenticate through supabase, you can leverage [Row Level Security](https://supabase.com/docs/guides/auth/row-level-security). Users identity will be propagated through the dataProvider if you provided the public API (anon) key. Keep in mind that passing the `service_role` key will bypass Row Level Security. This is not recommended. +## Guessers + +`ra-supabase` provides alternative guessers for all CRUD pages, leveraging the OpenAPI schema provided by Supabase. Use these guessers instead of react-admin's default guessers for a better first experience. + +```jsx +import { Admin, Resource } from 'react-admin'; +import { ListGuesser, ShowGuesser, EditGuesser, CreateGuesser } from 'ra-supabase'; + +export const MyAdmin = () => ( + + + + + +); +``` + ## Using Hash Router Supabase uses URL hash links for its redirections. This can cause conflicts if you use a HashRouter. For this reason, we recommend using the BrowserRouter. From 765855cec0e6d129efd0bdd972f3668b7fd79ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Tue, 5 Nov 2024 23:29:48 +0100 Subject: [PATCH 06/31] Add AdminGuesser --- .../src/guessers/AdminGuesser.tsx | 76 +++++++++++++++++++ .../src/guessers/index.ts | 2 + .../src/guessers/useCrudGuesser.tsx | 37 +++++++++ 3 files changed, 115 insertions(+) create mode 100644 packages/ra-supabase-ui-materialui/src/guessers/AdminGuesser.tsx create mode 100644 packages/ra-supabase-ui-materialui/src/guessers/useCrudGuesser.tsx diff --git a/packages/ra-supabase-ui-materialui/src/guessers/AdminGuesser.tsx b/packages/ra-supabase-ui-materialui/src/guessers/AdminGuesser.tsx new file mode 100644 index 0000000..ea8c692 --- /dev/null +++ b/packages/ra-supabase-ui-materialui/src/guessers/AdminGuesser.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; + +import { AdminContext, AdminUI, Resource, Loading } from 'react-admin'; +import type { AdminProps, AdminUIProps } from 'react-admin'; + +import { useCrudGuesser } from './useCrudGuesser'; + +export const AdminGuesser = (props: AdminProps) => { + const { + authProvider, + basename, + darkTheme, + dataProvider, + defaultTheme, + i18nProvider, + lightTheme, + queryClient, + store, + theme, + ...rest + } = props; + + return ( + + + + ); +}; + +const AdminUIGuesser = (props: AdminUIProps) => { + const resourceDefinitions = useCrudGuesser(); + const { children, ...rest } = props; + if (!children) { + console.log( + `Guessed Admin: +import { Admin, Resource } from 'react-admin'; +import ( ListGuesser, EditGuesser, CreateGuesser, ShowGuesser ) from 'ra-supabase'; + +export const App = () => ( + ${resourceDefinitions + .map( + def => ` + ` + ) + .join('')} + +);` + ); + } + return ( + + {children ?? + resourceDefinitions.map(resourceDefinition => ( + + ))} + + ); +}; diff --git a/packages/ra-supabase-ui-materialui/src/guessers/index.ts b/packages/ra-supabase-ui-materialui/src/guessers/index.ts index 5b81b0a..08cd003 100644 --- a/packages/ra-supabase-ui-materialui/src/guessers/index.ts +++ b/packages/ra-supabase-ui-materialui/src/guessers/index.ts @@ -1,4 +1,6 @@ +export * from './AdminGuesser'; export * from './CreateGuesser'; export * from './EditGuesser'; export * from './ListGuesser'; +export * from './useCrudGuesser'; export * from './ShowGuesser'; diff --git a/packages/ra-supabase-ui-materialui/src/guessers/useCrudGuesser.tsx b/packages/ra-supabase-ui-materialui/src/guessers/useCrudGuesser.tsx new file mode 100644 index 0000000..8811b78 --- /dev/null +++ b/packages/ra-supabase-ui-materialui/src/guessers/useCrudGuesser.tsx @@ -0,0 +1,37 @@ +import { useAPISchema } from 'ra-supabase-core'; +import type { ResourceProps } from 'react-admin'; + +import { ListGuesser } from './ListGuesser'; +import { CreateGuesser } from './CreateGuesser'; +import { EditGuesser } from './EditGuesser'; +import { ShowGuesser } from './ShowGuesser'; + +export const useCrudGuesser = () => { + const { data: schema, error, isPending } = useAPISchema(); + let resourceDefinitions: ResourceProps[] = []; + if (!isPending && !error) { + let edit, show, create, list; + const resourceNames = Object.keys(schema.definitions!); + resourceDefinitions = resourceNames.map(name => { + const resourcePaths = schema.paths[`/${name}`] ?? {}; + if (resourcePaths.get) { + list = ListGuesser; + show = ShowGuesser; + } + if (resourcePaths.post) { + create = CreateGuesser; + } + if (resourcePaths.patch) { + edit = EditGuesser; + } + return { + name, + list, + show, + edit, + create, + }; + }); + } + return resourceDefinitions; +}; From d9ab7e78159c9ede9505a6753029628fc4c9fe1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Tue, 5 Nov 2024 23:30:06 +0100 Subject: [PATCH 07/31] Replace demo by AdminGuesser --- packages/demo/index.html | 2 +- packages/demo/package.json | 5 - packages/demo/src/App.tsx | 58 +-- packages/demo/src/Header.tsx | 82 ---- packages/demo/src/Layout.tsx | 29 -- packages/demo/src/companies/CompanyAside.tsx | 106 ----- packages/demo/src/companies/CompanyAvatar.tsx | 25 - packages/demo/src/companies/CompanyCard.tsx | 98 ---- packages/demo/src/companies/CompanyCreate.tsx | 24 - packages/demo/src/companies/CompanyEdit.tsx | 26 - packages/demo/src/companies/CompanyForm.tsx | 63 --- packages/demo/src/companies/CompanyList.tsx | 43 -- .../demo/src/companies/CompanyListFilter.tsx | 56 --- packages/demo/src/companies/CompanyShow.tsx | 289 ----------- packages/demo/src/companies/GridList.tsx | 47 -- packages/demo/src/companies/LogoField.tsx | 30 -- packages/demo/src/companies/index.ts | 12 - packages/demo/src/companies/sectors.ts | 13 - packages/demo/src/companies/sizes.ts | 7 - packages/demo/src/contacts/ContactAside.tsx | 131 ----- packages/demo/src/contacts/ContactCreate.tsx | 42 -- packages/demo/src/contacts/ContactEdit.tsx | 41 -- packages/demo/src/contacts/ContactInputs.tsx | 153 ------ packages/demo/src/contacts/ContactList.tsx | 160 ------- .../demo/src/contacts/ContactListFilter.tsx | 159 ------- packages/demo/src/contacts/ContactShow.tsx | 71 --- packages/demo/src/contacts/TagsList.tsx | 33 -- packages/demo/src/contacts/TagsListEdit.tsx | 251 ---------- packages/demo/src/contacts/constants.ts | 5 - packages/demo/src/contacts/index.tsx | 15 - .../src/{contacts => dashboard}/Avatar.tsx | 0 packages/demo/src/dashboard/Dashboard.tsx | 9 - packages/demo/src/dashboard/DealsChart.tsx | 157 ------ packages/demo/src/dashboard/HotContacts.tsx | 2 +- .../demo/src/{tasks => dashboard}/Task.tsx | 0 .../{tasks => dashboard}/TasksIterator.tsx | 0 packages/demo/src/dashboard/TasksList.tsx | 2 +- packages/demo/src/dashboard/Welcome.tsx | 68 --- packages/demo/src/deals/ContactList.tsx | 45 -- packages/demo/src/deals/DealCard.tsx | 68 --- packages/demo/src/deals/DealColumn.tsx | 59 --- packages/demo/src/deals/DealCreate.tsx | 139 ------ packages/demo/src/deals/DealList.tsx | 65 --- packages/demo/src/deals/DealListContent.tsx | 246 ---------- packages/demo/src/deals/DealShow.tsx | 170 ------- packages/demo/src/deals/OnlyMineInput.tsx | 34 -- packages/demo/src/deals/index.ts | 7 - packages/demo/src/deals/stages.ts | 47 -- packages/demo/src/deals/types.ts | 9 - packages/demo/src/misc/NbRelations.tsx | 38 -- packages/demo/src/misc/Status.tsx | 25 - packages/demo/src/misc/useNbRelations.tsx | 35 -- packages/demo/src/notes/NewNote.tsx | 146 ------ packages/demo/src/notes/Note.tsx | 206 -------- packages/demo/src/notes/NotesIterator.tsx | 33 -- packages/demo/src/notes/StatusSelector.tsx | 32 -- packages/demo/src/notes/index.ts | 3 - packages/demo/src/tags/colors.ts | 12 - packages/demo/src/tasks/AddTask.tsx | 129 ----- yarn.lock | 447 +++--------------- 60 files changed, 87 insertions(+), 4222 deletions(-) delete mode 100644 packages/demo/src/Header.tsx delete mode 100644 packages/demo/src/Layout.tsx delete mode 100644 packages/demo/src/companies/CompanyAside.tsx delete mode 100644 packages/demo/src/companies/CompanyAvatar.tsx delete mode 100644 packages/demo/src/companies/CompanyCard.tsx delete mode 100644 packages/demo/src/companies/CompanyCreate.tsx delete mode 100644 packages/demo/src/companies/CompanyEdit.tsx delete mode 100644 packages/demo/src/companies/CompanyForm.tsx delete mode 100644 packages/demo/src/companies/CompanyList.tsx delete mode 100644 packages/demo/src/companies/CompanyListFilter.tsx delete mode 100644 packages/demo/src/companies/CompanyShow.tsx delete mode 100644 packages/demo/src/companies/GridList.tsx delete mode 100644 packages/demo/src/companies/LogoField.tsx delete mode 100644 packages/demo/src/companies/index.ts delete mode 100644 packages/demo/src/companies/sectors.ts delete mode 100644 packages/demo/src/companies/sizes.ts delete mode 100644 packages/demo/src/contacts/ContactAside.tsx delete mode 100644 packages/demo/src/contacts/ContactCreate.tsx delete mode 100644 packages/demo/src/contacts/ContactEdit.tsx delete mode 100644 packages/demo/src/contacts/ContactInputs.tsx delete mode 100644 packages/demo/src/contacts/ContactList.tsx delete mode 100644 packages/demo/src/contacts/ContactListFilter.tsx delete mode 100644 packages/demo/src/contacts/ContactShow.tsx delete mode 100644 packages/demo/src/contacts/TagsList.tsx delete mode 100644 packages/demo/src/contacts/TagsListEdit.tsx delete mode 100644 packages/demo/src/contacts/constants.ts delete mode 100644 packages/demo/src/contacts/index.tsx rename packages/demo/src/{contacts => dashboard}/Avatar.tsx (100%) delete mode 100644 packages/demo/src/dashboard/DealsChart.tsx rename packages/demo/src/{tasks => dashboard}/Task.tsx (100%) rename packages/demo/src/{tasks => dashboard}/TasksIterator.tsx (100%) delete mode 100644 packages/demo/src/dashboard/Welcome.tsx delete mode 100644 packages/demo/src/deals/ContactList.tsx delete mode 100644 packages/demo/src/deals/DealCard.tsx delete mode 100644 packages/demo/src/deals/DealColumn.tsx delete mode 100644 packages/demo/src/deals/DealCreate.tsx delete mode 100644 packages/demo/src/deals/DealList.tsx delete mode 100644 packages/demo/src/deals/DealListContent.tsx delete mode 100644 packages/demo/src/deals/DealShow.tsx delete mode 100644 packages/demo/src/deals/OnlyMineInput.tsx delete mode 100644 packages/demo/src/deals/index.ts delete mode 100644 packages/demo/src/deals/stages.ts delete mode 100644 packages/demo/src/deals/types.ts delete mode 100644 packages/demo/src/misc/NbRelations.tsx delete mode 100644 packages/demo/src/misc/Status.tsx delete mode 100644 packages/demo/src/misc/useNbRelations.tsx delete mode 100644 packages/demo/src/notes/NewNote.tsx delete mode 100644 packages/demo/src/notes/Note.tsx delete mode 100644 packages/demo/src/notes/NotesIterator.tsx delete mode 100644 packages/demo/src/notes/StatusSelector.tsx delete mode 100644 packages/demo/src/notes/index.ts delete mode 100644 packages/demo/src/tags/colors.ts delete mode 100644 packages/demo/src/tasks/AddTask.tsx diff --git a/packages/demo/index.html b/packages/demo/index.html index ac5e949..22b7231 100644 --- a/packages/demo/index.html +++ b/packages/demo/index.html @@ -9,7 +9,7 @@ - Atomic CRM + CRM Demo