Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Guessers based on the OpenAPI Schema #80

Merged
merged 31 commits into from
Nov 7, 2024
Merged
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6a5d5a2
Add dataProvider.getSchema to read the database schema
fzaninotto Nov 5, 2024
5f40365
Add CRUD guessers
fzaninotto Nov 5, 2024
bc7670d
Change imports from to react-admin
fzaninotto Nov 5, 2024
711ed56
Update yarn.lock
fzaninotto Nov 5, 2024
27fe5a8
Add documentation for guessers
fzaninotto Nov 5, 2024
765855c
Add AdminGuesser
fzaninotto Nov 5, 2024
d9ab7e7
Replace demo by AdminGuesser
fzaninotto Nov 5, 2024
1c24516
Include custom pages in AdminGuesser
fzaninotto Nov 5, 2024
3f15e5a
Simplify setup even further
fzaninotto Nov 5, 2024
e453a54
Even simpler guesser
fzaninotto Nov 5, 2024
f72c973
Update AdminGuesser syntax
fzaninotto Nov 5, 2024
e430340
Simplify even further
fzaninotto Nov 6, 2024
a133501
Fix double log of AdminGuesser
fzaninotto Nov 6, 2024
09c8132
Improve onboarding
fzaninotto Nov 6, 2024
a049f85
Fix blinking not found page
fzaninotto Nov 6, 2024
43df047
Improve date detection
fzaninotto Nov 6, 2024
9d0794d
Add unit test for useCrudGuesser
fzaninotto Nov 6, 2024
783b751
Fix e2e tests
fzaninotto Nov 6, 2024
a59fcce
Add AdminGuesser e2e test
fzaninotto Nov 6, 2024
7c77fcd
Remove useless dependencies
fzaninotto Nov 6, 2024
9a15381
Add screenshot
fzaninotto Nov 6, 2024
eaf7117
Add filter guessers
fzaninotto Nov 7, 2024
3418996
Update packages/ra-supabase-core/src/useAPISchema.ts
fzaninotto Nov 7, 2024
d2a75ce
Move devdep to dep
fzaninotto Nov 7, 2024
0119eb3
Fix bad move
fzaninotto Nov 7, 2024
4f0a12e
Fix ts compilation on older TS version
fzaninotto Nov 7, 2024
b382cbd
slight refactor
fzaninotto Nov 7, 2024
0d8775a
Remove required fields in ShowGuesser
fzaninotto Nov 7, 2024
f507185
Add support for full-text search
fzaninotto Nov 7, 2024
9a74c92
Review
fzaninotto Nov 7, 2024
9fc8c78
Add partial match in full-text filter
fzaninotto Nov 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add CRUD guessers
fzaninotto committed Nov 5, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
commit 5f403652a59486fedb88a1e90f651e4d1d26a262
126 changes: 126 additions & 0 deletions packages/ra-supabase-ui-materialui/src/guessers/CreateGuesser.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CreateBase
resource={resource}
record={record}
redirect={redirect}
transform={transform}
mutationOptions={mutationOptions}
disableAuthentication={disableAuthentication}
>
<CreateGuesserView {...rest} />
</CreateBase>
);
};

export const CreateGuesserView = (
props: CreateViewProps & {
enableLog?: boolean;
}
) => {
const { data: schema, error, isPending } = useAPISchema();
const resource = useResourceContext();
const [child, setChild] = React.useState<ReactNode>(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 = () => (
<Create>
${representation}
</Create>
);`
);
}, [resource, isPending, error, schema, enableLog]);

if (isPending) return <Loading />;
if (error) return <p>Error: {error.message}</p>;

return <CreateView {...rest}>{child}</CreateView>;
};
130 changes: 130 additions & 0 deletions packages/ra-supabase-ui-materialui/src/guessers/EditGuesser.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EditBase
resource={resource}
id={id}
mutationMode={mutationMode}
mutationOptions={mutationOptions}
queryOptions={queryOptions}
redirect={redirect}
transform={transform}
disableAuthentication={disableAuthentication}
>
<EditGuesserView {...rest} />
</EditBase>
);
};

export const EditGuesserView = (
props: EditViewProps & {
enableLog?: boolean;
}
) => {
const { data: schema, error, isPending } = useAPISchema();
const resource = useResourceContext();
const [child, setChild] = React.useState<ReactNode>(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 = () => (
<Edit>
${representation}
</Edit>
);`
);
}, [resource, isPending, error, schema, enableLog]);

if (isPending) return <Loading />;
if (error) return <p>Error: {error.message}</p>;

return <EditView {...rest}>{child}</EditView>;
};
135 changes: 135 additions & 0 deletions packages/ra-supabase-ui-materialui/src/guessers/ListGuesser.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ListBase
debounce={debounce}
disableAuthentication={disableAuthentication}
disableSyncWithLocation={disableSyncWithLocation}
exporter={exporter}
filter={filter}
filterDefaultValues={filterDefaultValues}
perPage={perPage}
queryOptions={queryOptions}
resource={resource}
sort={sort}
storeKey={storeKey}
>
<ListGuesserView {...rest} />
</ListBase>
);
};

export const ListGuesserView = (
props: ListViewProps & {
enableLog?: boolean;
}
) => {
const { data: schema, error, isPending } = useAPISchema();
const resource = useResourceContext();
const [child, setChild] = React.useState<ReactNode>(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 = () => (
<List>
${representation}
</List>
);`
);
}, [resource, isPending, error, schema, enableLog]);

if (isPending) return <Loading />;
if (error) return <p>Error: {error.message}</p>;

return <ListView {...rest}>{child}</ListView>;
};
116 changes: 116 additions & 0 deletions packages/ra-supabase-ui-materialui/src/guessers/ShowGuesser.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ShowBase
id={id}
disableAuthentication={disableAuthentication}
queryOptions={queryOptions}
resource={resource}
>
<ShowGuesserView {...rest} />
</ShowBase>
);
};

export const ShowGuesserView = (
props: ShowViewProps & {
enableLog?: boolean;
}
) => {
const { data: schema, error, isPending } = useAPISchema();
const resource = useResourceContext();
const [child, setChild] = React.useState<ReactNode>(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 = () => (
<Show>
${representation}
</Show>
);`
);
}, [resource, isPending, error, schema, enableLog]);

if (isPending) return <Loading />;
if (error) return <p>Error: {error.message}</p>;

return <ShowView {...rest}>{child}</ShowView>;
};
4 changes: 4 additions & 0 deletions packages/ra-supabase-ui-materialui/src/guessers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './CreateGuesser';
export * from './EditGuesser';
export * from './ListGuesser';
export * from './ShowGuesser';
Original file line number Diff line number Diff line change
@@ -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,
});
};
1 change: 1 addition & 0 deletions packages/ra-supabase-ui-materialui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './AuthLayout';
export * from './guessers';
export * from './LoginForm';
export * from './LoginPage';
export * from './ForgotPasswordForm';