From 1f6cf691b9f060c00f0054e3caf477a1005eb274 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 25 Apr 2024 15:41:42 +0200 Subject: [PATCH] feat: various improvements and features --- .../src/components/Table/useTableRow.tsx | 24 +- packages/app-aco/src/config/table/Column.tsx | 4 +- packages/app-aco/src/contexts/app.tsx | 8 +- packages/app-aco/src/contexts/records.tsx | 2 +- packages/app-aco/src/dialogs/dialogs.tsx | 121 ---- packages/app-aco/src/dialogs/index.tsx | 3 - packages/app-aco/src/dialogs/styled.tsx | 13 - .../app-aco/src/dialogs/useCreateDialog.tsx | 4 +- .../app-aco/src/dialogs/useDeleteDialog.tsx | 4 +- .../app-aco/src/dialogs/useEditDialog.tsx | 4 +- .../src/dialogs/useMoveToFolderDialog.tsx | 4 +- .../src/dialogs/useSetPermissionsDialog.tsx | 4 +- packages/app-admin/src/base/Admin.tsx | 7 +- packages/app-admin/src/base/ui/Menu.tsx | 6 +- packages/app-admin/src/base/ui/Navigation.tsx | 4 +- .../src/components/Buttons/Buttons.styles.tsx | 1 + .../src/components/Dialogs/CustomDialog.tsx | 63 ++ .../src/components/Dialogs}/Dialog.tsx | 26 +- .../src/components/Dialogs/DialogsContext.tsx | 165 +++++ .../src/components/Dialogs/styled.tsx | 15 + .../src/components/Dialogs}/useDialogs.ts | 6 +- packages/app-admin/src/hooks/index.ts | 1 + packages/app-admin/src/index.ts | 1 + .../src/plugins/cms/ApwOnEntryPublish.tsx | 220 +++---- .../src/components/fields/Aliases.tsx | 2 +- .../src/components/fields/Tags.tsx | 4 +- .../configComponents/Browser/Table/Column.tsx | 2 +- .../FileManagerView/index.tsx | 15 +- .../src/entries.graphql.ts | 15 +- .../src/types/index.ts | 5 + packages/app-headless-cms/src/HeadlessCMS.tsx | 2 - .../ContentEntryForm/ContentEntryForm.tsx | 277 ++++----- .../ContentEntryFormPreview.tsx | 138 ++--- .../ContentEntryForm/FieldElement.tsx | 44 +- .../components/ContentEntryForm/Fields.tsx | 22 +- .../ContentEntryForm/Header/Header.tsx | 50 +- .../RevisionSelector.styles.tsx | 1 + .../useSaveAndPublish.tsx | 11 +- .../ContentEntryForm/useContentEntryForm.ts | 566 +++++++++--------- .../{Editor.tsx => ContentModelEditor.tsx} | 13 +- .../ModelFieldProvider/ModelFieldContext.tsx | 14 + .../ModelFieldProvider/useModelField.ts | 19 +- .../config/IsApplicableToCurrentModel.tsx | 20 + .../list/Browser/Table/Column.tsx | 14 +- .../app-headless-cms/src/admin/constants.ts | 18 +- .../src/admin/contexts/Cms/index.tsx | 106 ++-- .../plugins/entry/DefaultOnEntryPublish.ts | 98 --- .../plugins/fieldRenderers/DynamicSection.tsx | 15 +- .../dynamicZone/AddTemplate.tsx | 8 +- .../dynamicZone/MultiValueDynamicZone.tsx | 16 +- .../dynamicZone/SingleValueDynamicZone.tsx | 67 ++- .../dynamicZone/useTemplateTypename.ts | 35 ++ .../admin/plugins/fieldRenderers/hidden.tsx | 17 + .../object/multipleObjectsAccordion.tsx | 20 +- .../object/singleObjectAccordion.tsx | 23 +- .../object/singleObjectInline.tsx | 37 +- .../components/SimpleSingleRenderer.tsx | 5 + .../plugins/fieldValidators/dynamicZone.tsx | 4 +- .../views/contentEntries/ContentEntries.tsx | 7 +- .../views/contentEntries/ContentEntry.tsx | 2 - .../ContentEntry/ContentEntryContext.tsx | 22 +- .../ContentEntry/RevisionListItem.tsx | 2 +- .../ContentEntry/useRevision.tsx | 36 +- .../contentModels/ContentModelEditor.tsx | 4 +- packages/app-headless-cms/src/allPlugins.ts | 4 +- packages/app-headless-cms/src/components.ts | 13 + .../pages/list/Browser/Table/Column.tsx | 2 +- .../configs/list/Browser/Table/Column.tsx | 2 +- packages/app/src/core/AddRoute.tsx | 7 +- 69 files changed, 1353 insertions(+), 1161 deletions(-) delete mode 100644 packages/app-aco/src/dialogs/dialogs.tsx create mode 100644 packages/app-admin/src/components/Dialogs/CustomDialog.tsx rename packages/{app-aco/src/dialogs => app-admin/src/components/Dialogs}/Dialog.tsx (66%) create mode 100644 packages/app-admin/src/components/Dialogs/DialogsContext.tsx create mode 100644 packages/app-admin/src/components/Dialogs/styled.tsx rename packages/{app-aco/src/dialogs => app-admin/src/components/Dialogs}/useDialogs.ts (58%) rename packages/app-headless-cms/src/admin/components/ContentModelEditor/{Editor.tsx => ContentModelEditor.tsx} (89%) create mode 100644 packages/app-headless-cms/src/admin/config/IsApplicableToCurrentModel.tsx delete mode 100644 packages/app-headless-cms/src/admin/plugins/entry/DefaultOnEntryPublish.ts create mode 100644 packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/useTemplateTypename.ts create mode 100644 packages/app-headless-cms/src/admin/plugins/fieldRenderers/hidden.tsx diff --git a/packages/app-aco/src/components/Table/useTableRow.tsx b/packages/app-aco/src/components/Table/useTableRow.tsx index fb99f99604f..b81e06a23c7 100644 --- a/packages/app-aco/src/components/Table/useTableRow.tsx +++ b/packages/app-aco/src/components/Table/useTableRow.tsx @@ -22,16 +22,20 @@ export const TableRowProvider = ({ row, children }: TableRowProviderProps return {children}; }; -export const useTableRow = & DefaultData>() => { - const context = useContext>( - TableRowContext as unknown as Context> - ); - - if (!context) { - throw Error( - `TableRowContext is missing in the component tree. Are you using "useTableRow()" hook in the right place?` +export const createUseTableRow = ,>() => { + return ,>() => { + const context = useContext>( + TableRowContext as unknown as Context< + TableRowContextData + > ); - } - return context; + if (!context) { + throw Error( + `TableRowContext is missing in the component tree. Are you using "useTableRow()" hook in the right place?` + ); + } + + return context; + }; }; diff --git a/packages/app-aco/src/config/table/Column.tsx b/packages/app-aco/src/config/table/Column.tsx index b67be4237c8..eeb5dd5d1eb 100644 --- a/packages/app-aco/src/config/table/Column.tsx +++ b/packages/app-aco/src/config/table/Column.tsx @@ -1,6 +1,6 @@ import React, { ReactElement } from "react"; import { Property, useIdGenerator } from "@webiny/react-properties"; -import { useTableRow } from "~/components/Table/useTableRow"; +import { createUseTableRow } from "~/components/Table/useTableRow"; import { FolderTableItem, BaseTableItem } from "~/types"; export interface ColumnConfig { @@ -81,4 +81,4 @@ const isFolderRow = (row: BaseTableItem): row is FolderTableItem => { return row.$type === "FOLDER"; }; -export const Column = Object.assign(BaseColumn, { isFolderRow, useTableRow }); +export const Column = Object.assign(BaseColumn, { isFolderRow, createUseTableRow }); diff --git a/packages/app-aco/src/contexts/app.tsx b/packages/app-aco/src/contexts/app.tsx index a07ad709a59..43843ab4cb2 100644 --- a/packages/app-aco/src/contexts/app.tsx +++ b/packages/app-aco/src/contexts/app.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useMemo, useState } from "react"; +import { ApolloClient } from "apollo-client"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { DialogsProvider } from "@webiny/app-admin"; import { AcoApp, AcoAppMode, AcoError, AcoModel, AcoModelField } from "~/types"; import { createGetAppQuery, GetAppResult, GetAppVariables } from "~/graphql/app.gql"; import { FoldersProvider as FoldersContextProvider } from "./folders"; import { SearchRecordsProvider as SearchRecordsContextProvider } from "./records"; -import { DialogsProvider as DialogsContextProvider } from "../dialogs"; import { DisplayError } from "./DisplayError"; -import { ApolloClient } from "apollo-client"; import { NavigateFolderWithRouterProvider } from "~/contexts/navigateFolderWithRouter"; -import { CircularProgress } from "@webiny/ui/Progress"; import { AcoListProvider } from "~/contexts/acoList"; export interface AcoAppProviderContext { @@ -253,7 +253,7 @@ export const AcoAppProvider = ({ createStorageKey={createNavigateFolderStorageKey} > - {children} + {children} diff --git a/packages/app-aco/src/contexts/records.tsx b/packages/app-aco/src/contexts/records.tsx index 7639fd292f1..6b3f3b3384d 100644 --- a/packages/app-aco/src/contexts/records.tsx +++ b/packages/app-aco/src/contexts/records.tsx @@ -175,7 +175,7 @@ export const SearchRecordsProvider = ({ children }: Props) => { const { after, limit, sort: sorting, search, where } = params; /** - * Avoiding to fetch records in case they have already been fetched. + * Avoiding fetching records in case they have already been fetched. * This happens when visiting a list with all records loaded and receives "after" param. */ const totalCount = meta?.totalCount || 0; diff --git a/packages/app-aco/src/dialogs/dialogs.tsx b/packages/app-aco/src/dialogs/dialogs.tsx deleted file mode 100644 index fae6b07aa0f..00000000000 --- a/packages/app-aco/src/dialogs/dialogs.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { ReactNode, useState } from "react"; - -import { i18n } from "@webiny/app/i18n"; -import { useSnackbar } from "@webiny/app-admin"; - -import { Dialog } from "~/dialogs/Dialog"; -import { GenericFormData } from "@webiny/form"; - -const t = i18n.ns("app-aco/contexts/dialogs"); - -interface ShowDialogParams { - title: ReactNode; - message: ReactNode; - acceptLabel: ReactNode; - cancelLabel: ReactNode; - loadingLabel: ReactNode; - onAccept?: (data: GenericFormData) => void; - onClose?: () => void; -} - -export interface DialogsContext { - showDialog: (params: ShowDialogParams) => void; -} - -interface DialogsProviderProps { - children: ReactNode; -} - -interface State { - title: ReactNode; - message: ReactNode; - acceptLabel: ReactNode; - cancelLabel: ReactNode; - loadingLabel: ReactNode; - onAccept?: (data: GenericFormData) => void; - onClose?: () => void; - open: boolean; - loading: boolean; -} - -export const initializeState = (): State => ({ - title: t`Confirmation`, - message: undefined, - acceptLabel: t`Confirm`, - cancelLabel: t`Cancel`, - loadingLabel: t`Loading`, - onAccept: undefined, - onClose: undefined, - open: false, - loading: false -}); - -export const Dialogs = React.createContext(undefined); - -export const DialogsProvider = ({ children }: DialogsProviderProps) => { - const { showSnackbar } = useSnackbar(); - - const [state, setState] = useState(initializeState()); - - const showDialog = (params: ShowDialogParams) => { - setState(state => ({ - ...state, - ...params, - open: true - })); - }; - - const closeDialog = () => { - if (typeof state.onClose === "function") { - state.onClose(); - } - - setState(state => ({ - ...state, - open: false - })); - }; - - const onSubmit = async (data: GenericFormData) => { - try { - if (typeof state.onAccept === "function") { - setState(state => ({ - ...state, - loading: true - })); - - await state.onAccept(data); - } - } catch (error) { - showSnackbar(error.message); - } finally { - setState(state => ({ - ...state, - loading: false - })); - closeDialog(); - } - }; - - const context = { - showDialog, - closeDialog - }; - - return ( - - {children} - - - ); -}; diff --git a/packages/app-aco/src/dialogs/index.tsx b/packages/app-aco/src/dialogs/index.tsx index ee7eb973357..8aa15dfc2e3 100644 --- a/packages/app-aco/src/dialogs/index.tsx +++ b/packages/app-aco/src/dialogs/index.tsx @@ -1,8 +1,5 @@ -export * from "./Dialog"; -export * from "./dialogs"; export * from "./useCreateDialog"; export * from "./useDeleteDialog"; -export * from "./useDialogs"; export * from "./useEditDialog"; export * from "./useMoveToFolderDialog"; export * from "./useSetPermissionsDialog"; diff --git a/packages/app-aco/src/dialogs/styled.tsx b/packages/app-aco/src/dialogs/styled.tsx index addca5002e9..0926a8fe827 100644 --- a/packages/app-aco/src/dialogs/styled.tsx +++ b/packages/app-aco/src/dialogs/styled.tsx @@ -1,17 +1,4 @@ import styled from "@emotion/styled"; -import { Dialog, DialogActions as DefaultDialogActions } from "@webiny/ui/Dialog"; - -export const DialogContainer = styled(Dialog)` - z-index: 100; - .mdc-dialog__surface { - width: 600px; - min-width: 600px; - } -`; - -export const DialogActions = styled(DefaultDialogActions)` - justify-content: space-between; -`; export const DialogFoldersContainer = styled("div")` max-height: 30vh; diff --git a/packages/app-aco/src/dialogs/useCreateDialog.tsx b/packages/app-aco/src/dialogs/useCreateDialog.tsx index 55f597f4365..fd85d352bce 100644 --- a/packages/app-aco/src/dialogs/useCreateDialog.tsx +++ b/packages/app-aco/src/dialogs/useCreateDialog.tsx @@ -8,7 +8,7 @@ import { Typography } from "@webiny/ui/Typography"; import { validation } from "@webiny/validation"; import { FolderTree } from "~/components"; -import { useDialogs } from "~/dialogs/useDialogs"; +import { useDialogs } from "@webiny/app-admin"; import { DialogFoldersContainer } from "~/dialogs/styled"; import { useFolders } from "~/hooks"; import { ROOT_FOLDER } from "~/constants"; @@ -103,7 +103,7 @@ export const useCreateDialog = (): UseCreateDialogResponse => { dialogs.showDialog({ title: "Create a new folder", - message: , + content: , acceptLabel: "Create folder", cancelLabel: "Cancel", loadingLabel: "Creating folder", diff --git a/packages/app-aco/src/dialogs/useDeleteDialog.tsx b/packages/app-aco/src/dialogs/useDeleteDialog.tsx index ebe07798bde..ecd6621a451 100644 --- a/packages/app-aco/src/dialogs/useDeleteDialog.tsx +++ b/packages/app-aco/src/dialogs/useDeleteDialog.tsx @@ -1,6 +1,6 @@ import { useSnackbar } from "@webiny/app-admin"; -import { useDialogs } from "~/dialogs/useDialogs"; +import { useDialogs } from "@webiny/app-admin"; import { useFolders } from "~/hooks"; import { FolderItem } from "~/types"; import { useCallback } from "react"; @@ -35,7 +35,7 @@ export const useDeleteDialog = (): UseDeleteDialogResponse => { const showDialog = ({ folder }: ShowDialogParams) => { dialogs.showDialog({ title: "Delete folder", - message: `You are about to delete the folder "${folder.title}"! Are you sure you want to continue?`, + content: `You are about to delete the folder "${folder.title}"! Are you sure you want to continue?`, acceptLabel: "Delete folder", cancelLabel: "Cancel", loadingLabel: "Deleting folder", diff --git a/packages/app-aco/src/dialogs/useEditDialog.tsx b/packages/app-aco/src/dialogs/useEditDialog.tsx index df51e5a7d2a..8439a99ad0c 100644 --- a/packages/app-aco/src/dialogs/useEditDialog.tsx +++ b/packages/app-aco/src/dialogs/useEditDialog.tsx @@ -8,7 +8,7 @@ import { Typography } from "@webiny/ui/Typography"; import { FolderTree } from "~/components"; import { ROOT_FOLDER } from "~/constants"; -import { useDialogs } from "~/dialogs/useDialogs"; +import { useDialogs } from "@webiny/app-admin"; import { DialogFoldersContainer } from "~/dialogs/styled"; import { useFolders } from "~/hooks"; import { FolderItem } from "~/types"; @@ -95,7 +95,7 @@ export const useEditDialog = (): UseEditDialogResponse => { const showDialog = ({ folder }: ShowDialogParams) => { dialog.showDialog({ title: "Edit folder", - message: , + content: , acceptLabel: "Edit folder", cancelLabel: "Cancel", loadingLabel: "Editing folder", diff --git a/packages/app-aco/src/dialogs/useMoveToFolderDialog.tsx b/packages/app-aco/src/dialogs/useMoveToFolderDialog.tsx index 55cb634b291..09166b89f26 100644 --- a/packages/app-aco/src/dialogs/useMoveToFolderDialog.tsx +++ b/packages/app-aco/src/dialogs/useMoveToFolderDialog.tsx @@ -3,7 +3,7 @@ import React, { ReactNode } from "react"; import { i18n } from "@webiny/app/i18n"; import { Bind, GenericFormData } from "@webiny/form"; import { Typography } from "@webiny/ui/Typography"; -import { useDialogs } from "~/dialogs/useDialogs"; +import { useDialogs } from "@webiny/app-admin"; import { FolderTree } from "~/components"; import { DialogFoldersContainer } from "~/dialogs/styled"; @@ -63,7 +63,7 @@ export const useMoveToFolderDialog = (): UseMoveToFolderDialogResponse => { }: ShowDialogParams) => { dialogs.showDialog({ title, - message: , + content: , acceptLabel, cancelLabel, loadingLabel, diff --git a/packages/app-aco/src/dialogs/useSetPermissionsDialog.tsx b/packages/app-aco/src/dialogs/useSetPermissionsDialog.tsx index e01094c4026..f4288073e5f 100644 --- a/packages/app-aco/src/dialogs/useSetPermissionsDialog.tsx +++ b/packages/app-aco/src/dialogs/useSetPermissionsDialog.tsx @@ -8,7 +8,7 @@ import { UsersTeamsMultiAutocomplete } from "./DialogSetPermissions/UsersTeamsMu import { UsersTeamsSelection } from "./DialogSetPermissions/UsersTeamsSelection"; import { LIST_FOLDER_LEVEL_PERMISSIONS_TARGETS } from "./DialogSetPermissions/graphql"; -import { useDialogs } from "~/dialogs/useDialogs"; +import { useDialogs } from "@webiny/app-admin"; import { useFolders } from "~/hooks"; import { FolderItem, FolderLevelPermissionsTarget, FolderPermission } from "~/types"; @@ -116,7 +116,7 @@ export const useSetPermissionsDialog = (): UseSetPermissionsDialogResponse => { const showDialog = ({ folder }: ShowDialogParams) => { dialogs.showDialog({ title: `Manage permissions - ${folder.title}`, - message: , + content: , acceptLabel: "Save", cancelLabel: "Cancel", loadingLabel: "Updating permissions", diff --git a/packages/app-admin/src/base/Admin.tsx b/packages/app-admin/src/base/Admin.tsx index cf5dce7428d..dba218aaf69 100644 --- a/packages/app-admin/src/base/Admin.tsx +++ b/packages/app-admin/src/base/Admin.tsx @@ -1,6 +1,8 @@ import React from "react"; import { App, Provider } from "@webiny/app"; +import { ThemeProvider } from "@webiny/app-theme"; import { WcpProvider } from "@webiny/app-wcp"; +import { CircularProgress } from "@webiny/ui/Progress"; import { ApolloClientFactory, createApolloProvider } from "./providers/ApolloProvider"; import { Base } from "./Base"; import { createTelemetryProvider } from "./providers/TelemetryProvider"; @@ -8,8 +10,7 @@ import { createUiStateProvider } from "./providers/UiStateProvider"; import { SearchProvider } from "./ui/Search"; import { UserMenuProvider } from "./ui/UserMenu"; import { NavigationProvider } from "./ui/Navigation"; -import { CircularProgress } from "@webiny/ui/Progress"; -import { ThemeProvider } from "@webiny/app-theme"; +import { createDialogsProvider } from "~/components/Dialogs/DialogsContext"; export interface AdminProps { createApolloClient: ApolloClientFactory; @@ -20,6 +21,7 @@ export const Admin = ({ children, createApolloClient }: AdminProps) => { const ApolloProvider = createApolloProvider(createApolloClient); const TelemetryProvider = createTelemetryProvider(); const UiStateProvider = createUiStateProvider(); + const DialogsProvider = createDialogsProvider(); return ( @@ -31,6 +33,7 @@ export const Admin = ({ children, createApolloClient }: AdminProps) => { + {children} diff --git a/packages/app-admin/src/base/ui/Menu.tsx b/packages/app-admin/src/base/ui/Menu.tsx index 46923326bc7..70f8cb11fe8 100644 --- a/packages/app-admin/src/base/ui/Menu.tsx +++ b/packages/app-admin/src/base/ui/Menu.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useEffect } from "react"; -import { useNavigation } from "~/index"; +import { makeDecoratable, useNavigation } from "~/index"; export interface MenuUpdater { (menuItem: MenuData | undefined | null): MenuData | undefined; @@ -80,7 +80,7 @@ const mergeMenuItems = (item1: MenuData, item2: MenuData): MenuData => { /** * Register a new menu item into the Admin app. */ -export const AddMenu = ({ children, ...props }: MenuProps) => { +export const AddMenu = makeDecoratable("AddMenu", ({ children, ...props }: MenuProps) => { const menu = useMenu(); const navigation = useNavigation(); @@ -170,6 +170,6 @@ export const AddMenu = ({ children, ...props }: MenuProps) => { } return {children}; -}; +}); AddMenu.defaultProps = { tags: [] }; diff --git a/packages/app-admin/src/base/ui/Navigation.tsx b/packages/app-admin/src/base/ui/Navigation.tsx index 3ca54c8f436..4fe93b8124e 100644 --- a/packages/app-admin/src/base/ui/Navigation.tsx +++ b/packages/app-admin/src/base/ui/Navigation.tsx @@ -142,13 +142,13 @@ export const NavigationProvider = (Component: React.ComponentType) => { }; }; -export const Navigation = () => { +export const Navigation = makeDecoratable("Navigation", () => { return ( ); -}; +}); export const NavigationRenderer = makeDecoratable("NavigationRenderer", createVoidComponent()); diff --git a/packages/app-admin/src/components/Buttons/Buttons.styles.tsx b/packages/app-admin/src/components/Buttons/Buttons.styles.tsx index 0b16c495d3a..204eb0f6ca8 100644 --- a/packages/app-admin/src/components/Buttons/Buttons.styles.tsx +++ b/packages/app-admin/src/components/Buttons/Buttons.styles.tsx @@ -2,4 +2,5 @@ import styled from "@emotion/styled"; export const ButtonContainer = styled("div")` margin-left: 16px; + text-wrap: nowrap; `; diff --git a/packages/app-admin/src/components/Dialogs/CustomDialog.tsx b/packages/app-admin/src/components/Dialogs/CustomDialog.tsx new file mode 100644 index 00000000000..42aa6de2e0c --- /dev/null +++ b/packages/app-admin/src/components/Dialogs/CustomDialog.tsx @@ -0,0 +1,63 @@ +import React, { useContext } from "react"; +import { Form, FormOnSubmit, GenericFormData, useForm } from "@webiny/form"; +import { DialogContainer } from "./styled"; + +interface DialogProps { + onSubmit: (data: GenericFormData) => void; + closeDialog: () => void; + loading: boolean; + open: boolean; + children: JSX.Element; +} + +export const CustomDialog = ({ open, loading, closeDialog, onSubmit, children }: DialogProps) => { + const handleSubmit: FormOnSubmit = data => { + onSubmit(data); + }; + + return ( + + {open ? ( +
+ {() => ( + + {children} + + )} +
+ ) : null} +
+ ); +}; + +export interface CustomDialogContext { + loading: boolean; + closeDialog: () => void; + submit: () => void; +} + +const CustomDialogContext = React.createContext(undefined); + +interface CustomDialogProviderProps { + loading: boolean; + closeDialog: () => void; + children: JSX.Element; +} + +const CustomDialogProvider = ({ loading, closeDialog, children }: CustomDialogProviderProps) => { + const form = useForm(); + + const context: CustomDialogContext = { submit: form.submit, loading, closeDialog }; + + return {children}; +}; + +export const useCustomDialog = () => { + const context = useContext(CustomDialogContext); + + if (!context) { + throw new Error("useCustomDialog must be used within the CustomDialogProvider."); + } + + return context; +}; diff --git a/packages/app-aco/src/dialogs/Dialog.tsx b/packages/app-admin/src/components/Dialogs/Dialog.tsx similarity index 66% rename from packages/app-aco/src/dialogs/Dialog.tsx rename to packages/app-admin/src/components/Dialogs/Dialog.tsx index 92df9e45a11..9aea4cdd9b9 100644 --- a/packages/app-aco/src/dialogs/Dialog.tsx +++ b/packages/app-admin/src/components/Dialogs/Dialog.tsx @@ -9,10 +9,10 @@ import { DialogContainer } from "./styled"; interface DialogProps { title: ReactNode; - message: ReactNode; - acceptLabel: ReactNode; - cancelLabel: ReactNode; - loadingLabel: ReactNode; + content: ReactNode; + acceptLabel?: ReactNode; + cancelLabel?: ReactNode; + loadingLabel?: ReactNode; onSubmit: (data: GenericFormData) => void; closeDialog: () => void; loading: boolean; @@ -23,10 +23,10 @@ export const Dialog = ({ open, loading, title, - message, + content, acceptLabel, cancelLabel, - loadingLabel, + loadingLabel = "Loading...", closeDialog, onSubmit }: DialogProps) => { @@ -40,12 +40,18 @@ export const Dialog = ({
{({ submit }) => ( <> - {title} {loading && } - {message} + {title} + {content} - {cancelLabel} - {acceptLabel} + {cancelLabel ? ( + + {cancelLabel} + + ) : null} + {acceptLabel ? ( + {acceptLabel} + ) : null} )} diff --git a/packages/app-admin/src/components/Dialogs/DialogsContext.tsx b/packages/app-admin/src/components/Dialogs/DialogsContext.tsx new file mode 100644 index 00000000000..50a30934ac8 --- /dev/null +++ b/packages/app-admin/src/components/Dialogs/DialogsContext.tsx @@ -0,0 +1,165 @@ +import React, { ReactNode, useState } from "react"; +import { GenericFormData } from "@webiny/form"; +import { useSnackbar } from "~/hooks"; +import { Dialog } from "./Dialog"; +import { CustomDialog } from "./CustomDialog"; + +interface ShowDialogParams { + title: ReactNode; + content: ReactNode; + actions?: JSX.Element; + acceptLabel?: ReactNode; + cancelLabel?: ReactNode; + loadingLabel?: ReactNode; + onAccept?: (data: GenericFormData) => void; + onClose?: () => void; +} + +interface ShowCustomDialogParams { + element: JSX.Element; + onSubmit?: (data: GenericFormData) => void; +} + +export interface DialogsContext { + showDialog: (params: ShowDialogParams) => void; + showCustomDialog: (params: ShowCustomDialogParams) => void; +} + +interface DialogsProviderProps { + children: ReactNode; +} + +interface State { + open: boolean; + loading: boolean; + title: ReactNode; + content: ReactNode; + acceptLabel: ReactNode; + cancelLabel: ReactNode; + loadingLabel: ReactNode; + element?: JSX.Element; + onAccept?: (data: GenericFormData) => void; + onClose?: () => void; +} + +export const initializeState = (): State => ({ + title: `Confirmation`, + content: undefined, + acceptLabel: `Confirm`, + cancelLabel: `Cancel`, + loadingLabel: `Loading`, + onAccept: undefined, + onClose: undefined, + open: false, + loading: false +}); + +export const DialogsContext = React.createContext(undefined); + +export const DialogsProvider = ({ children }: DialogsProviderProps) => { + const { showSnackbar } = useSnackbar(); + + const [state, setState] = useState(initializeState()); + + const showDialog = (params: ShowDialogParams | JSX.Element) => { + setState(state => ({ + ...state, + ...params, + open: true + })); + }; + + const showCustomDialog = ({ onSubmit, element }: ShowCustomDialogParams) => { + setState(state => ({ + ...state, + element, + onAccept: onSubmit, + open: true + })); + }; + + const closeDialog = () => { + if (typeof state.onClose === "function") { + state.onClose(); + } + + setState(state => ({ + ...state, + open: false, + element: undefined, + content: null + })); + }; + + const onSubmit = async (data: GenericFormData) => { + try { + if (typeof state.onAccept === "function") { + setState(state => ({ + ...state, + loading: true + })); + + await state.onAccept(data); + } + } catch (error) { + showSnackbar(error.message); + } finally { + setState(state => ({ + ...state, + loading: false + })); + closeDialog(); + } + }; + + const context = { + showDialog, + showCustomDialog, + closeDialog + }; + + return ( + + {children} + <> + {state.element ? ( + + {state.element} + + ) : null} + {!state.element ? ( + + ) : null} + + + ); +}; + +interface DialogsProviderProps { + children: React.ReactNode; +} + +export const createDialogsProvider = () => (Component: React.ComponentType) => { + return function DialogsProviderDecorator({ children }: DialogsProviderProps) { + return ( + + {children} + + ); + }; +}; diff --git a/packages/app-admin/src/components/Dialogs/styled.tsx b/packages/app-admin/src/components/Dialogs/styled.tsx new file mode 100644 index 00000000000..b724f8cdd4f --- /dev/null +++ b/packages/app-admin/src/components/Dialogs/styled.tsx @@ -0,0 +1,15 @@ +import styled from "@emotion/styled"; +import { Dialog, DialogActions as DefaultDialogActions } from "@webiny/ui/Dialog"; + +export const DialogContainer = styled(Dialog)` + z-index: 100; + .mdc-dialog__surface { + width: 600px; + min-width: 600px; + overflow: visible; + } +`; + +export const DialogActions = styled(DefaultDialogActions)` + justify-content: space-between; +`; diff --git a/packages/app-aco/src/dialogs/useDialogs.ts b/packages/app-admin/src/components/Dialogs/useDialogs.ts similarity index 58% rename from packages/app-aco/src/dialogs/useDialogs.ts rename to packages/app-admin/src/components/Dialogs/useDialogs.ts index 4202f15d407..6117cec045c 100644 --- a/packages/app-aco/src/dialogs/useDialogs.ts +++ b/packages/app-admin/src/components/Dialogs/useDialogs.ts @@ -1,9 +1,9 @@ import { useContext } from "react"; -import { Dialogs } from "~/dialogs/dialogs"; +import { DialogsContext } from "./DialogsContext"; export const useDialogs = () => { - const context = useContext(Dialogs); + const context = useContext(DialogsContext); if (!context) { throw new Error("useDialogs must be used within a DialogsContext.Provider"); @@ -11,3 +11,5 @@ export const useDialogs = () => { return context; }; + +export { useCustomDialog } from "./CustomDialog"; diff --git a/packages/app-admin/src/hooks/index.ts b/packages/app-admin/src/hooks/index.ts index a3a404e6bfd..dd1812500dc 100644 --- a/packages/app-admin/src/hooks/index.ts +++ b/packages/app-admin/src/hooks/index.ts @@ -1,5 +1,6 @@ export * from "./useConfirmationDialog"; export * from "./useDialog"; +export * from "../components/Dialogs/useDialogs"; export * from "./useSnackbar"; export * from "./useKeyHandler"; export * from "./useShiftKey"; diff --git a/packages/app-admin/src/index.ts b/packages/app-admin/src/index.ts index f787f86becc..9560d3ac5ae 100644 --- a/packages/app-admin/src/index.ts +++ b/packages/app-admin/src/index.ts @@ -32,6 +32,7 @@ export * from "./plugins/PermissionRendererPlugin"; // Components export { AppInstaller } from "./components/AppInstaller"; export * from "./components/Buttons"; +export { DialogsProvider } from "./components/Dialogs/DialogsContext"; export * from "./components/OptionsMenu"; export * from "./components/Filters"; export * from "./components/BulkActions"; diff --git a/packages/app-apw/src/plugins/cms/ApwOnEntryPublish.tsx b/packages/app-apw/src/plugins/cms/ApwOnEntryPublish.tsx index f86447bc1d4..65e7b413569 100644 --- a/packages/app-apw/src/plugins/cms/ApwOnEntryPublish.tsx +++ b/packages/app-apw/src/plugins/cms/ApwOnEntryPublish.tsx @@ -1,7 +1,9 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback } from "react"; import dotPropImmutable from "dot-prop-immutable"; import { useNavigate } from "@webiny/react-router"; import { i18n } from "@webiny/app/i18n"; +import { ContentEntryEditorConfig, useContentEntry } from "@webiny/app-headless-cms"; +import { useApolloClient } from "@apollo/react-hooks"; import { ShowConfirmationOnAccept, useConfirmationDialog, useSnackbar } from "@webiny/app-admin"; import { ApwContentReviewContent, ApwContentTypes } from "~/types"; import { @@ -11,8 +13,6 @@ import { } from "~/graphql/contentReview.gql"; import { IS_REVIEW_REQUIRED_QUERY } from "../graphql"; import { routePaths } from "~/utils"; -import { useCms } from "@webiny/app-headless-cms/admin/hooks"; -import { useApolloClient } from "@apollo/react-hooks"; const t = i18n.ns("app-apw/cms/dialog"); @@ -22,122 +22,126 @@ interface Resolve { type CreateContentReviewInput = Pick; -export const ApwOnEntryPublish = () => { - const { onEntryRevisionPublish } = useCms(); - const client = useApolloClient(); - const { showSnackbar } = useSnackbar(); - const navigate = useNavigate(); +const { ContentEntry } = ContentEntryEditorConfig; - const { showConfirmation: showRequestReviewConfirmation } = useConfirmationDialog({ - title: t`Request review`, - message: ( -

- {t`This content requires peer review approval before it can be published. +export const ApwOnEntryPublish = ContentEntry.useRevision.createDecorator(baseHook => { + return hookParams => { + const hook = baseHook(hookParams); + const { contentModel, entry } = useContentEntry(); + const client = useApolloClient(); + const { showSnackbar } = useSnackbar(); + const navigate = useNavigate(); + + const { showConfirmation: showRequestReviewConfirmation } = useConfirmationDialog({ + title: t`Request review`, + message: ( +

+ {t`This content requires peer review approval before it can be published. {separator} Do you wish to request a review?`({ separator:
})} -

- ) - }); +

+ ) + }); - const handleRequestReview = useCallback( - (resolve: Resolve, input: CreateContentReviewInput): ShowConfirmationOnAccept => { - return async () => { - const response = await client.mutate< - CreateContentReviewMutationResponse, - CreateApwContentReviewMutationVariables - >({ - mutation: CREATE_CONTENT_REVIEW_MUTATION, - variables: { - data: { - content: input + const handleRequestReview = useCallback( + (resolve: Resolve, input: CreateContentReviewInput): ShowConfirmationOnAccept => { + return async () => { + const response = await client.mutate< + CreateContentReviewMutationResponse, + CreateApwContentReviewMutationVariables + >({ + mutation: CREATE_CONTENT_REVIEW_MUTATION, + variables: { + data: { + content: input + } } + }); + + const error = response.data && response.data.apw.contentReview.error; + const contentReview = response.data && response.data.apw.contentReview.data; + if (error) { + showSnackbar(error.message); + resolve(); + return; + } else if (contentReview) { + showSnackbar(`Content review requested successfully!`); + /** + * Redirect to newly created "content review". + */ + resolve(); + navigate( + routePaths.CONTENT_REVIEWS + "/" + encodeURIComponent(contentReview.id) + ); + return; } - }); - const error = response.data && response.data.apw.contentReview.error; - const contentReview = response.data && response.data.apw.contentReview.data; - if (error) { - showSnackbar(error.message); + showSnackbar(`Something went wrong!`); resolve(); - return; - } else if (contentReview) { - showSnackbar(`Content review requested successfully!`); - /** - * Redirect to newly created "content review". - */ - resolve(); - navigate( - routePaths.CONTENT_REVIEWS + "/" + encodeURIComponent(contentReview.id) - ); - return; - } + }; + }, + [] + ); - showSnackbar(`Something went wrong!`); - resolve(); - }; - }, - [] - ); + return { + ...hook, + publishRevision: async id => { + const inputData = { + id: id || entry.id, + type: ApwContentTypes.CMS_ENTRY, + settings: { + modelId: contentModel.modelId + } + }; + const { data } = await client.query({ + query: IS_REVIEW_REQUIRED_QUERY, + variables: { + data: inputData + } + }); + const contentReviewId = dotPropImmutable.get( + data, + "apw.isReviewRequired.data.contentReviewId" + ); - useEffect(() => { - return onEntryRevisionPublish(next => async params => { - const { id, entry } = params; - const inputData = { - id: id || entry.id, - type: ApwContentTypes.CMS_ENTRY, - settings: { - modelId: params.model.modelId + if (contentReviewId) { + showSnackbar(`A peer review for this content has been already requested.`); + return { + error: { + message: `A peer review for this content has been already requested.`, + code: "PEER_REVIEW_REQUESTED", + data: {} + } + }; } - }; - const { data } = await client.query({ - query: IS_REVIEW_REQUIRED_QUERY, - variables: { - data: inputData + + const isReviewRequired = dotPropImmutable.get( + data, + "apw.isReviewRequired.data.isReviewRequired" + ); + + if (!isReviewRequired) { + return hook.publishRevision(id); } - }); - const contentReviewId = dotPropImmutable.get( - data, - "apw.isReviewRequired.data.contentReviewId" - ); - if (contentReviewId) { - showSnackbar(`A peer review for this content has been already requested.`); - return next({ - ...params, - error: { - message: `A peer review for this content has been already requested.`, - code: "PEER_REVIEW_REQUESTED", - data: {} - } - }); - } - const isReviewRequired = dotPropImmutable.get( - data, - "apw.isReviewRequired.data.isReviewRequired" - ); - if (!isReviewRequired) { - return next(params); - } - /** - * We need to show a confirmation dialog with a promise and a resolver - * because of the async nature of the dialog confirmation action. - * - * If there was no promise and resolver, the next function would be triggered immediately and the dialog would not show. - */ - return await new Promise((resolve: any) => { - showRequestReviewConfirmation(handleRequestReview(resolve, inputData), resolve); - }).then(() => { - return next({ - ...params, - error: { - message: `A peer review is required.`, - code: "PEER_REVIEW_REQUIRED", - data: {} - } + /** + * We need to show a confirmation dialog with a promise and a resolver + * because of the async nature of the dialog confirmation action. + * + * If there was no promise and resolver, the next function would be triggered immediately and the dialog would not show. + */ + return await new Promise((resolve: any) => { + showRequestReviewConfirmation(handleRequestReview(resolve, inputData), resolve); + }).then(() => { + return { + error: { + message: `A peer review is required.`, + code: "PEER_REVIEW_REQUIRED", + data: {} + } + }; }); - }); - }); - }, []); - - return null; -}; + } + }; + }; +}); diff --git a/packages/app-file-manager/src/components/fields/Aliases.tsx b/packages/app-file-manager/src/components/fields/Aliases.tsx index 8f540417778..f1ffdab4ef2 100644 --- a/packages/app-file-manager/src/components/fields/Aliases.tsx +++ b/packages/app-file-manager/src/components/fields/Aliases.tsx @@ -39,7 +39,7 @@ const FileAliasMessage = styled("span")` const PATHNAME_REGEX = /^\/[/.a-zA-Z0-9-]+$/; export const Aliases = () => { - const { value, onChange } = useBind({ name: "aliases" }); + const { value, onChange } = useBind({ name: "aliases" }); const addAlias = () => { const newValue = Array.isArray(value) ? [...value] : []; diff --git a/packages/app-file-manager/src/components/fields/Tags.tsx b/packages/app-file-manager/src/components/fields/Tags.tsx index 95fe208d6ee..307418386ff 100644 --- a/packages/app-file-manager/src/components/fields/Tags.tsx +++ b/packages/app-file-manager/src/components/fields/Tags.tsx @@ -9,14 +9,14 @@ export const Tags = () => { const { canEdit } = useFileManagerApi(); const { tags } = useFileManagerView(); - const bind = useBind({ + const bind = useBind({ name: "tags" }); return ( !tag.startsWith("mime:"))} + value={(bind.value || []).filter((tag: string) => !tag.startsWith("mime:"))} options={tags.allTags.map(tagItem => tagItem.tag)} label={"Tags"} description={"Type in a new tag or select an existing one."} diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx index 7137d4a17aa..91c21eb81a5 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx @@ -20,6 +20,6 @@ const BaseColumn = (props: ColumnProps) => { }; export const Column = Object.assign(BaseColumn, { - useTableRow: Table.Column.useTableRow, + useTableRow: Table.Column.createUseTableRow(), isFolderRow: Table.Column.isFolderRow }); diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx index 5979f50c93b..cb7c5a31666 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx @@ -1,6 +1,7 @@ import React from "react"; import { createDecorator, + DialogsProvider, FileManagerFileItem, FileManagerOnChange, FileManagerRenderer as BaseFileManagerRenderer @@ -15,7 +16,7 @@ import { FM_ACO_APP } from "~/constants"; import { FileManagerViewWithConfig } from "./FileManagerViewConfig"; import { FoldersProvider } from "@webiny/app-aco/contexts/folders"; import { NavigateFolderProvider } from "./NavigateFolderProvider"; -import { AcoWithConfig, DialogsProvider } from "@webiny/app-aco"; +import { AcoWithConfig } from "@webiny/app-aco"; import { CompositionScope } from "@webiny/react-composition"; /** @@ -65,13 +66,13 @@ export function FileManagerProvider({ - - - + + + {children} - - - + + + diff --git a/packages/app-headless-cms-common/src/entries.graphql.ts b/packages/app-headless-cms-common/src/entries.graphql.ts index e95c3a2a187..46409f31c8d 100644 --- a/packages/app-headless-cms-common/src/entries.graphql.ts +++ b/packages/app-headless-cms-common/src/entries.graphql.ts @@ -225,6 +225,17 @@ export interface CmsEntriesListQueryVariables { after?: string; } +export const createListQueryDataSelection = ( + model: CmsEditorContentModel, + fields?: CmsModelField[] +) => { + return ` + ${CONTENT_ENTRY_SYSTEM_FIELDS} + ${fields ? createFieldsList({ model, fields }) : ""} + ${!fields ? getModelTitleFieldId(model) : ""} + `; +}; + export const createListQuery = ( model: CmsEditorContentModel, fields?: CmsModelField[], @@ -244,9 +255,7 @@ export const createListQuery = ( search: $search ) { data { - ${CONTENT_ENTRY_SYSTEM_FIELDS} - ${fields ? createFieldsList({ model, fields }) : ""} - ${!fields ? getModelTitleFieldId(model) : ""} + ${createListQueryDataSelection(model, fields)} } meta { cursor diff --git a/packages/app-headless-cms-common/src/types/index.ts b/packages/app-headless-cms-common/src/types/index.ts index 210275590ab..7bf778369ec 100644 --- a/packages/app-headless-cms-common/src/types/index.ts +++ b/packages/app-headless-cms-common/src/types/index.ts @@ -335,6 +335,10 @@ export interface CmsDynamicZoneTemplate { tags?: string[]; } +export interface CmsDynamicZoneTemplateWithTypename extends CmsDynamicZoneTemplate { + __typename: string; +} + export type CmsContentEntryStatusType = "draft" | "published" | "unpublished"; /** @@ -344,6 +348,7 @@ export type CmsEditorContentEntry = CmsContentEntry; export interface CmsContentEntry { id: string; + entryId: string; modelId: string; createdOn: string; createdBy: CmsIdentity; diff --git a/packages/app-headless-cms/src/HeadlessCMS.tsx b/packages/app-headless-cms/src/HeadlessCMS.tsx index 9a8da34059a..6d2d03facd0 100644 --- a/packages/app-headless-cms/src/HeadlessCMS.tsx +++ b/packages/app-headless-cms/src/HeadlessCMS.tsx @@ -8,7 +8,6 @@ import { CmsMenuLoader } from "~/admin/menus/CmsMenuLoader"; import apiInformation from "./admin/plugins/apiInformation"; import { ContentEntriesModule } from "~/admin/views/contentEntries/ContentEntriesModule"; import { DefaultOnEntryDelete } from "./admin/plugins/entry/DefaultOnEntryDelete"; -import { DefaultOnEntryPublish } from "~/admin/plugins/entry/DefaultOnEntryPublish"; import { DefaultOnEntryUnpublish } from "~/admin/plugins/entry/DefaultOnEntryUnpublish"; import allPlugins from "./allPlugins"; import { LexicalEditorCmsPlugin } from "~/admin/components/LexicalCmsEditor/LexicalEditorCmsPlugin"; @@ -69,7 +68,6 @@ const HeadlessCMSExtension = ({ createApolloClient }: HeadlessCMSProps) => { - diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx index 60bee823c9d..bcca9e2bf80 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx @@ -9,15 +9,16 @@ import { CmsContentEntry, CmsContentFormRendererPlugin } from "~/types"; import { useContentEntryForm, UseContentEntryFormParams } from "./useContentEntryForm"; import { Fields } from "./Fields"; import { Prompt } from "@webiny/react-router"; -import { useSnackbar } from "@webiny/app-admin"; +import { makeDecoratable, useSnackbar } from "@webiny/app-admin"; import { ModelProvider, useModel } from "~/admin/components/ModelProvider"; +import { Header } from "~/admin/components/ContentEntryForm/Header"; const FormWrapper = styled("div")({ height: "calc(100vh - 260px)", overflow: "auto" }); -interface ContentEntryFormProps extends UseContentEntryFormParams { +export interface ContentEntryFormProps extends UseContentEntryFormParams { onForm?: (form: FormAPI) => void; } @@ -36,139 +37,143 @@ const isDifferent = (value: any, compare: any): boolean => { return stringify(value) !== stringify(compare); }; -export const ContentEntryForm = ({ onForm, ...props }: ContentEntryFormProps) => { - const formElementRef = useRef(null); - const { model } = useModel(); - const { - loading, - data: initialData, - onChange, - onSubmit, - invalidFields - } = useContentEntryForm(props); - - const [isDirty, setIsDirty] = React.useState(false); - /** - * Reset isDirty when the loaded data changes. - */ - useEffect(() => { - if (!isDirty) { - return; - } - setIsDirty(false); - }, [initialData]); - - const { showSnackbar } = useSnackbar(); - - const ref = useRef(null); - - useEffect(() => { - if (typeof onForm !== "function" || !ref.current) { - return; - } - onForm(ref.current); - }, []); - - useEffect(() => { - if (!formElementRef.current) { - return; - } - - formElementRef.current.scrollTo(0, 0); - }, [initialData.id, formElementRef.current]); - - const formRenderer = plugins - .byType("cms-content-form-renderer") - .find(pl => pl.modelId === model.modelId); - - const renderCustomLayout = useCallback( - (formRenderProps: FormRenderPropParams) => { - const fields = model.fields.reduce((acc, field) => { - acc[field.fieldId] = ( - - ); - - return acc; - }, {} as Record); - if (!formRenderer) { - return <>{`Missing form renderer for modelId "${model.modelId}".`}; +export const ContentEntryForm = makeDecoratable( + "ContentEntryForm", + ({ onForm, ...props }: ContentEntryFormProps) => { + const formElementRef = useRef(null); + const { model } = useModel(); + const { + loading, + data: initialData, + onChange, + onSubmit, + invalidFields + } = useContentEntryForm(props); + + const [isDirty, setIsDirty] = React.useState(false); + /** + * Reset isDirty when the loaded data changes. + */ + useEffect(() => { + if (!isDirty) { + return; } - return formRenderer.render({ - ...formRenderProps, - contentModel: model, - fields, - /** - * TODO @ts-refactor - * Figure out type for Bind. - */ - // @ts-expect-error - Bind: formRenderProps.Bind - }); - }, - [formRenderer] - ); - - return ( - - onChange={(data, form) => { - const different = isDifferent(data, initialData); - if (isDirty !== different) { - setIsDirty(different); - } - return onChange(data, form); - }} - onSubmit={(data, form) => { - setIsDirty(false); - return onSubmit(data, form); - }} - data={initialData} - ref={ref} - invalidFields={invalidFields} - onInvalid={() => { - setIsDirty(true); - showSnackbar("Some fields did not pass the validation. Please check the form."); - }} - > - {formProps => { - return ( - - (null); + + useEffect(() => { + if (typeof onForm !== "function" || !ref.current) { + return; + } + onForm(ref.current); + }, []); + + useEffect(() => { + if (!formElementRef.current) { + return; + } + + formElementRef.current.scrollTo(0, 0); + }, [initialData.id, formElementRef.current]); + + const formRenderer = plugins + .byType("cms-content-form-renderer") + .find(pl => pl.modelId === model.modelId); + + const renderCustomLayout = useCallback( + (formRenderProps: FormRenderPropParams) => { + const fields = model.fields.reduce((acc, field) => { + acc[field.fieldId] = ( + - - {loading && } - {formRenderer ? ( - renderCustomLayout(formProps) - ) : ( - - )} - - - ); - }} - - ); -}; + ); + + return acc; + }, {} as Record); + if (!formRenderer) { + return <>{`Missing form renderer for modelId "${model.modelId}".`}; + } + return formRenderer.render({ + ...formRenderProps, + contentModel: model, + fields, + /** + * TODO @ts-refactor + * Figure out type for Bind. + */ + // @ts-expect-error + Bind: formRenderProps.Bind + }); + }, + [formRenderer] + ); + + return ( + + onChange={(data, form) => { + const different = isDifferent(data, initialData); + if (isDirty !== different) { + setIsDirty(different); + } + return onChange(data, form); + }} + onSubmit={(data, form) => { + setIsDirty(false); + return onSubmit(data, form); + }} + data={initialData} + ref={ref} + invalidFields={invalidFields} + onInvalid={() => { + setIsDirty(true); + showSnackbar("Some fields did not pass the validation. Please check the form."); + }} + > + {formProps => { + return ( + + +
+ + {loading && } + {formRenderer ? ( + renderCustomLayout(formProps) + ) : ( + + )} + + + ); + }} + + ); + } +); diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormPreview.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormPreview.tsx index 9d85fdcc6f6..bf9a0760474 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormPreview.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryFormPreview.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from "react"; import styled from "@emotion/styled"; import { Form, FormRenderPropParams } from "@webiny/form"; import { plugins } from "@webiny/plugins"; +import { makeDecoratable } from "@webiny/app-admin"; import { FieldElement } from "./FieldElement"; import { CmsContentFormRendererPlugin, CmsEditorContentModel } from "~/types"; import { Fields } from "~/admin/components/ContentEntryForm/Fields"; @@ -12,77 +13,80 @@ const FormWrapper = styled("div")({ overflow: "auto" }); -interface ContentEntryFormPreviewProps { +export interface ContentEntryFormPreviewProps { contentModel: CmsEditorContentModel; } -export const ContentEntryFormPreview = (props: ContentEntryFormPreviewProps) => { - const { contentModel } = props; +export const ContentEntryFormPreview = makeDecoratable( + "ContentEntryFormPreview", + (props: ContentEntryFormPreviewProps) => { + const { contentModel } = props; - const formRenderer = plugins - .byType("cms-content-form-renderer") - .find(pl => pl.modelId === contentModel.modelId); + const formRenderer = plugins + .byType("cms-content-form-renderer") + .find(pl => pl.modelId === contentModel.modelId); - const renderCustomLayout = useCallback( - (formRenderProps: FormRenderPropParams) => { - const fields = contentModel.fields.reduce((acc, field) => { - acc[field.fieldId] = ( - - ); + const renderCustomLayout = useCallback( + (formRenderProps: FormRenderPropParams) => { + const fields = contentModel.fields.reduce((acc, field) => { + acc[field.fieldId] = ( + + ); - return acc; - }, {} as Record); - if (!formRenderer) { - return <>{`Missing form renderer for modelId "${contentModel.modelId}".`}; - } - return formRenderer.render({ - ...formRenderProps, - /** - * TODO @ts-refactor - * Figure out type for Bind. - */ - // @ts-expect-error - Bind: formRenderProps.Bind, - contentModel, - fields - }); - }, - [formRenderer, contentModel.fields] - ); + return acc; + }, {} as Record); + if (!formRenderer) { + return <>{`Missing form renderer for modelId "${contentModel.modelId}".`}; + } + return formRenderer.render({ + ...formRenderProps, + /** + * TODO @ts-refactor + * Figure out type for Bind. + */ + // @ts-expect-error + Bind: formRenderProps.Bind, + contentModel, + fields + }); + }, + [formRenderer, contentModel.fields] + ); - return ( -
- {formProps => ( - - - {formRenderer ? ( - renderCustomLayout(formProps) - ) : ( - - )} - - - )} -
- ); -}; + return ( +
+ {formProps => ( + + + {formRenderer ? ( + renderCustomLayout(formProps) + ) : ( + + )} + + + )} +
+ ); + } +); diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/FieldElement.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/FieldElement.tsx index 118a6218bd4..f852d4ce154 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/FieldElement.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/FieldElement.tsx @@ -6,27 +6,20 @@ import { CmsModelField, CmsEditorContentModel, BindComponent } from "~/types"; import Label from "./Label"; import { useBind } from "./useBind"; import { useRenderPlugins } from "./useRenderPlugins"; -import { ModelFieldProvider } from "../ModelFieldProvider"; +import { ModelFieldProvider, useModelField } from "../ModelFieldProvider"; const t = i18n.ns("app-headless-cms/admin/components/content-form"); -export interface FieldElementProps { - field: CmsModelField; - Bind: BindComponent; - contentModel: CmsEditorContentModel; -} +type RenderFieldProps = Omit; -export const FieldElement = makeDecoratable("FieldElement", (props: FieldElementProps) => { +const RenderField = (props: RenderFieldProps) => { const renderPlugins = useRenderPlugins(); - const { field, Bind, contentModel } = props; + const { Bind, contentModel } = props; + const { field } = useModelField(); const getBind = useBind({ Bind, field }); if (typeof field.renderer === "function") { - return ( - - {field.renderer({ field, getBind, Label, contentModel })} - - ); + return <>{field.renderer({ field, getBind, Label, contentModel })}; } const renderPlugin = renderPlugins.find( @@ -39,12 +32,25 @@ export const FieldElement = makeDecoratable("FieldElement", (props: FieldElement }); } - return ( - - {renderPlugin.renderer.render({ field, getBind, Label, contentModel })} - - ); -}); + return <>{renderPlugin.renderer.render({ field, getBind, Label, contentModel })}; +}; + +export interface FieldElementProps { + field: CmsModelField; + Bind: BindComponent; + contentModel: CmsEditorContentModel; +} + +export const FieldElement = makeDecoratable( + "FieldElement", + ({ field, ...props }: FieldElementProps) => { + return ( + + + + ); + } +); /** * @deprecated Use `FieldElement` instead. diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Fields.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Fields.tsx index 33d9799b669..60eeb63fb56 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Fields.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Fields.tsx @@ -25,15 +25,19 @@ export const Fields = ({ Bind, fields, layout, contentModel, gridClassName }: Fi {layout.map((row, rowIndex) => ( - {row.map(fieldId => ( - - - - ))} + {row.map(fieldId => { + const field = getFieldById(fields, fieldId) as CmsModelField; + + return ( + + + + ); + })} ))} diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/Header.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/Header.tsx index 5502be7dd58..1205dfd1933 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/Header.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/Header.tsx @@ -1,45 +1,37 @@ import React from "react"; -import { css } from "emotion"; import { Buttons } from "@webiny/app-admin"; -import { Grid, Cell } from "@webiny/ui/Grid"; -import classNames from "classnames"; import { useContentEntryEditorConfig } from "~/admin/config/contentEntries"; import { ContentFormOptionsMenu } from "./ContentFormOptionsMenu"; import { RevisionSelector } from "~/admin/components/ContentEntryForm/Header/RevisionSelector"; +import styled from "@emotion/styled"; -const toolbarGrid = css({ - borderBottom: "1px solid var(--mdc-theme-on-background)" -}); +const ToolbarGrid = styled.div` + padding: 15px; + border-bottom: 1px solid var(--mdc-theme-on-background); + display: flex; + justify-content: space-between; + align-items: center; +`; -const headerActions = css({ - display: "flex", - alignItems: "center" -}); - -const headerActionsLeft = css({ - justifyContent: "flex-end" -}); - -const headerActionsRight = css({ - justifyContent: "flex-start" -}); +const Actions = styled.div` + display: flex; + align-items: center; +`; export const Header = () => { const { buttonActions } = useContentEntryEditorConfig(); return ( - - - - - - - - - - - + +
+ +
+ + + + +
); }; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/RevisionSelector/RevisionSelector.styles.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/RevisionSelector/RevisionSelector.styles.tsx index 0f8aa82b8f8..4a2bac4f97c 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/RevisionSelector/RevisionSelector.styles.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/RevisionSelector/RevisionSelector.styles.tsx @@ -4,6 +4,7 @@ import { Menu as DefaultMenu } from "@webiny/ui/Menu"; export const Button = styled(DefaultButtonDefault)` color: var(--mdc-theme-text-primary-on-background) !important; + text-wrap: nowrap; `; export const Menu = styled(DefaultMenu)` diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish.tsx index 3c362b87b5d..fd47b1e98b8 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/Header/SaveAndPublishContent/useSaveAndPublish.tsx @@ -24,12 +24,13 @@ export const useSaveAndPublish = (): UseSaveAndPublishResponse => { }); const showConfirmationDialog = useCallback( - ({ ev, onAccept, onCancel }) => { + async ({ ev, onAccept, onCancel }) => { + const entry = await form.current.submit(ev); + if (!entry || !entry.id) { + return; + } + showConfirmation(async () => { - const entry = await form.current.submit(ev); - if (!entry || !entry.id) { - return; - } await publishRevision(entry.id); if (typeof onAccept === "function") { diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useContentEntryForm.ts b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useContentEntryForm.ts index 63251a23834..4f158017b4d 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useContentEntryForm.ts +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useContentEntryForm.ts @@ -1,7 +1,7 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useMemo, useState } from "react"; import pick from "lodash/pick"; import { useRouter } from "@webiny/react-router"; -import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { makeDecoratable, useSnackbar } from "@webiny/app-admin"; import { FormOnSubmit } from "@webiny/form"; import { CmsEntryCreateFromMutationResponse, @@ -15,7 +15,7 @@ import { createUpdateMutation, prepareFormData } from "@webiny/app-headless-cms-common"; -import { useCms, useModel, useMutation } from "~/admin/hooks"; +import { useModel, useMutation } from "~/admin/hooks"; import { CmsContentEntry, CmsModelField, CmsModelFieldRendererPlugin } from "~/types"; import { plugins } from "@webiny/plugins"; import { getFetchPolicy } from "~/utils/getFetchPolicy"; @@ -41,7 +41,7 @@ interface InvalidFieldError { error: string; } -interface UseContentEntryForm { +export interface UseContentEntryForm { data: Record; loading: boolean; setLoading: Dispatch>; @@ -58,326 +58,302 @@ export interface UseContentEntryFormParams { addEntryToListCache: boolean; } -function useEntry(entryFromProps: Partial) { - /** - * We need to keep track of the entry locally - */ - const [entry, setEntry] = useState(entryFromProps); - const { onEntryRevisionPublish } = useCms(); - - useEffect(() => { - setEntry(entryFromProps); - - if (!entryFromProps.id) { - return; - } - - return onEntryRevisionPublish(next => async params => { - const publishRes = await next(params); - setEntry(entry => { - return { ...entry, meta: publishRes?.entry?.meta || {} }; - }); - return publishRes; - }); - }, [entryFromProps, entry.id]); - - return entry; -} +export const useContentEntryForm = makeDecoratable( + (params: UseContentEntryFormParams): UseContentEntryForm => { + const { model } = useModel(); + const { history, search: routerSearch } = useRouter(); + const [query] = routerSearch; + const { currentFolderId } = useNavigateFolder(); + const { showSnackbar } = useSnackbar(); + const [invalidFields, setInvalidFields] = useState>({}); + const [loading, setLoading] = useState(false); + const entry = params.entry; + + const { addRecordToCache, updateRecordInCache } = useRecords(); + + const renderPlugins = useMemo( + () => plugins.byType("cms-editor-field-renderer"), + [] + ); + + const goToRevision = useCallback( + id => { + const fId = query.get("folderId"); + const folderId = fId ? `&folderId=${encodeURIComponent(fId)}` : ""; + history.push( + `/cms/content-entries/${model.modelId}?id=${encodeURIComponent(id)}${folderId}` + ); + }, + [query] + ); + + const { CREATE_CONTENT, UPDATE_CONTENT, CREATE_CONTENT_FROM } = useMemo(() => { + return { + CREATE_CONTENT: createCreateMutation(model), + UPDATE_CONTENT: createUpdateMutation(model), + CREATE_CONTENT_FROM: createCreateFromMutation(model) + }; + }, [model.modelId]); + + const [createMutation] = useMutation< + CmsEntryCreateMutationResponse, + CmsEntryCreateMutationVariables + >(CREATE_CONTENT); + const [updateMutation] = useMutation< + CmsEntryUpdateMutationResponse, + CmsEntryUpdateMutationVariables + >(UPDATE_CONTENT); + const [createFromMutation] = useMutation< + CmsEntryCreateFromMutationResponse, + CmsEntryCreateFromMutationVariables + >(CREATE_CONTENT_FROM); -export function useContentEntryForm(params: UseContentEntryFormParams): UseContentEntryForm { - const { model } = useModel(); - const { history, search: routerSearch } = useRouter(); - const [query] = routerSearch; - const { currentFolderId } = useNavigateFolder(); - const { showSnackbar } = useSnackbar(); - const [invalidFields, setInvalidFields] = useState>({}); - const [loading, setLoading] = useState(false); - const entry = useEntry(params.entry); - - const { addRecordToCache, updateRecordInCache } = useRecords(); - - const renderPlugins = useMemo( - () => plugins.byType("cms-editor-field-renderer"), - [] - ); - - const goToRevision = useCallback( - id => { - const fId = query.get("folderId"); - const folderId = fId ? `&folderId=${encodeURIComponent(fId)}` : ""; - history.push( - `/cms/content-entries/${model.modelId}?id=${encodeURIComponent(id)}${folderId}` - ); - }, - [query] - ); - - const { CREATE_CONTENT, UPDATE_CONTENT, CREATE_CONTENT_FROM } = useMemo(() => { - return { - CREATE_CONTENT: createCreateMutation(model), - UPDATE_CONTENT: createUpdateMutation(model), - CREATE_CONTENT_FROM: createCreateFromMutation(model) + /** + * Note that when passing `error.data` variable we cast as InvalidFieldError[] because we know it is so. + */ + const setInvalidFieldValues = (errors?: InvalidFieldError[]): void => { + if (Array.isArray(errors) === false || !errors) { + return; + } + const values = (errors || []).reduce((acc, er) => { + acc[er.fieldId] = er.error; + return acc; + }, {} as Record); + setInvalidFields(() => values); }; - }, [model.modelId]); - - const [createMutation] = useMutation< - CmsEntryCreateMutationResponse, - CmsEntryCreateMutationVariables - >(CREATE_CONTENT); - const [updateMutation] = useMutation< - CmsEntryUpdateMutationResponse, - CmsEntryUpdateMutationVariables - >(UPDATE_CONTENT); - const [createFromMutation] = useMutation< - CmsEntryCreateFromMutationResponse, - CmsEntryCreateFromMutationVariables - >(CREATE_CONTENT_FROM); - - /** - * Note that when passing `error.data` variable we cast as InvalidFieldError[] because we know it is so. - */ - const setInvalidFieldValues = (errors?: InvalidFieldError[]): void => { - if (Array.isArray(errors) === false || !errors) { - return; - } - const values = (errors || []).reduce((acc, er) => { - acc[er.fieldId] = er.error; - return acc; - }, {} as Record); - setInvalidFields(() => values); - }; - - const resetInvalidFieldValues = (): void => { - setInvalidFields(() => ({})); - }; - - const createContent: FormOnSubmit = useCallback( - async (formData, form) => { - setLoading(true); - const response = await createMutation({ - variables: { - data: { - ...formData, - /** - * Added for the ACO to work. - * TODO: introduce hook like onEntryPublish, or similar. - */ - wbyAco_location: { - folderId: currentFolderId || ROOT_FOLDER + + const resetInvalidFieldValues = (): void => { + setInvalidFields(() => ({})); + }; + + const createContent: FormOnSubmit = useCallback( + async (formData, form) => { + setLoading(true); + const response = await createMutation({ + variables: { + data: { + ...formData, + /** + * Added for the ACO to work. + * TODO: introduce hook like onEntryPublish, or similar. + */ + wbyAco_location: { + folderId: currentFolderId || ROOT_FOLDER + } + }, + options: { + skipValidators: form.options.skipValidators } }, - options: { - skipValidators: form.options.skipValidators - } - }, - fetchPolicy: getFetchPolicy(model) - }); + fetchPolicy: getFetchPolicy(model) + }); - setLoading(false); + setLoading(false); - /** - * Finalize "create" process - */ - if (!response.data) { - showSnackbar("Missing response data in Create Entry."); - return; - } - const { data: entry, error } = response.data.content || {}; - if (error) { - showSnackbar(error.message); - setInvalidFieldValues(error.data as InvalidFieldError[]); - return; - } else if (!entry) { - showSnackbar("Missing entry data in Create Entry Response."); - return; - } - resetInvalidFieldValues(); + /** + * Finalize "create" process + */ + if (!response.data) { + showSnackbar("Missing response data in Create Entry."); + return; + } + const { data: entry, error } = response.data.content || {}; + if (error) { + showSnackbar(error.message); + setInvalidFieldValues(error.data as InvalidFieldError[]); + return; + } else if (!entry) { + showSnackbar("Missing entry data in Create Entry Response."); + return; + } + resetInvalidFieldValues(); - if (params.addEntryToListCache) { - addRecordToCache(entry); - } + if (params.addEntryToListCache) { + addRecordToCache(entry); + } - showSnackbar(`${model.name} entry created successfully!`); - if (typeof params.onSubmit === "function") { - params.onSubmit(entry, form); + showSnackbar(`${model.name} entry created successfully!`); + if (typeof params.onSubmit === "function") { + params.onSubmit(entry, form); + return entry; + } + goToRevision(entry.id); return entry; - } - goToRevision(entry.id); - return entry; - }, - [model.modelId, params.onSubmit, params.addEntryToListCache, query, currentFolderId] - ); - - const updateContent = useCallback( - async (revision, data, form) => { - setLoading(true); - const response = await updateMutation({ - variables: { - revision, - data, - options: { - skipValidators: form.options.skipValidators - } - }, - fetchPolicy: getFetchPolicy(model) - }); - setLoading(false); - if (!response.data) { - showSnackbar("Missing response data on Update Entry Response."); - return; - } + }, + [model.modelId, params.onSubmit, params.addEntryToListCache, query, currentFolderId] + ); + + const updateContent = useCallback( + async (revision, data, form) => { + setLoading(true); + const response = await updateMutation({ + variables: { + revision, + data, + options: { + skipValidators: form.options.skipValidators + } + }, + fetchPolicy: getFetchPolicy(model) + }); + setLoading(false); + if (!response.data) { + showSnackbar("Missing response data on Update Entry Response."); + return; + } - const { error } = response.data.content; - if (error) { - showSnackbar(error.message); - setInvalidFieldValues(error.data as InvalidFieldError[]); - return null; - } + const { error } = response.data.content; + if (error) { + showSnackbar(error.message); + setInvalidFieldValues(error.data as InvalidFieldError[]); + return null; + } - resetInvalidFieldValues(); - showSnackbar("Content saved successfully."); - const { data: entry } = response.data.content; - - updateRecordInCache(entry); - - return entry; - }, - [model.modelId] - ); - - const createContentFrom = useCallback( - async (revision: string, formData: Record, form) => { - setLoading(true); - const response = await createFromMutation({ - variables: { - revision, - data: formData, - options: { - skipValidators: form.options.skipValidators - } - }, - fetchPolicy: getFetchPolicy(model) - }); + resetInvalidFieldValues(); + showSnackbar("Content saved successfully."); + const { data: entry } = response.data.content; - if (!response.data) { - showSnackbar("Missing data in update callback on Create From Entry Response."); - return; - } - const { data: newRevision, error } = response.data.content; - if (error) { - showSnackbar(error.message); - setInvalidFieldValues(error.data as InvalidFieldError[]); - return; - } else if (!newRevision) { - showSnackbar("Missing entry data in update callback on Create From Entry."); - return; - } - resetInvalidFieldValues(); + updateRecordInCache(entry); - updateRecordInCache(newRevision); + return entry; + }, + [model.modelId] + ); + + const createContentFrom = useCallback( + async (revision: string, formData: Record, form) => { + setLoading(true); + const response = await createFromMutation({ + variables: { + revision, + data: formData, + options: { + skipValidators: form.options.skipValidators + } + }, + fetchPolicy: getFetchPolicy(model) + }); - showSnackbar("A new revision was created!"); - goToRevision(newRevision.id); + if (!response.data) { + showSnackbar("Missing data in update callback on Create From Entry Response."); + return; + } + const { data: newRevision, error } = response.data.content; + if (error) { + showSnackbar(error.message); + setInvalidFieldValues(error.data as InvalidFieldError[]); + return; + } else if (!newRevision) { + showSnackbar("Missing entry data in update callback on Create From Entry."); + return; + } + resetInvalidFieldValues(); - setLoading(false); + updateRecordInCache(newRevision); - return newRevision; - }, - [model.modelId] - ); + showSnackbar("A new revision was created!"); + goToRevision(newRevision.id); - const onChange: FormOnSubmit = (data, form) => { - if (!params.onChange) { - return; - } - return params.onChange(data, form); - }; + setLoading(false); - const onSubmit: FormOnSubmit = async (data, form) => { - const fieldsIds = model.fields.map(item => item.fieldId); - const formData = pick(data, [...fieldsIds]); + return newRevision; + }, + [model.modelId] + ); - const gqlData = prepareFormData(formData, model.fields); + const onChange: FormOnSubmit = (data, form) => { + if (!params.onChange) { + return; + } + return params.onChange(data, form); + }; - if (!entry.id) { - return createContent(gqlData as CmsContentEntry, form); - } + const onSubmit: FormOnSubmit = async (data, form) => { + const fieldsIds = model.fields.map(item => item.fieldId); + const formData = pick(data, [...fieldsIds]); - const { meta } = entry; - const { locked: isLocked } = meta || {}; + const gqlData = prepareFormData(formData, model.fields); - if (!isLocked) { - return updateContent(entry.id, gqlData, form); - } + if (!entry.id) { + return createContent(gqlData as CmsContentEntry, form); + } - return createContentFrom(entry.id, gqlData, form); - }; + const { meta } = entry; + const { locked: isLocked } = meta || {}; - const defaultValues = useMemo((): Record => { - const values: Record = {}; - /** - * Assign the default values: - * * check the settings.defaultValue - * * check the predefinedValues for selected value - */ - for (const field of model.fields) { + if (!isLocked) { + return updateContent(entry.id, gqlData, form); + } + + return createContentFrom(entry.id, gqlData, form); + }; + + const defaultValues = useMemo((): Record => { + const values: Record = {}; /** - * When checking if defaultValue is set in settings, we do the undefined check because it can be null, 0, empty string, false, etc... + * Assign the default values: + * * check the settings.defaultValue + * * check the predefinedValues for selected value */ - const { settings, multipleValues = false } = field; - if (settings && settings.defaultValue !== undefined) { + for (const field of model.fields) { /** - * Special type of field is the boolean one. - * We MUST set true/false for default value. + * When checking if defaultValue is set in settings, we do the undefined check because it can be null, 0, empty string, false, etc... */ - values[field.fieldId] = convertDefaultValue(field, settings.defaultValue); - continue; - } - /** - * No point in going further if predefined values are not enabled. - */ - const { predefinedValues } = field; - if ( - !predefinedValues || - !predefinedValues.enabled || - Array.isArray(predefinedValues.values) === false - ) { - continue; - } - /** - * When field is not a multiple values one, we find the first possible default selected value and set it as field value. - */ - if (!multipleValues) { - const selectedValue = predefinedValues.values.find(({ selected }) => { - return !!selected; - }); - if (selectedValue) { - values[field.fieldId] = convertDefaultValue(field, selectedValue.value); + const { settings, multipleValues = false } = field; + if (settings && settings.defaultValue !== undefined) { + /** + * Special type of field is the boolean one. + * We MUST set true/false for default value. + */ + values[field.fieldId] = convertDefaultValue(field, settings.defaultValue); + continue; + } + /** + * No point in going further if predefined values are not enabled. + */ + const { predefinedValues } = field; + if ( + !predefinedValues || + !predefinedValues.enabled || + Array.isArray(predefinedValues.values) === false + ) { + continue; } - continue; + /** + * When field is not a multiple values one, we find the first possible default selected value and set it as field value. + */ + if (!multipleValues) { + const selectedValue = predefinedValues.values.find(({ selected }) => { + return !!selected; + }); + if (selectedValue) { + values[field.fieldId] = convertDefaultValue(field, selectedValue.value); + } + continue; + } + /** + * + */ + values[field.fieldId] = predefinedValues.values + .filter(({ selected }) => !!selected) + .map(({ value }) => { + return convertDefaultValue(field, value); + }); } + return values; + }, [model.modelId]); + + return { /** - * + * If entry is not set or `entry.id` does not exist, it means it's a new entry, so fetch default values. */ - values[field.fieldId] = predefinedValues.values - .filter(({ selected }) => !!selected) - .map(({ value }) => { - return convertDefaultValue(field, value); - }); - } - return values; - }, [model.modelId]); - - return { - /** - * If entry is not set or `entry.id` does not exist, it means it's a new entry, so fetch default values. - */ - data: entry && entry.id ? entry : defaultValues, - loading, - setLoading, - onChange, - onSubmit, - invalidFields, - renderPlugins - }; -} + data: entry && entry.id ? entry : defaultValues, + loading, + setLoading, + onChange, + onSubmit, + invalidFields, + renderPlugins + }; + } +); diff --git a/packages/app-headless-cms/src/admin/components/ContentModelEditor/Editor.tsx b/packages/app-headless-cms/src/admin/components/ContentModelEditor/ContentModelEditor.tsx similarity index 89% rename from packages/app-headless-cms/src/admin/components/ContentModelEditor/Editor.tsx rename to packages/app-headless-cms/src/admin/components/ContentModelEditor/ContentModelEditor.tsx index 0d672965c58..fe3e271d3dd 100644 --- a/packages/app-headless-cms/src/admin/components/ContentModelEditor/Editor.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentModelEditor/ContentModelEditor.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { CompositionScope, makeDecoratable } from "@webiny/app-admin"; import { Prompt } from "@webiny/react-router"; import styled from "@emotion/styled"; import { css } from "emotion"; @@ -64,7 +65,7 @@ interface OnChangeParams { layout: CmsEditorFieldsLayout; } -export const Editor = () => { +export const ContentModelEditor = makeDecoratable("ContentModelEditor", () => { const { data, setData, isPristine } = useModelEditor(); const [activeTabIndex, setActiveTabIndex] = useState(0); @@ -112,9 +113,11 @@ export const Editor = () => { - - - + + + + + @@ -123,4 +126,4 @@ export const Editor = () => { ); -}; +}); diff --git a/packages/app-headless-cms/src/admin/components/ModelFieldProvider/ModelFieldContext.tsx b/packages/app-headless-cms/src/admin/components/ModelFieldProvider/ModelFieldContext.tsx index 1bda00a4ccc..a523849add6 100644 --- a/packages/app-headless-cms/src/admin/components/ModelFieldProvider/ModelFieldContext.tsx +++ b/packages/app-headless-cms/src/admin/components/ModelFieldProvider/ModelFieldContext.tsx @@ -1,5 +1,6 @@ import React from "react"; import { CmsModelField } from "~/types"; +import { createGenericContext } from "@webiny/app-admin"; export type ModelFieldContext = CmsModelField; @@ -13,3 +14,16 @@ export interface ModelFieldProviderProps { export const ModelFieldProvider = ({ field, children }: ModelFieldProviderProps) => { return {children}; }; + +const { Provider, useHook } = createGenericContext<{ index: number }>("FieldIndex"); + +export const ParentValueIndexProvider = Provider; + +export const useParentValueIndex = () => { + try { + const context = useHook(); + return context.index; + } catch { + return -1; + } +}; diff --git a/packages/app-headless-cms/src/admin/components/ModelFieldProvider/useModelField.ts b/packages/app-headless-cms/src/admin/components/ModelFieldProvider/useModelField.ts index ac05732d8f2..74fa5e61bfa 100644 --- a/packages/app-headless-cms/src/admin/components/ModelFieldProvider/useModelField.ts +++ b/packages/app-headless-cms/src/admin/components/ModelFieldProvider/useModelField.ts @@ -1,7 +1,8 @@ import { useContext } from "react"; -import { ModelFieldContext } from "./ModelFieldContext"; import { plugins } from "@webiny/plugins"; -import { CmsModelFieldTypePlugin } from "~/types"; +import { makeDecoratable } from "@webiny/react-composition"; +import { ModelFieldContext, useParentValueIndex } from "./ModelFieldContext"; +import { CmsModelField, CmsModelFieldTypePlugin } from "~/types"; interface GetFieldPlugin { (type: string): CmsModelFieldTypePlugin; @@ -19,11 +20,19 @@ const getFieldPlugin: GetFieldPlugin = type => { return plugin; }; +export interface UseModelField { + field: CmsModelField; + parentValueIndex: number; + fieldPlugin: CmsModelFieldTypePlugin; +} + /** * Get model field from the current context. */ -export function useModelField() { +export const useModelField = makeDecoratable((): UseModelField => { const field = useContext(ModelFieldContext); + const parentValueIndex = useParentValueIndex(); + if (!field) { throw Error( `Missing "ModelFieldProvider" in the component tree. Are you using the "useModelField()" hook in the right place?` @@ -32,5 +41,5 @@ export function useModelField() { const fieldPlugin = getFieldPlugin(field.type); - return { field, fieldPlugin }; -} + return { field, fieldPlugin, parentValueIndex }; +}); diff --git a/packages/app-headless-cms/src/admin/config/IsApplicableToCurrentModel.tsx b/packages/app-headless-cms/src/admin/config/IsApplicableToCurrentModel.tsx new file mode 100644 index 00000000000..b19ae78196b --- /dev/null +++ b/packages/app-headless-cms/src/admin/config/IsApplicableToCurrentModel.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { useModel } from "~/admin/components/ModelProvider"; + +export interface IsApplicableToCurrentModelProps { + modelIds: string[]; + children: React.ReactNode; +} + +export const IsApplicableToCurrentModel = ({ + modelIds, + children +}: IsApplicableToCurrentModelProps) => { + const { model } = useModel(); + + if (modelIds.length > 0 && !modelIds.includes(model.modelId)) { + return null; + } + + return <>{children}; +}; diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx index cb973150432..0ff2c5f2f9f 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx @@ -1,8 +1,8 @@ import React from "react"; import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco"; -import { useModel } from "~/admin/components/ModelProvider"; import { TableItem } from "~/types"; +import { IsApplicableToCurrentModel } from "~/admin/config/IsApplicableToCurrentModel"; const { Table } = AcoConfig; @@ -13,22 +13,18 @@ export interface ColumnProps extends React.ComponentProps { - const { model } = useModel(); - - if (modelIds.length > 0 && !modelIds.includes(model.modelId)) { - return null; - } - return ( - + + + ); }; export const Column = Object.assign(BaseColumn, { - useTableRow: Table.Column.useTableRow, + useTableRow: Table.Column.createUseTableRow(), isFolderRow: Table.Column.isFolderRow }); diff --git a/packages/app-headless-cms/src/admin/constants.ts b/packages/app-headless-cms/src/admin/constants.ts index c63b391745e..d96296f4a1c 100644 --- a/packages/app-headless-cms/src/admin/constants.ts +++ b/packages/app-headless-cms/src/admin/constants.ts @@ -1,19 +1,11 @@ -import { i18n } from "@webiny/app/i18n"; - -const t = i18n.ns("app-headless-cms/admin/content-entries/status"); - export { ROOT_FOLDER } from "@webiny/app-aco/constants"; export const CMS_ENTRY_LIST_LINK = "/cms/content-entries"; export const LOCAL_STORAGE_LATEST_VISITED_FOLDER = "webiny_cms_entry_latest_visited_folder"; -export interface Statuses { - draft: `Draft`; - published: `Published`; - unpublished: `Unpublished`; -} +export type Statuses = typeof statuses; -export const statuses: Statuses = { - draft: t`Draft`, - published: t`Published`, - unpublished: t`Unpublished` +export const statuses = { + draft: "Draft", + published: "Published", + unpublished: "Unpublished" }; diff --git a/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx b/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx index a8403d206f2..11c771aec20 100644 --- a/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx +++ b/packages/app-headless-cms/src/admin/contexts/Cms/index.tsx @@ -1,17 +1,22 @@ -import React, { useRef } from "react"; +import React, { useCallback, useRef } from "react"; import ApolloClient from "apollo-client"; import { useI18N } from "@webiny/app-i18n/hooks/useI18N"; import { CircularProgress } from "@webiny/ui/Progress"; import { config as appConfig } from "@webiny/app/config"; -import { CmsContentEntry, CmsModel } from "~/types"; +import { CmsContentEntry, CmsErrorResponse, CmsModel } from "~/types"; import { MutationHookOptions } from "@apollo/react-hooks"; import { AsyncProcessor, composeAsync } from "@webiny/utils"; +import { DocumentNode } from "graphql"; +import { + CmsEntryPublishMutationResponse, + CmsEntryPublishMutationVariables, + createPublishMutation +} from "@webiny/app-headless-cms-common"; interface MutationEntryOptions { mutationOptions?: MutationHookOptions; } -type PublishEntryOptions = MutationEntryOptions; type UnpublishEntryOptions = MutationEntryOptions; type DeleteEntryOptions = MutationEntryOptions; @@ -21,20 +26,15 @@ interface EntryError { data?: Record; } -export interface OnEntryPublishRequest { - model: CmsModel; - entry: CmsContentEntry; - id: string; - options: PublishEntryOptions; - // TODO: Maybe a different input and output type for compose. - error?: EntryError | null; - locale: string; - client: ApolloClient; -} - -export interface OnEntryPublishResponse extends Omit { - entry: CmsContentEntry | undefined; -} +export type OnEntryPublishResponse = + | { + entry: CmsContentEntry; + error?: never; + } + | { + entry?: never; + error: EntryError; + }; export interface OnEntryDeleteRequest { model: CmsModel; @@ -66,10 +66,6 @@ export interface OnEntryUnpublishResponse extends Omit; type OnEntryDeleteSubscriber = AsyncProcessor; type OnEntryRevisionUnpublishSubscriber = AsyncProcessor< OnEntryUnpublishRequest, @@ -79,7 +75,6 @@ type OnEntryRevisionUnpublishSubscriber = AsyncProcessor< interface PublishEntryRevisionParams { model: CmsModel; entry: CmsContentEntry; - options?: PublishEntryOptions; id: string; } interface DeleteEntryParams { @@ -101,7 +96,6 @@ export interface CmsContext { createApolloClient: CmsProviderProps["createApolloClient"]; apolloClient: ApolloClient; publishEntryRevision: (params: PublishEntryRevisionParams) => Promise; - onEntryRevisionPublish: (fn: OnEntryRevisionPublishSubscriber) => () => void; unpublishEntryRevision: ( params: UnpublishEntryRevisionParams ) => Promise; @@ -127,6 +121,20 @@ interface ApolloClientsCache { [locale: string]: ApolloClient; } +interface Mutations { + [key: string]: DocumentNode; +} + +interface CreateMutationKeyParams { + model: CmsModel; + locale: string; +} + +const createMutationKey = (params: CreateMutationKeyParams): string => { + const { model, locale } = params; + return `${model.modelId}_${locale}_${model.savedOn}`; +}; + const apolloClientsCache: ApolloClientsCache = {}; export interface CmsProviderProps { @@ -137,11 +145,21 @@ export interface CmsProviderProps { export const CmsProvider = (props: CmsProviderProps) => { const apiUrl = appConfig.getKey("API_URL", process.env.REACT_APP_API_URL); const { getCurrentLocale } = useI18N(); - - const onEntryRevisionPublish = useRef([]); + const mutations = useRef({}); const onEntryRevisionUnpublish = useRef([]); const onEntryDelete = useRef([]); + const getMutation = useCallback( + (model: CmsModel, locale: string): DocumentNode => { + const key = createMutationKey({ model, locale }); + if (!mutations.current[key]) { + mutations.current[key] = createPublishMutation(model); + } + return mutations.current[key]; + }, + [mutations.current] + ); + const currentLocale = getCurrentLocale("content"); if (currentLocale && !apolloClientsCache[currentLocale]) { @@ -168,18 +186,34 @@ export const CmsProvider = (props: CmsProviderProps) => { createApolloClient: props.createApolloClient, apolloClient: getApolloClient(currentLocale), publishEntryRevision: async params => { - return await composeAsync([...onEntryRevisionPublish.current].reverse())({ - locale: currentLocale, - ...params, - client: getApolloClient(currentLocale), - options: params.options || {} + const mutation = getMutation(params.model, currentLocale); + const response = await value.apolloClient.mutate< + CmsEntryPublishMutationResponse, + CmsEntryPublishMutationVariables + >({ + mutation, + variables: { + revision: params.id + } }); - }, - onEntryRevisionPublish: fn => { - onEntryRevisionPublish.current.push(fn); - return () => { - const index = onEntryRevisionPublish.current.length; - onEntryRevisionPublish.current.splice(index, 1); + + if (!response.data) { + const error: CmsErrorResponse = { + message: "Missing response data on Publish Entry Mutation.", + code: "MISSING_RESPONSE_DATA", + data: {} + }; + return { error }; + } + + const { data, error } = response.data.content; + + if (error) { + return { error }; + } + + return { + entry: data as CmsContentEntry }; }, unpublishEntryRevision: async params => { diff --git a/packages/app-headless-cms/src/admin/plugins/entry/DefaultOnEntryPublish.ts b/packages/app-headless-cms/src/admin/plugins/entry/DefaultOnEntryPublish.ts deleted file mode 100644 index 988d6ef63cf..00000000000 --- a/packages/app-headless-cms/src/admin/plugins/entry/DefaultOnEntryPublish.ts +++ /dev/null @@ -1,98 +0,0 @@ -import React, { useCallback, useEffect, useRef } from "react"; -import { DocumentNode } from "graphql"; -import { - CmsEntryPublishMutationResponse, - CmsEntryPublishMutationVariables, - createPublishMutation -} from "@webiny/app-headless-cms-common"; -import { CmsErrorResponse, CmsModel } from "~/types"; -import { useCms } from "~/admin/hooks"; -import { OnEntryPublishResponse } from "~/admin/contexts/Cms"; - -interface Mutations { - [key: string]: DocumentNode; -} - -interface CreateMutationKeyParams { - model: CmsModel; - locale: string; -} - -const createMutationKey = (params: CreateMutationKeyParams): string => { - const { model, locale } = params; - return `${model.modelId}_${locale}_${model.savedOn}`; -}; - -const OnEntryPublish = () => { - const { onEntryRevisionPublish } = useCms(); - - const mutations = useRef({}); - - const getMutation = useCallback( - (model: CmsModel, locale: string): DocumentNode => { - const key = createMutationKey({ model, locale }); - if (!mutations.current[key]) { - mutations.current[key] = createPublishMutation(model); - } - return mutations.current[key]; - }, - [mutations.current] - ); - - const handleOnPublish = async ({ model, id, client, locale }: OnEntryPublishResponse) => { - const mutation = getMutation(model, locale); - - const response = await client.mutate< - CmsEntryPublishMutationResponse, - CmsEntryPublishMutationVariables - >({ - mutation, - variables: { - revision: id - } - }); - - if (!response.data) { - const error: CmsErrorResponse = { - message: "Missing response data on Publish Entry Mutation.", - code: "MISSING_RESPONSE_DATA", - data: {} - }; - return { - error - }; - } - const { data, error } = response.data.content; - if (error) { - return { - error - }; - } - - return { - entry: data, - error: null - }; - }; - - useEffect(() => { - return onEntryRevisionPublish(next => async params => { - const result = await next(params); - - if (result.error) { - return result; - } - - const response = await handleOnPublish(params); - - return { - ...result, - ...response - }; - }); - }, []); - - return null; -}; - -export const DefaultOnEntryPublish = React.memo(OnEntryPublish); diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/DynamicSection.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/DynamicSection.tsx index 48fb54b965c..987a54f1af4 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/DynamicSection.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/DynamicSection.tsx @@ -8,6 +8,7 @@ import { FormElementMessage } from "@webiny/ui/FormElementMessage"; import { ReactComponent as AddIcon } from "@webiny/app-admin/assets/icons/add-18px.svg"; import { GetBindCallable } from "~/admin/components/ContentEntryForm/useBind"; import { ParentFieldProvider } from "~/admin/hooks"; +import { ParentValueIndexProvider } from "~/admin/components/ModelFieldProvider"; const t = i18n.ns("app-headless-cms/admin/fields/text"); @@ -74,10 +75,7 @@ const DynamicSection = ({ {bindProps => ( - + {/* We bind it to index "0", so when you start typing, that index in parent array will be populated */} {children({ Bind: FirstFieldBind, @@ -90,7 +88,7 @@ const DynamicSection = ({ }, index: 0 // Binds to "items.0" in the
. })} - + )} @@ -104,10 +102,7 @@ const DynamicSection = ({ {bindProps => ( - + {children({ Bind: BindField, field, @@ -117,7 +112,7 @@ const DynamicSection = ({ }, index: realIndex })} - + )} diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/AddTemplate.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/AddTemplate.tsx index 455bccde1af..a3eee887b7e 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/AddTemplate.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/AddTemplate.tsx @@ -3,9 +3,10 @@ import styled from "@emotion/styled"; import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; import { ReactComponent as AddIcon } from "@material-design-icons/svg/round/add_circle_outline.svg"; import { Typography } from "@webiny/ui/Typography"; -import { CmsDynamicZoneTemplate } from "~/types"; +import { CmsDynamicZoneTemplate, CmsDynamicZoneTemplateWithTypename } from "~/types"; import { TemplateGallery } from "./TemplateGallery"; import { IconButton, ButtonSecondary } from "@webiny/ui/Button"; +import { useTemplateTypename } from "~/admin/plugins/fieldRenderers/dynamicZone/useTemplateTypename"; const AddIconContainer = styled.div` text-align: center; @@ -34,18 +35,19 @@ const Info = styled.div` `; interface UseAddTemplateParams { - onTemplate: (template: CmsDynamicZoneTemplate) => void; + onTemplate: (template: CmsDynamicZoneTemplateWithTypename) => void; } function useAddTemplate(params: UseAddTemplateParams) { const [showGallery, setShowGallery] = useState(false); + const { getFullTypename } = useTemplateTypename(); const browseTemplates = () => { setShowGallery(true); }; const onTemplate = (template: CmsDynamicZoneTemplate) => { - params.onTemplate(template); + params.onTemplate({ ...template, __typename: getFullTypename(template) }); onGalleryClose(); }; diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx index 82a45ad71ce..066f39785f5 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx @@ -16,10 +16,12 @@ import { CmsDynamicZoneTemplate, CmsModelFieldRendererProps, CmsModel, - CmsModelField + CmsModelField, + CmsDynamicZoneTemplateWithTypename } from "~/types"; import { makeDecoratable } from "@webiny/react-composition"; import { TemplateProvider } from "~/admin/plugins/fieldRenderers/dynamicZone/TemplateProvider"; +import { ParentValueIndexProvider } from "~/admin/components/ModelFieldProvider"; const BottomMargin = styled.div` margin-bottom: 20px; @@ -180,8 +182,8 @@ interface MultiValueDynamicZoneProps { export const MultiValueDynamicZone = (props: MultiValueDynamicZoneProps) => { const { bind, getBind, contentModel } = props; - const onTemplate = (template: CmsDynamicZoneTemplate) => { - bind.appendValue({ _templateId: template.id }); + const onTemplate = (template: CmsDynamicZoneTemplateWithTypename) => { + bind.appendValue({ _templateId: template.id, __typename: template.__typename }); }; const cloneValue = (index: number) => { @@ -203,11 +205,7 @@ export const MultiValueDynamicZone = (props: MultiValueDynamicZoneProps) => { const Bind = getBind(index); return ( - + { onDelete={() => bind.removeValue(index)} onClone={() => cloneValue(index)} /> - + ); })} diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx index 3bd7c4c1d66..aa7450755f8 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx @@ -9,10 +9,15 @@ import { CmsDynamicZoneTemplate, CmsModelFieldRendererProps, CmsModel, - CmsModelField + CmsModelField, + CmsDynamicZoneTemplateWithTypename } from "~/types"; import { Fields } from "~/admin/components/ContentEntryForm/Fields"; import { ParentFieldProvider } from "~/admin/components/ContentEntryForm/ParentValue"; +import { + ParentValueIndexProvider, + ModelFieldProvider +} from "~/admin/components/ModelFieldProvider"; type GetBind = CmsModelFieldRendererProps["getBind"]; @@ -29,8 +34,8 @@ export const SingleValueDynamicZone = ({ contentModel, getBind }: SingleValueDynamicZoneProps) => { - const onTemplate = (template: CmsDynamicZoneTemplate) => { - bind.onChange({ _templateId: template.id }); + const onTemplate = (template: CmsDynamicZoneTemplateWithTypename) => { + bind.onChange({ _templateId: template.id, __typename: template.__typename }); }; const templates = field.settings?.templates || []; @@ -49,32 +54,36 @@ export const SingleValueDynamicZone = ({ <> {template ? ( - - } - open={true} - interactive={false} - actions={ - - } - onClick={unsetValue} - /> - - } - > - - - - - + + + + } + open={true} + interactive={false} + actions={ + + } + onClick={unsetValue} + /> + + } + > + + + + + + + ) : null} {bind.value ? null : } diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/useTemplateTypename.ts b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/useTemplateTypename.ts new file mode 100644 index 00000000000..af5c505742b --- /dev/null +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/useTemplateTypename.ts @@ -0,0 +1,35 @@ +import upperFirst from "lodash/upperFirst"; +import camelCase from "lodash/camelCase"; +import { CmsDynamicZoneTemplate } from "@webiny/app-headless-cms-common/types"; +import { useParentField } from "~/admin/components/ContentEntryForm/ParentValue"; +import { useModelField } from "~/admin/components/ModelFieldProvider"; +import { useModel } from "~/admin/components/ModelProvider"; + +const createTypeName = (modelId: string): string => { + return upperFirst(camelCase(modelId)); +}; + +export const useTemplateTypename = () => { + const { model } = useModel(); + const { field } = useModelField(); + const parent = useParentField(); + + const getFullTypename = (template: CmsDynamicZoneTemplate) => { + let currentParent = parent; + const parents: string[] = []; + while (currentParent) { + parents.push(createTypeName(currentParent.field.fieldId)); + + currentParent = currentParent.getParentField(0); + } + + return [ + model.singularApiName, + ...parents.reverse(), + createTypeName(field.fieldId), + template.gqlTypeName + ].join("_"); + }; + + return { getFullTypename }; +}; diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/hidden.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/hidden.tsx new file mode 100644 index 00000000000..e1add07565b --- /dev/null +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/hidden.tsx @@ -0,0 +1,17 @@ +import { CmsModelFieldRendererPlugin } from "~/types"; + +export const hiddenFieldRenderer: CmsModelFieldRendererPlugin = { + type: "cms-editor-field-renderer", + name: "cms-editor-field-renderer-hidden", + renderer: { + rendererName: "hidden", + name: "Hidden Field", + description: `Hides the component from the UI.`, + canUse() { + return true; + }, + render() { + return null; + } + } +}; diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx index 0331abef5e2..95eb60be5e0 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx @@ -23,7 +23,6 @@ import { } from "./StyledComponents"; import { generateAlphaNumericLowerCaseId } from "@webiny/utils"; import { FieldSettings } from "./FieldSettings"; -import { ParentFieldProvider } from "~/admin/components/ContentEntryForm/ParentValue"; const t = i18n.ns("app-headless-cms/admin/fields/text"); @@ -112,18 +111,13 @@ const ObjectsRenderer = (props: CmsModelFieldRendererProps) => { defaultValue={index === 0} > - - - + diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/singleObjectAccordion.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/singleObjectAccordion.tsx index 2ea0d54b18b..1d8e2893d4c 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/singleObjectAccordion.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/singleObjectAccordion.tsx @@ -5,6 +5,7 @@ import { CmsModelFieldRendererPlugin } from "~/types"; import { Fields } from "~/admin/components/ContentEntryForm/Fields"; import { FieldSettings } from "./FieldSettings"; import { ParentFieldProvider } from "~/admin/hooks"; +import { ParentValueIndexProvider } from "~/admin/components/ModelFieldProvider"; const t = i18n.ns("app-headless-cms/admin/fields/text"); @@ -34,16 +35,18 @@ const plugin: CmsModelFieldRendererPlugin = { {bindProps => ( - - - - - + + + + + + + )} diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/singleObjectInline.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/singleObjectInline.tsx index 182c9e66700..8dca21f1625 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/singleObjectInline.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/singleObjectInline.tsx @@ -8,6 +8,7 @@ import { FormElementMessage } from "@webiny/ui/FormElementMessage"; import { fieldsWrapperStyle } from "./StyledComponents"; import { FieldSettings } from "./FieldSettings"; import { ParentFieldProvider } from "~/admin/components/ContentEntryForm/ParentValue"; +import { ParentValueIndexProvider } from "~/admin/components/ModelFieldProvider"; const t = i18n.ns("app-headless-cms/admin/fields/text"); @@ -37,22 +38,26 @@ const plugin: CmsModelFieldRendererPlugin = { {bindProps => ( - - - - {field.helpText && ( - {field.helpText} - )} - - - - - + + + + + {field.helpText && ( + + {field.helpText} + + )} + + + + + + )} diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/simple/components/SimpleSingleRenderer.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/simple/components/SimpleSingleRenderer.tsx index 4c655111460..7ed019616b9 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/simple/components/SimpleSingleRenderer.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/ref/simple/components/SimpleSingleRenderer.tsx @@ -8,6 +8,7 @@ import { AddItemParams, SimpleItems } from "./SimpleItems"; interface SimpleSingleRendererProps { bind: BindComponentRenderProp; field: CmsModelField; + loadingElement?: JSX.Element; } export const SimpleSingleRenderer = (props: SimpleSingleRendererProps) => { @@ -35,6 +36,10 @@ export const SimpleSingleRenderer = (props: SimpleSingleRendererProps) => { bind.onChange(null); }, [bind, value]); + if (references.loading && props.loadingElement) { + return props.loadingElement; + } + return ( name={"settings.templates"}> + {({ value: templates }) => { return ( <> - {templates.map((template, index) => { + {(templates as CmsDynamicZoneTemplate[]).map((template, index) => { const icon = template.icon ? (template.icon.split("/") as FontAwesomeIconProps["icon"]) : undefined; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntries.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntries.tsx index 5adf6d1ece3..caacf542b5a 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntries.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntries.tsx @@ -1,4 +1,6 @@ import React from "react"; +import { DialogsProvider } from "@webiny/app-admin"; +import { AcoWithConfig } from "@webiny/app-aco"; import { Table as CmsAcoTable } from "./Table"; import { useModel } from "~/admin/components/ModelProvider"; import { @@ -6,7 +8,6 @@ import { ContentEntryListWithConfig } from "~/admin/config/contentEntries"; import { ContentEntriesProvider } from "~/admin/views/contentEntries/ContentEntriesContext"; -import { AcoWithConfig } from "@webiny/app-aco"; export const ContentEntries = () => { const { model } = useModel(); @@ -16,7 +17,9 @@ export const ContentEntries = () => { - + + + diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx index cf79270c292..31068ba1b5c 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry.tsx @@ -10,7 +10,6 @@ import { Elevation } from "@webiny/ui/Elevation"; import { CircularProgress } from "@webiny/ui/Progress"; import RevisionsList from "./ContentEntry/RevisionsList"; import { useContentEntry } from "./hooks/useContentEntry"; -import { Header } from "~/admin/components/ContentEntryForm/Header"; import { ContentEntryForm } from "~/admin/components/ContentEntryForm/ContentEntryForm"; const t = i18n.namespace("app-headless-cms/admin/content-model-entries/details"); @@ -90,7 +89,6 @@ export const ContentEntry = () => { {loading && } -
setFormRef(form)} diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx index 79966276f38..4add26842cd 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/ContentEntryContext.tsx @@ -8,7 +8,6 @@ import React, { useRef, useState } from "react"; -import isEmpty from "lodash/isEmpty"; import get from "lodash/get"; import { useRouter } from "@webiny/react-router"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; @@ -38,6 +37,7 @@ type ContentEntryContextFormRef = MutableRefObject; export interface ContentEntryContext extends ContentEntriesContext { createEntry: () => void; entry: CmsContentEntry; + setEntry: (entry: CmsContentEntry) => void; form: ContentEntryContextFormRef; setFormRef: (form: Pick) => void; loading: boolean; @@ -84,6 +84,7 @@ export const ContentEntryProvider = ({ getContentId }: ContentEntryContextProviderProps) => { const [activeTab, setActiveTab] = useState(0); + const [entry, setEntry] = useState(); const { contentModel, canCreate } = useContentEntries(); const { search } = useRouter(); @@ -116,6 +117,12 @@ export const ContentEntryProvider = ({ version = result.version; } + useEffect(() => { + if (!revisionId && entry) { + setEntry(undefined); + } + }, [revisionId]); + const { READ_CONTENT } = useMemo(() => { return { READ_CONTENT: createReadQuery(contentModel) @@ -162,13 +169,14 @@ export const ContentEntryProvider = ({ variables, skip: !revisionId, fetchPolicy: getFetchPolicy(contentModel), - onCompleted: data => { - if (!data) { + onCompleted: response => { + if (!response) { return; } - const { error } = data.content; + const { data, error } = response.content; if (!error) { + setEntry(data); return; } history.push(`/cms/content-entries/${contentModel.modelId}`); @@ -196,13 +204,13 @@ export const ContentEntryProvider = ({ }, [revisionId, getRevisions]); const loading = isLoading || getEntry.loading || getRevisions.loading; - const entry = (get(getEntry, "data.content.data") as unknown as CmsContentEntry) || {}; const value: ContentEntryContext = { canCreate, contentModel, createEntry, - entry, + entry: (entry || {}) as CmsContentEntry, + setEntry, form: formRef, loading, revisions: get(getRevisions, "data.revisions.data") || [], @@ -211,7 +219,7 @@ export const ContentEntryProvider = ({ setLoading, setActiveTab, activeTab, - showEmptyView: !newEntry && !loading && isEmpty(entry) + showEmptyView: !newEntry && !loading && !revisionId }; return {children}; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionListItem.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionListItem.tsx index dff359cda42..bb0578aceca 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionListItem.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/RevisionListItem.tsx @@ -24,7 +24,7 @@ import { ReactComponent as DeleteIcon } from "~/admin/icons/delete.svg"; import { CmsContentEntryRevision } from "~/types"; import { i18n } from "@webiny/app/i18n"; import { useRevision } from "./useRevision"; -import usePermission from "~/admin/hooks/usePermission"; +import { usePermission } from "~/admin/hooks/usePermission"; import { useContentEntry } from "~/admin/views/contentEntries/hooks/useContentEntry"; import { PublishEntryRevisionListItem } from "./PublishEntryRevisionListItem"; import { useConfirmationDialog } from "@webiny/app-admin"; diff --git a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/useRevision.tsx b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/useRevision.tsx index 1a65bd21646..5f837b39189 100644 --- a/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/useRevision.tsx +++ b/packages/app-headless-cms/src/admin/views/contentEntries/ContentEntry/useRevision.tsx @@ -2,6 +2,8 @@ import React, { useMemo } from "react"; import { useRouter } from "@webiny/react-router"; import { useHandlers } from "@webiny/app/hooks/useHandlers"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; +import { makeDecoratable } from "@webiny/app-admin"; +import { useRecords } from "@webiny/app-aco"; import { CmsContentEntry } from "~/types"; import { CmsEntryCreateFromMutationResponse, @@ -11,23 +13,23 @@ import { import { useApolloClient, useCms } from "~/admin/hooks"; import { useContentEntry } from "~/admin/views/contentEntries/hooks/useContentEntry"; import { getFetchPolicy } from "~/utils/getFetchPolicy"; -import { useRecords } from "@webiny/app-aco"; +import { OnEntryPublishResponse } from "~/admin/contexts/Cms"; -interface CreateRevisionHandler { +export interface CreateRevisionHandler { (id?: string): Promise; } -interface EditRevisionHandler { +export interface EditRevisionHandler { (id?: string): void; } -interface DeleteRevisionHandler { +export interface DeleteRevisionHandler { (id?: string): Promise; } -interface PublishRevisionHandler { - (id?: string): Promise; +export interface PublishRevisionHandler { + (id?: string): Promise; } -interface UnpublishRevisionHandler { +export interface UnpublishRevisionHandler { (id?: string): Promise; } interface UseRevisionHandlers { @@ -44,9 +46,9 @@ export interface UseRevisionProps { }; } -export const useRevision = ({ revision }: UseRevisionProps) => { +export const useRevision = makeDecoratable(({ revision }: UseRevisionProps) => { const { publishEntryRevision, unpublishEntryRevision, deleteEntry } = useCms(); - const { contentModel, entry, setLoading } = useContentEntry(); + const { contentModel, entry, setEntry, setLoading } = useContentEntry(); const { history } = useRouter(); const { showSnackbar } = useSnackbar(); @@ -149,20 +151,22 @@ export const useRevision = ({ revision }: UseRevisionProps) => { setLoading(false); - const { error, entry: entryResult } = response; - if (error) { - showSnackbar(error.message); - return; + if (response.error) { + showSnackbar(response.error.message); + return response; } - updateRecordInCache(entryResult); + setEntry(response.entry); + updateRecordInCache(response.entry); showSnackbar( Successfully published revision{" "} - #{response.entry!.meta.version}! + #{response.entry.meta.version}! ); + + return response; }, unpublishRevision: ({ entry }): UnpublishRevisionHandler => @@ -203,4 +207,4 @@ export const useRevision = ({ revision }: UseRevisionProps) => { publishRevision, unpublishRevision }; -}; +}); diff --git a/packages/app-headless-cms/src/admin/views/contentModels/ContentModelEditor.tsx b/packages/app-headless-cms/src/admin/views/contentModels/ContentModelEditor.tsx index c9ea60e4f2a..91e40b2ba15 100644 --- a/packages/app-headless-cms/src/admin/views/contentModels/ContentModelEditor.tsx +++ b/packages/app-headless-cms/src/admin/views/contentModels/ContentModelEditor.tsx @@ -1,7 +1,7 @@ import React from "react"; import { HTML5Backend } from "react-dnd-html5-backend"; import { DndProvider } from "react-dnd"; -import { Editor } from "~/admin/components/ContentModelEditor/Editor"; +import { ContentModelEditor } from "~/admin/components/ContentModelEditor/ContentModelEditor"; import { useRouter } from "@webiny/react-router"; import { useCms } from "~/admin/hooks"; import { ContentModelEditorProvider } from "~/admin/components/ContentModelEditor"; @@ -20,7 +20,7 @@ const ContentModelEditorView = () => { return ( - + ); diff --git a/packages/app-headless-cms/src/allPlugins.ts b/packages/app-headless-cms/src/allPlugins.ts index 8d50a3fbb75..bf3fefafe68 100644 --- a/packages/app-headless-cms/src/allPlugins.ts +++ b/packages/app-headless-cms/src/allPlugins.ts @@ -20,6 +20,7 @@ import selectFieldRenderer from "~/admin/plugins/fieldRenderers/select"; import checkboxesFieldRenderer from "~/admin/plugins/fieldRenderers/checkboxes"; import refFieldRenderer from "~/admin/plugins/fieldRenderers/ref"; import objectFieldRenderer from "~/admin/plugins/fieldRenderers/object"; +import { hiddenFieldRenderer } from "~/admin/plugins/fieldRenderers/hidden"; import editorGteFieldValidator from "~/admin/plugins/fieldValidators/gte"; import editorDateGteFieldValidator from "~/admin/plugins/fieldValidators/dateGte"; @@ -83,5 +84,6 @@ export default [ objectField, objectFieldRenderer, dynamicZoneField, - dynamicZoneFieldRenderer + dynamicZoneFieldRenderer, + hiddenFieldRenderer ]; diff --git a/packages/app-headless-cms/src/components.ts b/packages/app-headless-cms/src/components.ts index 3e5675c9f6b..8fa6454a7d2 100644 --- a/packages/app-headless-cms/src/components.ts +++ b/packages/app-headless-cms/src/components.ts @@ -6,8 +6,21 @@ import { TemplateGallery, useTemplate } from "~/admin/plugins/fieldRenderers/dynamicZone"; +import { ContentEntryForm as BaseContentEntryForm } from "./admin/components/ContentEntryForm/ContentEntryForm"; +import { ContentEntryFormPreview } from "./admin/components/ContentEntryForm/ContentEntryFormPreview"; +import { useContentEntryForm } from "./admin/components/ContentEntryForm/useContentEntryForm"; +import { useRevision } from "./admin/views/contentEntries/ContentEntry/useRevision"; +import { useContentEntry } from "~/admin/views/contentEntries/hooks"; export const Components = { + ContentEntry: { + ContentEntryForm: Object.assign(BaseContentEntryForm, { + useContentEntryForm, + useContentEntry + }), + ContentEntryFormPreview, + useRevision + }, FieldRenderers: { DynamicZone: { Template: { diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx index b1b18fec746..d43d6e36b8c 100644 --- a/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx @@ -20,6 +20,6 @@ const BaseColumn = (props: ColumnProps) => { }; export const Column = Object.assign(BaseColumn, { - useTableRow: Table.Column.useTableRow, + useTableRow: Table.Column.createUseTableRow(), isFolderRow: Table.Column.isFolderRow }); diff --git a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx index 327d2c0b877..14462c1d997 100644 --- a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx +++ b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx @@ -20,6 +20,6 @@ const BaseColumn = (props: ColumnProps) => { }; export const Column = Object.assign(BaseColumn, { - useTableRow: Table.Column.useTableRow, + useTableRow: Table.Column.createUseTableRow(), isFolderRow: Table.Column.isFolderRow }); diff --git a/packages/app/src/core/AddRoute.tsx b/packages/app/src/core/AddRoute.tsx index 429327b5e29..7eefa9c5f78 100644 --- a/packages/app/src/core/AddRoute.tsx +++ b/packages/app/src/core/AddRoute.tsx @@ -1,8 +1,9 @@ import React, { useEffect } from "react"; -import { useApp } from "~/App"; import { Route, RouteProps } from "@webiny/react-router"; +import { useApp } from "~/App"; +import { makeDecoratable } from "@webiny/react-composition"; -export const AddRoute = (props: RouteProps) => { +export const AddRoute = makeDecoratable("AddRoute", (props: RouteProps) => { const { addRoute } = useApp(); useEffect(() => { @@ -10,4 +11,4 @@ export const AddRoute = (props: RouteProps) => { }, []); return null; -}; +});