diff --git a/.changeset/silver-drinks-perform.md b/.changeset/silver-drinks-perform.md new file mode 100644 index 0000000000..83deb27b7b --- /dev/null +++ b/.changeset/silver-drinks-perform.md @@ -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. \ No newline at end of file diff --git a/demo/admin/src/products/ProductForm.gql.ts b/demo/admin/src/products/ProductForm.gql.ts index 311d38a87d..fabed48c8d 100644 --- a/demo/admin/src/products/ProductForm.gql.ts +++ b/demo/admin/src/products/ProductForm.gql.ts @@ -5,7 +5,6 @@ export const productFormFragment = gql` title slug description - price type inStock image diff --git a/demo/admin/src/products/ProductForm.tsx b/demo/admin/src/products/ProductForm.tsx index cc38b67ecb..2ed3024a46 100644 --- a/demo/admin/src/products/ProductForm.tsx +++ b/demo/admin/src/products/ProductForm.tsx @@ -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"; @@ -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; @@ -62,13 +54,11 @@ const rootBlocks = { image: DamImageBlock, }; -type FormValues = Omit & { - price: string; +type FormValues = Omit & { image: BlockState; }; function ProductForm({ id }: FormProps): React.ReactElement { - const stackApi = useStackApi(); const client = useApolloClient(); const mode = id ? "edit" : "add"; const formApiRef = useFormApiRef(); @@ -82,7 +72,6 @@ function ProductForm({ id }: FormProps): React.ReactElement { const initialValues: Partial = data?.product ? { ...filter(productFormFragment, data.product), - price: String(data.product.price), image: rootBlocks.image.input2State(data.product.image), } : { @@ -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, @@ -144,7 +132,7 @@ function ProductForm({ id }: FormProps): React.ReactElement { return categories.data.productCategories.nodes; }); const tagsSelectAsyncProps = useAsyncOptionsProps(async () => { - const tags = await client.query({ query: productTagsQuery }); + const tags = await client.query({ query: productTagsQuery }); return tags.data.productTags.nodes; }); @@ -168,24 +156,6 @@ function ProductForm({ id }: FormProps): React.ReactElement { {() => ( {saveConflict.dialogs} - - - - - - - - - {({ input }) => - input.value ? input.value : - } - - - - - - - option.title} /> - } - /> {(props) => ( & { + price: string; +}; + +function ProductPriceForm({ id }: FormProps): React.ReactElement { + const client = useApolloClient(); + const formApiRef = useFormApiRef(); + + const { data, error, loading, refetch } = useQuery(productPriceFormQuery, { + variables: { id }, + }); + + const initialValues: Partial = data?.product + ? { + ...filter(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, event: FinalFormSubmitEvent) => { + if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected"); + const output = { + ...formValues, + price: parseFloat(formValues.price), + }; + await client.mutate({ + mutation: updateProductPriceFormMutation, + variables: { id, input: output, lastUpdatedAt: data?.product.updatedAt }, + }); + }; + + if (error) { + return ; + } + + if (loading) { + return ; + } + + return ( + + 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={{}} + > + {() => ( + + {saveConflict.dialogs} + + } + /> + + + )} + + ); +} + +export default ProductPriceForm; diff --git a/demo/admin/src/products/ProductVariantsGrid.tsx b/demo/admin/src/products/ProductVariantsGrid.tsx new file mode 100644 index 0000000000..d64319f0c4 --- /dev/null +++ b/demo/admin/src/products/ProductVariantsGrid.tsx @@ -0,0 +1,181 @@ +import { useQuery } from "@apollo/client"; +import { + GridFilterButton, + StackLink, + Toolbar, + ToolbarAutomaticTitleItem, + ToolbarFillSpace, + ToolbarItem, + useBufferedRowCount, + useDataGridRemote, + usePersistentColumnState, +} from "@comet/admin"; +import { Add as AddIcon, Edit } from "@comet/admin-icons"; +import { Box, Button, IconButton } from "@mui/material"; +import { DataGridPro, GridColDef, GridToolbarQuickFilter } from "@mui/x-data-grid-pro"; +import gql from "graphql-tag"; +import * as React from "react"; +import { FormattedMessage } from "react-intl"; + +import { + //GQLCreateProductMutation, + //GQLCreateProductMutationVariables, + //GQLDeleteProductMutation, + //GQLDeleteProductMutationVariables, + GQLProductVariantsListFragment, + GQLProductVariantsListQuery, + GQLProductVariantsListQueryVariables, + //GQLUpdateProductVisibilityMutation, + //GQLUpdateProductVisibilityMutationVariables, +} from "./ProductVariantsGrid.generated"; + +function ProductVariantsGridToolbar() { + return ( + + + + + + + + + + + + + + ); +} + +function ProductVariantsGrid({ productId }: { productId: string }) { + const dataGridProps = { ...useDataGridRemote(), ...usePersistentColumnState("ProductVariantsGrid") }; + //const sortModel = dataGridProps.sortModel; + //const client = useApolloClient(); + + const columns: GridColDef[] = [ + { field: "name", headerName: "Name", width: 150 }, + /* + { + field: "visible", + headerName: "Visible", + width: 100, + type: "boolean", + renderCell: (params) => { + return ( + { + await client.mutate({ + mutation: updateProductVisibilityMutation, + variables: { id: params.row.id, visible }, + optimisticResponse: { + __typename: "Mutation", + updateProductVisibility: { __typename: "Product", id: params.row.id, visible }, + }, + }); + }} + /> + ); + }, + }, + */ + { + field: "action", + headerName: "", + sortable: false, + filterable: false, + renderCell: (params) => { + return ( + <> + + + + {/* + + */} + + ); + }, + }, + ]; + + const { data, loading, error } = useQuery(productVariantsQuery, { + variables: { + productId, + /* + ...muiGridFilterToGql(columns, dataGridProps.filterModel), + offset: dataGridProps.page * dataGridProps.pageSize, + limit: dataGridProps.pageSize, + sort: muiGridSortToGql(sortModel), + */ + }, + }); + const rows = data?.product.variants ?? []; + const rowCount = useBufferedRowCount(data?.product.variants.length); + + return ( + + + + ); +} + +const productVariantsFragment = gql` + fragment ProductVariantsList on ProductVariant { + id + name + } +`; + +const productVariantsQuery = gql` + query ProductVariantsList($productId: ID!) { + product(id: $productId) { + variants { + ...ProductVariantsList + } + } + } + ${productVariantsFragment} +`; +/* +const deleteProductMutation = gql` + mutation DeleteProductVariant($id: ID!) { + deleteProduct(id: $id) + } +`; + +const createProductMutation = gql` + mutation CreateProductVariant($input: ProductVariantInput!) { + createProduct(input: $input) { + id + } + } +`; +*/ +/* +const updateProductVisibilityMutation = gql` + mutation UpdateProductVisibility($id: ID!, $visible: Boolean!) { + updateProductVariantVisibility(id: $id, visible: $visible) { + id + visible + } + } +`; +*/ + +export default ProductVariantsGrid; diff --git a/demo/admin/src/products/ProductsPage.tsx b/demo/admin/src/products/ProductsPage.tsx index 3592cc7973..1606ea5b36 100644 --- a/demo/admin/src/products/ProductsPage.tsx +++ b/demo/admin/src/products/ProductsPage.tsx @@ -1,9 +1,24 @@ -import { Stack, StackPage, StackSwitch } from "@comet/admin"; +import { + RouterTab, + RouterTabs, + SaveBoundary, + SaveBoundarySaveButton, + Stack, + StackPage, + StackSwitch, + Toolbar, + ToolbarActions, + ToolbarAutomaticTitleItem, + ToolbarBackButton, + ToolbarFillSpace, +} from "@comet/admin"; import React from "react"; import { useIntl } from "react-intl"; import ProductForm from "./ProductForm"; +import ProductPriceForm from "./ProductPriceForm"; import ProductsGrid from "./ProductsGrid"; +import ProductVariantsGrid from "./ProductVariantsGrid"; const ProductsPage: React.FC = () => { const intl = useIntl(); @@ -15,7 +30,53 @@ const ProductsPage: React.FC = () => { - {(selectedId) => } + {(selectedId) => ( + + + + + + + + + + + + + + + + + + + + + + + {(selectedId) => <>TODO: edit variant {selectedId}} + + + TODO: add variant + + + + + + )} diff --git a/packages/admin/admin/src/FinalForm.tsx b/packages/admin/admin/src/FinalForm.tsx index a469c2f933..efd15f1ad0 100644 --- a/packages/admin/admin/src/FinalForm.tsx +++ b/packages/admin/admin/src/FinalForm.tsx @@ -2,7 +2,7 @@ import { getApolloContext } from "@apollo/client"; import { Config, Decorator, FORM_ERROR, FormApi, FormSubscription, MutableState, Mutator, SubmissionErrors, ValidationErrors } from "final-form"; import setFieldData from "final-form-set-field-data"; import * as React from "react"; -import { AnyObject, Form, FormRenderProps, RenderableProps } from "react-final-form"; +import { AnyObject, Form, FormRenderProps, FormSpy, RenderableProps } from "react-final-form"; import { useIntl } from "react-intl"; import { useEditDialogFormApi } from "./EditDialogFormApiContext"; @@ -11,6 +11,7 @@ import { FinalFormContext, FinalFormContextProvider } from "./form/FinalFormCont import { messages } from "./messages"; import { RouterPrompt } from "./router/Prompt"; import { useSubRoutePrefix } from "./router/SubRoute"; +import { Savable, useSaveBoundaryApi } from "./saveBoundary/SaveBoundary"; import { TableQueryContext } from "./table/TableQueryContext"; export const useFormApiRef = , InitialFormValues = Partial>() => @@ -60,6 +61,40 @@ const getSubmitEvent: Mutator = (args: any[], state: MutableState Promise; + subRoutePath: string; + formApi: FormApi; +}) { + const saveBoundaryApi = useSaveBoundaryApi(); + const intl = useIntl(); + + if (saveBoundaryApi) { + //render no RouterPrompt if we are inside a SaveBoundary + return <>{children}; + } + return ( + { + if (formApi.getState().dirty) { + return intl.formatMessage(messages.saveUnsavedChanges); + } + return true; + }} + saveAction={doSave} + subRoutePath={subRoutePath} + > + {children} + + ); +} + export class FinalFormSubmitEvent extends Event { navigatingBack?: boolean; } @@ -86,8 +121,8 @@ export function FinalForm(props: IProps) { ); function RenderForm({ formContext = {}, ...formRenderProps }: FormRenderProps & { formContext: Partial }) { - const intl = useIntl(); const subRoutePrefix = useSubRoutePrefix(); + const saveBoundaryApi = useSaveBoundaryApi(); if (props.apiRef) props.apiRef.current = formRenderProps.form; const { mutators } = formRenderProps.form; const setFieldData = mutators.setFieldData as (...args: any[]) => any; @@ -148,36 +183,35 @@ export function FinalForm(props: IProps) { } }, [formRenderProps.values, setFieldData, registeredFields]); - return ( - - { - if (formRenderProps.form.getState().dirty) { - return intl.formatMessage(messages.saveUnsavedChanges); - } - return true; - }} - saveAction={async () => { - editDialogFormApi?.onFormStatusChange("saving"); - const hasValidationErrors = await waitForValidationToFinish(formRenderProps.form); - - if (hasValidationErrors) { - editDialogFormApi?.onFormStatusChange("error"); - return false; - } + const doSave = React.useCallback(async () => { + editDialogFormApi?.onFormStatusChange("saving"); + const hasValidationErrors = await waitForValidationToFinish(formRenderProps.form); + if (hasValidationErrors) { + editDialogFormApi?.onFormStatusChange("error"); + return false; + } - const submissionErrors = await formRenderProps.form.submit(); + const submissionErrors = await formRenderProps.form.submit(); + if (submissionErrors) { + editDialogFormApi?.onFormStatusChange("error"); + return false; + } - if (submissionErrors) { - editDialogFormApi?.onFormStatusChange("error"); - return false; - } + return true; + }, [formRenderProps.form]); - return true; - }} - // TODO DirtyHandler removal: do we need a resetAction functionality here? - subRoutePath={subRoutePath} - > + return ( + + {saveBoundaryApi && ( + + {(props) => ( + <> + + + )} + + )} +
{renderComponent( @@ -193,7 +227,7 @@ export function FinalForm(props: IProps) {
{formRenderProps.submitError || formRenderProps.error}
)} - + ); } diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index 46358bf73f..8b70d00dbb 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -121,6 +121,16 @@ export { RouterPromptHandler, SaveAction } from "./router/PromptHandler"; export { SubRoute, SubRouteIndexRoute, useSubRoutePrefix } from "./router/SubRoute"; export { RowActionsItem, RowActionsItemProps } from "./rowActions/RowActionsItem"; export { RowActionsMenu, RowActionsMenuProps } from "./rowActions/RowActionsMenu"; +export { + Savable, + SavableProps, + SaveBoundary, + SaveBoundaryApi, + SaveBoundaryApiContext, + useSavable, + useSaveBoundaryApi, +} from "./saveBoundary/SaveBoundary"; +export { SaveBoundarySaveButton } from "./saveBoundary/SaveBoundarySaveButton"; export { Selected } from "./Selected"; export { ISelectionRenderPropArgs, Selection, useSelection } from "./Selection"; export { ISelectionApi } from "./SelectionApi"; diff --git a/packages/admin/admin/src/saveBoundary/SaveBoundary.tsx b/packages/admin/admin/src/saveBoundary/SaveBoundary.tsx new file mode 100644 index 0000000000..91c2eadcb2 --- /dev/null +++ b/packages/admin/admin/src/saveBoundary/SaveBoundary.tsx @@ -0,0 +1,137 @@ +import * as React from "react"; +import { useIntl } from "react-intl"; +import useConstant from "use-constant"; +import { v4 as uuid } from "uuid"; + +import { messages } from "../messages"; +import { RouterPrompt } from "../router/Prompt"; + +export type SaveActionSuccess = boolean; +export interface SaveBoundaryApi { + save: () => Promise; + register: (id: string, props: SavableProps) => void; + unregister: (id: string) => void; +} +export interface Savable { + hasErrors: boolean; + hasChanges: boolean; + saving: boolean; +} + +export const SaveBoundaryApiContext = React.createContext(undefined); +export function useSaveBoundaryApi() { + return React.useContext(SaveBoundaryApiContext); +} + +export const SavableContext = React.createContext(undefined); +export function useSavable() { + return React.useContext(SavableContext); +} + +interface SaveBoundaryProps { + children: React.ReactNode; + subRoutePath?: string; + onAfterSave?: () => void; +} + +export function SaveBoundary({ onAfterSave, ...props }: SaveBoundaryProps) { + const [saving, setSaving] = React.useState(false); + const [hasErrors, setHasErrors] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const saveStates = React.useRef>({}); + const intl = useIntl(); + + const save = React.useCallback(async (): Promise => { + setHasErrors(false); + setSaving(true); + try { + const saveSuccess = !( + await Promise.all( + Object.values(saveStates.current).map((state) => { + return state.doSave(); + }), + ) + ).some((saveSuccess) => !saveSuccess); + if (!saveSuccess) { + setHasErrors(true); + } else { + onAfterSave?.(); + } + return saveSuccess; + } catch (error: unknown) { + setHasErrors(true); + throw error; + } finally { + setSaving(false); + } + }, [onAfterSave]); + + const onSaveStatesChanged = React.useCallback(() => { + const hasChanges = Object.values(saveStates.current).some((saveState) => saveState.hasChanges); + setHasChanges(hasChanges); + }, []); + + const register = React.useCallback( + (id: string, props: SavableProps) => { + saveStates.current[id] = props; + onSaveStatesChanged(); + }, + [onSaveStatesChanged], + ); + const unregister = React.useCallback( + (id: string) => { + delete saveStates.current[id]; + onSaveStatesChanged(); + }, + [onSaveStatesChanged], + ); + + return ( + { + if (hasChanges) { + return intl.formatMessage(messages.saveUnsavedChanges); + } + return true; + }} + saveAction={save} + subRoutePath={props.subRoutePath} + > + + + {props.children} + + + + ); +} + +export interface SavableProps { + hasChanges: boolean; + doSave: () => Promise | SaveActionSuccess; +} + +export function Savable({ doSave, hasChanges }: SavableProps) { + const id = useConstant(() => uuid()); + const saveBoundaryApi = useSaveBoundaryApi(); + if (!saveBoundaryApi) throw new Error("Savable must be inside SaveBoundary"); + React.useEffect(() => { + saveBoundaryApi.register(id, { doSave, hasChanges }); + return function cleanup() { + saveBoundaryApi.unregister(id); + }; + }, [id, doSave, hasChanges, saveBoundaryApi]); + return null; +} diff --git a/packages/admin/admin/src/saveBoundary/SaveBoundarySaveButton.tsx b/packages/admin/admin/src/saveBoundary/SaveBoundarySaveButton.tsx new file mode 100644 index 0000000000..1c1133dbf0 --- /dev/null +++ b/packages/admin/admin/src/saveBoundary/SaveBoundarySaveButton.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { SaveButton, SaveButtonProps } from "../common/buttons/save/SaveButton"; +import { useSavable, useSaveBoundaryApi } from "./SaveBoundary"; + +export function SaveBoundarySaveButton(props: SaveButtonProps) { + const saveBoundaryState = useSavable(); + const saveBoundaryApi = useSaveBoundaryApi(); + if (!saveBoundaryState || !saveBoundaryApi) throw new Error("SaveBoundarySaveButton must be inside SaveBoundary"); + return ( + { + return saveBoundaryApi.save(); + }} + {...props} + /> + ); +} diff --git a/storybook/src/admin/save-boundary/SaveBoundary.tsx b/storybook/src/admin/save-boundary/SaveBoundary.tsx new file mode 100644 index 0000000000..98e8c6160f --- /dev/null +++ b/storybook/src/admin/save-boundary/SaveBoundary.tsx @@ -0,0 +1,60 @@ +import { Savable, SaveBoundary, SaveBoundarySaveButton } from "@comet/admin"; +import { storiesOf } from "@storybook/react"; +import * as React from "react"; + +import { storyRouterDecorator } from "../../story-router.decorator"; + +async function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +function DemoForm() { + console.log("Render DemoForm"); + const [saving, setSaving] = React.useState(false); + const [input, setInput] = React.useState(""); + + const doSave = React.useCallback(async () => { + setSaving(true); + await delay(1000); + setSaving(false); + if (input == "err") { + return false; + } + return true; + }, [input]); + return ( +
+ DemoForm + setInput(e.target.value)} /> + {saving && <>Saving...} +
+ ); +} +function UnrelatedChild() { + console.log("Render UnrelatedChild"); + return

UnrelatedChild

; +} + +function SaveButtonContainer() { + console.log("Render SaveButtonContainer"); + return ; +} + +function Story() { + return ( + + + + + + + ); +} + +storiesOf("@comet/admin/save-range", module) + .addDecorator(storyRouterDecorator()) + .add("SaveBoundary", () => );