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

CRUD Generator Tutorial #1294

Closed
wants to merge 10 commits into from
14 changes: 14 additions & 0 deletions demo/admin/crud-generator-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,18 @@ export default [
target: "src/news/generated",
entityName: "News",
},
{
target: "src/books/generated",
entityName: "Book",
// if your entity expects block JSON for some props, you might need to explicitly define rootBlocks
// that's not necessary if the used block is defined
// - in src/common/blocks and conforms to our naming conventions
// - in a blocks folder right next to the generated folder and conforms to our naming conventions
// - in @comet/cms-admin or @comet/blocks-admin
//
// the definition looks like this:
// rootBlocks: {
// prop: { name: "ExampleBlock", import: "path/or/npm/package" },
// },
},
] satisfies CrudGeneratorConfig[];
6 changes: 6 additions & 0 deletions demo/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
SitesConfigProvider,
} from "@comet/cms-admin";
import { css, Global } from "@emotion/react";
import { BooksPage } from "@src/books/generated/BooksPage";
import { createApolloClient } from "@src/common/apollo/createApolloClient";
import ContentScopeProvider, { ContentScope } from "@src/common/ContentScopeProvider";
import { additionalPageTreeNodeFieldsFragment, EditPageNode } from "@src/common/EditPageNode";
Expand Down Expand Up @@ -225,6 +226,11 @@ class App extends React.Component {
component={ProductTagsPage}
/>

<RouteWithErrorBoundary
path={`${match.path}/books`}
component={BooksPage}
/>

<Redirect from={`${match.path}`} to={`${match.url}/dashboard`} />
</Switch>
</MasterLayout>
Expand Down
58 changes: 58 additions & 0 deletions demo/admin/src/books/generated/BookForm.gql.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// This file has been generated by comet admin-generator.
// You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment.

import { gql } from "@apollo/client";

export const bookFormFragment = gql`
fragment BookForm on Book {
title
description
isAvailable
releaseDate
price
publisher
coverImage
link
}
`;

export const bookFormQuery = gql`
query BookForm($id: ID!) {
book(id: $id) {
id
updatedAt
...BookForm
}
}
${bookFormFragment}
`;

export const bookFormCheckForChangesQuery = gql`
query BookFormCheckForChanges($id: ID!) {
book(id: $id) {
updatedAt
}
}
`;

export const createBookMutation = gql`
mutation CreateBook($input: BookInput!) {
createBook(input: $input) {
id
updatedAt
...BookForm
}
}
${bookFormFragment}
`;

export const updateBookMutation = gql`
mutation UpdateBook($id: ID!, $input: BookUpdateInput!, $lastUpdatedAt: DateTime) {
updateBook(id: $id, input: $input, lastUpdatedAt: $lastUpdatedAt) {
id
updatedAt
...BookForm
}
}
${bookFormFragment}
`;
233 changes: 233 additions & 0 deletions demo/admin/src/books/generated/BookForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// This file has been generated by comet admin-generator.
// You may choose to use this file as scaffold by moving this file out of generated folder and removing this comment.

import { useApolloClient, useQuery } from "@apollo/client";
import {
Field,
FinalForm,
FinalFormCheckbox,
FinalFormInput,
FinalFormSaveSplitButton,
FinalFormSelect,
FinalFormSubmitEvent,
MainContent,
Toolbar,
ToolbarActions,
ToolbarFillSpace,
ToolbarItem,
ToolbarTitleItem,
useFormApiRef,
useStackApi,
useStackSwitchApi,
} from "@comet/admin";
import { FinalFormDatePicker } from "@comet/admin-date-time";
import { ArrowLeft } from "@comet/admin-icons";
import { BlockState, createFinalFormBlock } from "@comet/blocks-admin";
import { DamImageBlock, EditPageLayout, queryUpdatedAt, resolveHasSaveConflict, useFormSaveConflict } from "@comet/cms-admin";
import { CircularProgress, FormControlLabel, IconButton, MenuItem } from "@mui/material";
import { LinkBlock } from "@src/common/blocks/LinkBlock";
import { FormApi } from "final-form";
import { filter } from "graphql-anywhere";
import isEqual from "lodash.isequal";
import React from "react";
import { FormattedMessage } from "react-intl";

import { bookFormFragment, bookFormQuery, createBookMutation, updateBookMutation } from "./BookForm.gql";
import {
GQLBookFormFragment,
GQLBookFormQuery,
GQLBookFormQueryVariables,
GQLCreateBookMutation,
GQLCreateBookMutationVariables,
GQLUpdateBookMutation,
GQLUpdateBookMutationVariables,
} from "./BookForm.gql.generated";

const rootBlocks = {
coverImage: DamImageBlock,
link: LinkBlock,
};

type FormValues = Omit<GQLBookFormFragment, "price"> & {
price: string;
coverImage: BlockState<typeof rootBlocks.coverImage>;
link: BlockState<typeof rootBlocks.link>;
};

interface FormProps {
id?: string;
}

export function BookForm({ id }: FormProps): React.ReactElement {
const stackApi = useStackApi();
const client = useApolloClient();
const mode = id ? "edit" : "add";
const formApiRef = useFormApiRef<FormValues>();
const stackSwitchApi = useStackSwitchApi();

const { data, error, loading, refetch } = useQuery<GQLBookFormQuery, GQLBookFormQueryVariables>(
bookFormQuery,
id ? { variables: { id } } : { skip: true },
);

const initialValues = React.useMemo<Partial<FormValues>>(
() =>
data?.book
? {
...filter<GQLBookFormFragment>(bookFormFragment, data.book),
price: String(data.book.price),
coverImage: rootBlocks.coverImage.input2State(data.book.coverImage),
link: rootBlocks.link.input2State(data.book.link),
}
: {
coverImage: rootBlocks.coverImage.defaultValues(),
link: rootBlocks.link.defaultValues(),
},
[data],
);

const saveConflict = useFormSaveConflict({
checkConflict: async () => {
const updatedAt = await queryUpdatedAt(client, "book", id);
return resolveHasSaveConflict(data?.book.updatedAt, updatedAt);
},
formApiRef,
loadLatestVersion: async () => {
await refetch();
},
});

const handleSubmit = async (state: FormValues, form: FormApi<FormValues>, event: FinalFormSubmitEvent) => {
if (await saveConflict.checkForConflicts()) {
throw new Error("Conflicts detected");
}

const output = {
...state,
price: parseFloat(state.price),
coverImage: rootBlocks.coverImage.state2Output(state.coverImage),
link: rootBlocks.link.state2Output(state.link),
};

if (mode === "edit") {
if (!id) {
throw new Error("Missing id in edit mode");
}
await client.mutate<GQLUpdateBookMutation, GQLUpdateBookMutationVariables>({
mutation: updateBookMutation,
variables: { id, input: output, lastUpdatedAt: data?.book?.updatedAt },
});
} else {
const { data: mutationReponse } = await client.mutate<GQLCreateBookMutation, GQLCreateBookMutationVariables>({
mutation: createBookMutation,
variables: { input: output },
});
if (!event.navigatingBack) {
const id = mutationReponse?.createBook.id;
if (id) {
setTimeout(() => {
stackSwitchApi.activatePage("edit", id);
});
}
}
}
};

if (error) throw error;

if (loading) {
return <CircularProgress />;
}

return (
<FinalForm<FormValues>
apiRef={formApiRef}
onSubmit={handleSubmit}
mode={mode}
initialValues={initialValues}
onAfterSubmit={(values, form) => {
//don't go back automatically
}}
>
{({ values }) => (
<EditPageLayout>
{saveConflict.dialogs}
<Toolbar>
<ToolbarItem>
<IconButton onClick={stackApi?.goBack}>
<ArrowLeft />
</IconButton>
</ToolbarItem>
<ToolbarTitleItem>
<FormattedMessage id="books.Book" defaultMessage="Book" />
</ToolbarTitleItem>
<ToolbarFillSpace />
<ToolbarActions>
<FinalFormSaveSplitButton />
</ToolbarActions>
</Toolbar>
<MainContent>
<Field
required
fullWidth
name="title"
component={FinalFormInput}
label={<FormattedMessage id="book.title" defaultMessage="Title" />}
/>
<Field
required
fullWidth
name="description"
component={FinalFormInput}
label={<FormattedMessage id="book.description" defaultMessage="Description" />}
/>
<Field name="isAvailable" label="" type="checkbox" fullWidth>
{(props) => (
<FormControlLabel
label={<FormattedMessage id="book.isAvailable" defaultMessage="Is Available" />}
control={<FinalFormCheckbox {...props} />}
/>
)}
</Field>
<Field
required
fullWidth
name="releaseDate"
component={FinalFormDatePicker}
label={<FormattedMessage id="book.releaseDate" defaultMessage="Release Date" />}
/>
<Field
required
fullWidth
name="price"
component={FinalFormInput}
type="number"
label={<FormattedMessage id="book.price" defaultMessage="Price" />}
/>
<Field fullWidth name="publisher" label={<FormattedMessage id="book.publisher" defaultMessage="Publisher" />}>
{(props) => (
<FinalFormSelect {...props}>
<MenuItem value="Piper">
<FormattedMessage id="book.publisher.piper" defaultMessage="Piper" />
</MenuItem>
<MenuItem value="Ullstein">
<FormattedMessage id="book.publisher.ullstein" defaultMessage="Ullstein" />
</MenuItem>
<MenuItem value="Manhattan">
<FormattedMessage id="book.publisher.manhattan" defaultMessage="Manhattan" />
</MenuItem>
</FinalFormSelect>
)}
</Field>
<Field name="coverImage" isEqual={isEqual}>
{createFinalFormBlock(rootBlocks.coverImage)}
</Field>
<Field name="link" isEqual={isEqual}>
{createFinalFormBlock(rootBlocks.link)}
</Field>
</MainContent>
</EditPageLayout>
)}
</FinalForm>
);
}
Loading