Skip to content

Commit

Permalink
Add SaveBoundary to allow saving multiple things (e.g., forms) with…
Browse files Browse the repository at this point in the history
… a centralized save button (#1448)

This introduces a SaveBoundary (context) that can contain multiple areas
that get saved together using a SaveBoundarySaveButton.

Any component can register itself in the SaveBoundary by providing a
hasChanges boolean and a doSave method. This also adds a default
implementation for FinalForm:
- if FinalForm is rendered inside a SaveBoundary, it registers
- else the FinalForm renders (like previously) a router Prompt

Prompts are not needed for the inner components, instead SaveBoundary
renders a single prompt containing all hasChanges from registered
components.

See products admin for an implementation example.

Also changed in Demo Products: ProductsPage now contains Toolbar and
Save Button for Detail Page. That way the form is now completely
independent of where it is used and could also be put into an
EditDialog.

#### Alternative:

A possible alternative would be to re-use the router Prompt component
that also has a save action. But I decided for an additional
implementation because:
- PromptHandler stores dirty in a ref instead of state, so
enabling/disabling save button is not possible (could be changed to
state though)
- Prompt has no clear edges, as a new SubmissionBoundary has

---

see #1449 for additional usage of SubmissionBoundary

---------

Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com>
  • Loading branch information
nsams and johnnyomair authored Feb 12, 2024
1 parent 9d47b5b commit 8eb1375
Show file tree
Hide file tree
Showing 12 changed files with 680 additions and 74 deletions.
7 changes: 7 additions & 0 deletions .changeset/silver-drinks-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@comet/admin": minor
---

Add `SaveBoundary` and `SaveBoundarySaveButton` that helps implementing multiple forms with a centralized save button

Render a `Savable` Component anywhere below a `SaveBoundary`. For `FinalForm` this hasn't to be done manually.
1 change: 0 additions & 1 deletion demo/admin/src/products/ProductForm.gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export const productFormFragment = gql`
title
slug
description
price
type
inStock
image
Expand Down
45 changes: 4 additions & 41 deletions demo/admin/src/products/ProductForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,17 @@ import {
FinalForm,
FinalFormCheckbox,
FinalFormInput,
FinalFormSaveSplitButton,
FinalFormSelect,
FinalFormSubmitEvent,
Loading,
MainContent,
Toolbar,
ToolbarActions,
ToolbarFillSpace,
ToolbarItem,
ToolbarTitleItem,
useAsyncOptionsProps,
useFormApiRef,
useStackApi,
useStackSwitchApi,
} from "@comet/admin";
import { ArrowLeft } from "@comet/admin-icons";
import { BlockState, createFinalFormBlock } from "@comet/blocks-admin";
import { DamImageBlock, EditPageLayout, queryUpdatedAt, resolveHasSaveConflict, useFormSaveConflict } from "@comet/cms-admin";
import { FormControlLabel, IconButton, MenuItem } from "@mui/material";
import { FormControlLabel, MenuItem } from "@mui/material";
import { GQLProductType } from "@src/graphql.generated";
import { FormApi } from "final-form";
import { filter } from "graphql-anywhere";
Expand All @@ -48,11 +40,11 @@ import {
GQLProductQuery,
GQLProductQueryVariables,
GQLProductTagsQuery,
GQLProductTagsQueryVariables,
GQLProductTagsSelectFragment,
GQLUpdateProductMutation,
GQLUpdateProductMutationVariables,
} from "./ProductForm.gql.generated";
import { GQLProductTagsListQueryVariables } from "./tags/ProductTagTable.generated";

interface FormProps {
id?: string;
Expand All @@ -62,13 +54,11 @@ const rootBlocks = {
image: DamImageBlock,
};

type FormValues = Omit<GQLProductFormManualFragment, "price" | "image"> & {
price: string;
type FormValues = Omit<GQLProductFormManualFragment, "image"> & {
image: BlockState<typeof rootBlocks.image>;
};

function ProductForm({ id }: FormProps): React.ReactElement {
const stackApi = useStackApi();
const client = useApolloClient();
const mode = id ? "edit" : "add";
const formApiRef = useFormApiRef<FormValues>();
Expand All @@ -82,7 +72,6 @@ function ProductForm({ id }: FormProps): React.ReactElement {
const initialValues: Partial<FormValues> = data?.product
? {
...filter<GQLProductFormManualFragment>(productFormFragment, data.product),
price: String(data.product.price),
image: rootBlocks.image.input2State(data.product.image),
}
: {
Expand All @@ -106,7 +95,6 @@ function ProductForm({ id }: FormProps): React.ReactElement {
if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected");
const output = {
...formValues,
price: parseFloat(formValues.price),
image: rootBlocks.image.state2Output(formValues.image),
type: formValues.type as GQLProductType,
category: formValues.category?.id,
Expand Down Expand Up @@ -144,7 +132,7 @@ function ProductForm({ id }: FormProps): React.ReactElement {
return categories.data.productCategories.nodes;
});
const tagsSelectAsyncProps = useAsyncOptionsProps(async () => {
const tags = await client.query<GQLProductTagsQuery, GQLProductTagsListQueryVariables>({ query: productTagsQuery });
const tags = await client.query<GQLProductTagsQuery, GQLProductTagsQueryVariables>({ query: productTagsQuery });
return tags.data.productTags.nodes;
});

Expand All @@ -168,24 +156,6 @@ function ProductForm({ id }: FormProps): React.ReactElement {
{() => (
<EditPageLayout>
{saveConflict.dialogs}
<Toolbar>
<ToolbarItem>
<IconButton onClick={stackApi?.goBack}>
<ArrowLeft />
</IconButton>
</ToolbarItem>
<ToolbarTitleItem>
<Field name="title">
{({ input }) =>
input.value ? input.value : <FormattedMessage id="products.productDetail" defaultMessage="Product Detail" />
}
</Field>
</ToolbarTitleItem>
<ToolbarFillSpace />
<ToolbarActions>
<FinalFormSaveSplitButton hasConflict={saveConflict.hasConflict} />
</ToolbarActions>
</Toolbar>
<MainContent>
<Field
required
Expand Down Expand Up @@ -236,13 +206,6 @@ function ProductForm({ id }: FormProps): React.ReactElement {
{...tagsSelectAsyncProps}
getOptionLabel={(option: GQLProductTagsSelectFragment) => option.title}
/>
<Field
fullWidth
name="price"
component={FinalFormInput}
inputProps={{ type: "number" }}
label={<FormattedMessage id="product.price" defaultMessage="Price" />}
/>
<Field name="inStock" label="" type="checkbox" fullWidth>
{(props) => (
<FormControlLabel
Expand Down
29 changes: 29 additions & 0 deletions demo/admin/src/products/ProductPriceForm.gql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { gql } from "@apollo/client";

export const productPriceFormFragment = gql`
fragment ProductPriceForm on Product {
price
}
`;

export const productPriceFormQuery = gql`
query ProductPriceForm($id: ID!) {
product(id: $id) {
id
updatedAt
...ProductPriceForm
}
}
${productPriceFormFragment}
`;

export const updateProductPriceFormMutation = gql`
mutation ProductPriceFormUpdateProduct($id: ID!, $input: ProductUpdateInput!, $lastUpdatedAt: DateTime) {
updateProduct(id: $id, input: $input, lastUpdatedAt: $lastUpdatedAt) {
id
updatedAt
...ProductPriceForm
}
}
${productPriceFormFragment}
`;
104 changes: 104 additions & 0 deletions demo/admin/src/products/ProductPriceForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useApolloClient, useQuery } from "@apollo/client";
import { Field, FinalForm, FinalFormInput, FinalFormSubmitEvent, MainContent, useFormApiRef } from "@comet/admin";
import { EditPageLayout, queryUpdatedAt, resolveHasSaveConflict, useFormSaveConflict } from "@comet/cms-admin";
import { CircularProgress } from "@mui/material";
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 { productPriceFormFragment, productPriceFormQuery, updateProductPriceFormMutation } from "./ProductPriceForm.gql";
import {
GQLProductPriceFormFragment,
GQLProductPriceFormQuery,
GQLProductPriceFormQueryVariables,
GQLProductPriceFormUpdateProductMutation,
GQLProductPriceFormUpdateProductMutationVariables,
} from "./ProductPriceForm.gql.generated";

interface FormProps {
id: string;
}

type FormValues = Omit<GQLProductPriceFormFragment, "price"> & {
price: string;
};

function ProductPriceForm({ id }: FormProps): React.ReactElement {
const client = useApolloClient();
const formApiRef = useFormApiRef<FormValues>();

const { data, error, loading, refetch } = useQuery<GQLProductPriceFormQuery, GQLProductPriceFormQueryVariables>(productPriceFormQuery, {
variables: { id },
});

const initialValues: Partial<FormValues> = data?.product
? {
...filter<GQLProductPriceFormFragment>(productPriceFormFragment, data.product),
price: String(data.product.price),
}
: {};

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

const handleSubmit = async (formValues: FormValues, form: FormApi<FormValues>, event: FinalFormSubmitEvent) => {
if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected");
const output = {
...formValues,
price: parseFloat(formValues.price),
};
await client.mutate<GQLProductPriceFormUpdateProductMutation, GQLProductPriceFormUpdateProductMutationVariables>({
mutation: updateProductPriceFormMutation,
variables: { id, input: output, lastUpdatedAt: data?.product.updatedAt },
});
};

if (error) {
return <FormattedMessage id="common.error" defaultMessage="An error has occured. Please try again at later" />;
}

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

return (
<FinalForm<FormValues>
apiRef={formApiRef}
onSubmit={handleSubmit}
mode="edit"
initialValues={initialValues}
initialValuesEqual={isEqual} //required to compare block data correctly
onAfterSubmit={(values, form) => {
//don't go back automatically TODO remove this automatismn
}}
subscription={{}}
>
{() => (
<EditPageLayout>
{saveConflict.dialogs}
<MainContent>
<Field
fullWidth
name="price"
component={FinalFormInput}
inputProps={{ type: "number" }}
label={<FormattedMessage id="product.price" defaultMessage="Price" />}
/>
</MainContent>
</EditPageLayout>
)}
</FinalForm>
);
}

export default ProductPriceForm;
Loading

0 comments on commit 8eb1375

Please sign in to comment.