diff --git a/packages/slice-machine/components/Forms/CreateSliceModal/CreateSliceModal.tsx b/packages/slice-machine/components/Forms/CreateSliceModal/CreateSliceModal.tsx index 89d6301952..88ab46e96e 100644 --- a/packages/slice-machine/components/Forms/CreateSliceModal/CreateSliceModal.tsx +++ b/packages/slice-machine/components/Forms/CreateSliceModal/CreateSliceModal.tsx @@ -15,10 +15,7 @@ import { InputBox } from "../components/InputBox"; type CreateSliceModalProps = { onClose: () => void; - onSuccess: ( - newSlice: SharedSlice, - libraryName: string, - ) => Promise | void; + onSuccess: (newSlice: SharedSlice, libraryName: string) => void; localLibraries: readonly LibraryUI[]; remoteSlices: ReadonlyArray; }; @@ -49,7 +46,7 @@ export const CreateSliceModal: FC = ({ // Update Redux store createSliceSuccess(serverState.libraries); - await onSuccess(newSlice, libraryName); + onSuccess(newSlice, libraryName); }, }); }; diff --git a/packages/slice-machine/components/Forms/RenameCustomTypeModal/RenameCustomTypeModal.tsx b/packages/slice-machine/components/Forms/RenameCustomTypeModal/RenameCustomTypeModal.tsx index 17cd89cdb6..dedf0075ae 100644 --- a/packages/slice-machine/components/Forms/RenameCustomTypeModal/RenameCustomTypeModal.tsx +++ b/packages/slice-machine/components/Forms/RenameCustomTypeModal/RenameCustomTypeModal.tsx @@ -6,13 +6,14 @@ import { InputBox } from "../components/InputBox"; import { useSelector } from "react-redux"; import { SliceMachineStoreType } from "@src/redux/type"; import { FormikErrors } from "formik"; -import { selectAllCustomTypeLabels } from "@src/modules/availableCustomTypes"; +import { selectAllCustomTypeLabels } from "@src/modules/availableCustomTypes"; import { CustomType } from "@prismicio/types-internal/lib/customtypes"; import { CustomTypeFormat } from "@slicemachine/manager"; import { CUSTOM_TYPES_MESSAGES } from "@src/features/customTypes/customTypesMessages"; import { renameCustomType } from "@src/features/customTypes/actions/renameCustomType"; +import { useCustomTypeState } from "@src/features/customTypes/customTypesBuilder/CustomTypeProvider"; interface RenameCustomTypeModalProps { isChangesLocal: boolean; @@ -29,15 +30,18 @@ export const RenameCustomTypeModal: React.FC = ({ }) => { const customTypeName = customType?.label ?? ""; const customTypeId = customType?.id ?? ""; - const { renameAvailableCustomTypeSuccess, renameSelectedCustomType } = - useSliceMachineActions(); + const { renameAvailableCustomTypeSuccess } = useSliceMachineActions(); + const { setCustomType } = useCustomTypeState(); const [isRenaming, setIsRenaming] = useState(false); const handleOnSubmit = async (values: { customTypeName: string }) => { setIsRenaming(true); if (isChangesLocal) { - renameSelectedCustomType(values.customTypeName); + setCustomType({ + ...customType, + label: values.customTypeName, + }); } else { await renameCustomType({ model: customType, diff --git a/packages/slice-machine/components/ItemHeader/index.tsx b/packages/slice-machine/components/ItemHeader/index.tsx index 05606f13db..840d9f4123 100644 --- a/packages/slice-machine/components/ItemHeader/index.tsx +++ b/packages/slice-machine/components/ItemHeader/index.tsx @@ -22,6 +22,7 @@ const ItemHeader: React.FC<{ /> { CustomEditElements?: JSX.Element[]; widget: Widget; draggableId: string; + dataCy: string; isRepeatableCustomType?: boolean; children: React.ReactNode; } @@ -50,6 +51,7 @@ function ListItem({ CustomEditElements, widget, draggableId, + dataCy, isRepeatableCustomType, children, }: ListItemProps): JSX.Element { @@ -66,6 +68,7 @@ function ListItem({ {(provided) => (
  • ({ enterEditMode( diff --git a/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/SlicesTemplatesModal.tsx b/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/SlicesTemplatesModal.tsx index 49d3af1b93..52293b3130 100644 --- a/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/SlicesTemplatesModal.tsx +++ b/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/SlicesTemplatesModal.tsx @@ -18,7 +18,7 @@ import { sliceTemplatesComingSoon } from "./sliceTemplatesComingSoon"; interface UpdateSliceModalProps { formId: string; close: () => void; - onSuccess: (slices: SharedSlice[]) => Promise; + onSuccess: (slices: SharedSlice[]) => void; availableSlicesTemplates: SliceTemplate[]; localLibraries: readonly LibraryUI[]; } @@ -69,7 +69,7 @@ export const SlicesTemplatesModal: FC = ({ ) as Promise[], ); - await onSuccess(slices); + onSuccess(slices); }, }); }} diff --git a/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModal.tsx b/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModal.tsx index 6851c797a7..c9d7ed5213 100644 --- a/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModal.tsx +++ b/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/UpdateSliceZoneModal.tsx @@ -9,7 +9,7 @@ import UpdateSliceZoneModalList from "./UpdateSliceZoneModalList"; interface UpdateSliceModalProps { formId: string; close: () => void; - onSubmit: (slices: SharedSlice[]) => Promise; + onSubmit: (slices: SharedSlice[]) => void; availableSlices: ReadonlyArray; } @@ -37,7 +37,7 @@ const UpdateSliceZoneModal: React.FC = ({ availableSlices.find((s) => s.model.id === sliceKey)?.model, ) .filter((slice) => slice !== undefined) as SharedSlice[]; - void onSubmit(slices); + onSubmit(slices); }} initialValues={{ sliceKeys: [], diff --git a/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/index.tsx b/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/index.tsx index fbd6c1eea3..51b70a4fb7 100644 --- a/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/index.tsx +++ b/packages/slice-machine/lib/builders/CustomTypeBuilder/SliceZone/index.tsx @@ -31,7 +31,6 @@ import { } from "@src/modules/slices"; import type { SliceMachineStoreType } from "@src/redux/type"; import { useSlicesTemplates } from "@src/features/slicesTemplates/useSlicesTemplates"; -import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import { addSlicesToSliceZone } from "@src/features/slices/actions/addSlicesToSliceZone"; import { ToastMessageWithPath } from "@components/ToasterContainer"; @@ -39,6 +38,7 @@ import { DeleteSliceZoneModal } from "./DeleteSliceZoneModal"; import UpdateSliceZoneModal from "./UpdateSliceZoneModal"; import { SlicesTemplatesModal } from "./SlicesTemplatesModal"; import { SlicesList } from "./List"; +import { useCustomTypeState } from "@src/features/customTypes/customTypesBuilder/CustomTypeProvider"; const mapAvailableAndSharedSlices = ( sliceZone: SlicesSM, @@ -121,8 +121,8 @@ const SliceZone: React.FC = ({ slices: getFrontendSlices(store), }), ); - const { initCustomTypeStore, saveCustomTypeSuccess } = - useSliceMachineActions(); + const { setCustomType } = useCustomTypeState(); + const localLibraries: readonly LibraryUI[] = libraries.filter( (library) => library.isLocal, ); @@ -197,14 +197,6 @@ const SliceZone: React.FC = ({ setIsSlicesTemplatesModalOpen(false); }; - const onAddSlicesToSliceZone = (newCustomType: CustomTypeSM) => { - // Reset selected custom type store to update slice zone and saving status - initCustomTypeStore(newCustomType, newCustomType); - - // Update available custom type store with new custom type - saveCustomTypeSuccess(CustomTypes.fromSM(newCustomType)); - }; - return ( <> {query.newPageType === undefined ? ( @@ -299,13 +291,13 @@ const SliceZone: React.FC = ({ { - const newCustomType = await addSlicesToSliceZone({ + onSubmit={(slices: SharedSlice[]) => { + const newCustomType = addSlicesToSliceZone({ customType, tabId, slices, }); - onAddSlicesToSliceZone(newCustomType); + setCustomType(CustomTypes.fromSM(newCustomType)); closeUpdateSliceZoneModal(); redirectToEditMode(); toast.success("Slice(s) added to slice zone"); @@ -318,13 +310,13 @@ const SliceZone: React.FC = ({ formId={`tab-slicezone-form-${tabId}`} availableSlicesTemplates={availableSlicesTemplates} localLibraries={localLibraries} - onSuccess={async (slices: SharedSlice[]) => { - const newCustomType = await addSlicesToSliceZone({ + onSuccess={(slices: SharedSlice[]) => { + const newCustomType = addSlicesToSliceZone({ customType, tabId, slices, }); - onAddSlicesToSliceZone(newCustomType); + setCustomType(CustomTypes.fromSM(newCustomType)); closeSlicesTemplatesModal(); redirectToEditMode(); toast.success( @@ -350,13 +342,13 @@ const SliceZone: React.FC = ({ )} {localLibraries?.length !== 0 && isCreateSliceModalOpen && ( { - const newCustomType = await addSlicesToSliceZone({ + onSuccess={(newSlice: SharedSlice) => { + const newCustomType = addSlicesToSliceZone({ customType, tabId, slices: [newSlice], }); - onAddSlicesToSliceZone(newCustomType); + setCustomType(CustomTypes.fromSM(newCustomType)); closeCreateSliceModal(); redirectToEditMode(); toast.success( diff --git a/packages/slice-machine/lib/builders/CustomTypeBuilder/TabModal/update.tsx b/packages/slice-machine/lib/builders/CustomTypeBuilder/TabModal/update.tsx index 0dfb012806..51d5431f87 100644 --- a/packages/slice-machine/lib/builders/CustomTypeBuilder/TabModal/update.tsx +++ b/packages/slice-machine/lib/builders/CustomTypeBuilder/TabModal/update.tsx @@ -10,11 +10,13 @@ const UpdateCustomTypeForm = ({ onSubmit, close, tabIds, + initialTabKey, }: { isOpen: boolean; onSubmit: (values: { id: string }) => void; close: () => void; tabIds: ReadonlyArray; + initialTabKey: string; }) => { return ( { if (!id) { diff --git a/packages/slice-machine/lib/builders/CustomTypeBuilder/TabZone/index.tsx b/packages/slice-machine/lib/builders/CustomTypeBuilder/TabZone/index.tsx index a0f06ca988..824fc1a81d 100644 --- a/packages/slice-machine/lib/builders/CustomTypeBuilder/TabZone/index.tsx +++ b/packages/slice-machine/lib/builders/CustomTypeBuilder/TabZone/index.tsx @@ -1,67 +1,67 @@ import { Box, ErrorBoundary, ProgressCircle } from "@prismicio/editor-ui"; import { FC, Suspense } from "react"; import type { DropResult } from "react-beautiful-dnd"; -import { useSelector } from "react-redux"; import type { AnyObjectSchema } from "yup"; import { useRouter } from "next/router"; import ctBuilderArray from "@lib/models/common/widgets/ctBuilderArray"; -import type { +import { + TabSM, CustomTypeSM, - TabField, - TabFields, + type TabField, + type TabFields, + CustomTypes, } from "@lib/models/common/CustomType"; -import type { SlicesSM } from "@lib/models/common/Slices"; import { ensureDnDDestination, ensureWidgetTypeExistence } from "@lib/utils"; import { List } from "@src/components/List"; -import useSliceMachineActions from "@src/modules/useSliceMachineActions"; -import type { SliceMachineStoreType } from "@src/redux/type"; import { telemetry } from "@src/apiClient"; import { transformKeyAccessor } from "@utils/str"; +import { useCustomTypeState } from "@src/features/customTypes/customTypesBuilder/CustomTypeProvider"; +import { + createSectionSliceZone, + deleteSectionSliceZone, + deleteSliceZoneSlice, + addField, + deleteField, + reorderField, + updateField, +} from "@src/domain/customType"; import * as Widgets from "../../../../lib/models/common/widgets/withGroup"; -import { selectCurrentPoolOfFields } from "../../../../src/modules/selectedCustomType"; import type { Widget } from "../../../models/common/widgets/Widget"; import EditModal from "../../common/EditModal"; import Zone from "../../common/Zone"; import SliceZone from "../SliceZone"; interface TabZoneProps { - customType: CustomTypeSM; tabId: string; - sliceZone?: SlicesSM | null | undefined; - fields: TabFields; } -const TabZone: FC = ({ - customType, - tabId, - fields, - sliceZone, -}) => { - const { - deleteCustomTypeField, - addCustomTypeField, - reorderCustomTypeField, - replaceCustomTypeField, - createSliceZone, - deleteSliceZone, - deleteCustomTypeSharedSlice, - } = useSliceMachineActions(); +type PoolOfFields = ReadonlyArray<{ key: string; value: TabField }>; - const { query } = useRouter(); - - const { poolOfFields } = useSelector((store: SliceMachineStoreType) => ({ - poolOfFields: selectCurrentPoolOfFields(store), - })); +const TabZone: FC = ({ tabId }) => { + const { customType, setCustomType } = useCustomTypeState(); + const customTypeSM = CustomTypes.toSM(customType); + const sliceZone = customTypeSM.tabs.find((tab) => tab.key === tabId) + ?.sliceZone; + const fields: TabFields = + customTypeSM.tabs.find((tab) => tab.key === tabId)?.value ?? []; - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!poolOfFields) { - return null; - } + const { query } = useRouter(); + const poolOfFields = customTypeSM.tabs.reduce( + (acc: PoolOfFields, curr: TabSM) => { + return [...acc, ...curr.value]; + }, + [], + ); const onDeleteItem = (fieldId: string) => { - deleteCustomTypeField(tabId, fieldId); + const newCustomType = deleteField({ + customType: customTypeSM, + fieldId, + tabId, + }); + setCustomType(CustomTypes.fromSM(newCustomType)); }; const onSaveNewField = ({ @@ -73,34 +73,53 @@ const TabZone: FC = ({ label: string; widgetTypeName: string; }) => { - // @ts-expect-error We have to create a widget map or a service instead of using export name - if (ensureWidgetTypeExistence(Widgets, widgetTypeName)) { - return; + try { + // @ts-expect-error We have to create a widget map or a service instead of using export name + if (ensureWidgetTypeExistence(Widgets, widgetTypeName)) { + return; + } + // @ts-expect-error We have to create a widget map or a service instead of using export name + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const widget: Widget = Widgets[widgetTypeName]; + const field: TabField = widget.create(label); + + const newCustomType = addField({ + customType: customTypeSM, + field, + fieldId: id, + tabId, + }); + setCustomType(CustomTypes.fromSM(newCustomType)); + + void telemetry.track({ + event: "custom-type:field-added", + id, + name: customType.id, + type: widget.TYPE_NAME, + zone: "static", + }); + } catch (err) { + console.error(err); } - // @ts-expect-error We have to create a widget map or a service instead of using export name - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const widget: Widget = Widgets[widgetTypeName]; - void telemetry.track({ - event: "custom-type:field-added", - id, - name: customType.id, - type: widget.TYPE_NAME, - zone: "static", - }); - addCustomTypeField(tabId, id, widget.create(label)); }; const onDragEnd = (result: DropResult) => { if (ensureDnDDestination(result)) { return; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - reorderCustomTypeField( + + const { source, destination } = result; + if (!destination) { + return; + } + + const newCustomType = reorderField({ + customType: customTypeSM, + sourceIndex: source.index, + destinationIndex: destination.index, tabId, - result.source.index, - // @ts-expect-error We have to change the typeGuard above to cast properly the "result" property - result.destination.index, - ); + }); + setCustomType(CustomTypes.fromSM(newCustomType)); }; const onSave = ({ @@ -117,19 +136,34 @@ const TabZone: FC = ({ if (ensureWidgetTypeExistence(Widgets, value.type)) { return; } - replaceCustomTypeField(tabId, previousKey, newKey, value); + + const newCustomType: CustomTypeSM = updateField({ + customType: customTypeSM, + previousKey, + newKey, + value, + tabId, + }); + setCustomType(CustomTypes.fromSM(newCustomType)); }; const onCreateSliceZone = () => { - createSliceZone(tabId); + const newCustomType = createSectionSliceZone(customType, tabId); + setCustomType(newCustomType); }; const onDeleteSliceZone = () => { - deleteSliceZone(tabId); + const newCustomType = deleteSectionSliceZone(customType, tabId); + setCustomType(newCustomType); }; const onRemoveSharedSlice = (sliceId: string) => { - deleteCustomTypeSharedSlice(tabId, sliceId); + const newCustomType = deleteSliceZoneSlice({ + customType, + sectionId: tabId, + sliceId, + }); + setCustomType(newCustomType); }; return ( @@ -169,13 +203,13 @@ const TabZone: FC = ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument `data${transformKeyAccessor(key)}` } - dataCy="ct-static-zone" + dataCy="static-zone" isRepeatableCustomType={customType.repeatable} /> ) : undefined} = (props) => { - const { customType } = props; - - const [tabValue, setTabValue] = useState(customType.tabs[0]?.key); +export const CustomTypeBuilder = () => { + const { customType, setCustomType } = useCustomTypeState(); + const customTypeSM = CustomTypes.toSM(customType); + const [tabValue, setTabValue] = useState(customTypeSM.tabs[0]?.key); const [dialog, setDialog] = useState(); - const { createCustomTypeTab, updateCustomTypeTab, deleteCustomTypeTab } = - useSliceMachineActions(); const { query } = useRouter(); const sliceZoneEmpty = - customType.tabs.find((tab) => tab.key === tabValue)?.sliceZone?.value + customTypeSM.tabs.find((tab) => tab.key === tabValue)?.sliceZone?.value .length === 0; return ( @@ -45,28 +46,24 @@ export const CustomTypeBuilder: FC = (props) => { {customType.format === "page" ? : undefined} {query.newPageType === "true" ? ( - + ) : ( { - setDialog({ type: "CREATE_CUSTOM_TYPE" }); + setDialog({ type: "CREATE_CUSTOM_TYPE_TAB" }); }} > - {customType.tabs.map((tab) => ( + {customTypeSM.tabs.map((tab) => ( { setDialog({ - type: "UPDATE_CUSTOM_TYPE", + type: "UPDATE_CUSTOM_TYPE_TAB", tabKey: tab.key, }); }} @@ -76,10 +73,10 @@ export const CustomTypeBuilder: FC = (props) => { { setDialog({ - type: "DELETE_CUSTOM_TYPE", + type: "DELETE_CUSTOM_TYPE_TAB", tabKey: tab.key, }); }} @@ -95,57 +92,58 @@ export const CustomTypeBuilder: FC = (props) => { ))} - {customType.tabs.map((tab) => ( + {customTypeSM.tabs.map((tab) => ( - + ))} )} - {dialog?.type === "CREATE_CUSTOM_TYPE" ? ( + {dialog?.type === "CREATE_CUSTOM_TYPE_TAB" ? ( { setDialog(undefined); }} isOpen onSubmit={({ id }) => { - createCustomTypeTab(id); + const newCustomType = createSection(customType, id); + setCustomType(newCustomType); setTabValue(id); }} - tabIds={customType.tabs.map((tab) => tab.key.toLowerCase())} + tabIds={customTypeSM.tabs.map((tab) => tab.key.toLowerCase())} /> ) : undefined} - {dialog?.type === "UPDATE_CUSTOM_TYPE" ? ( + {dialog?.type === "UPDATE_CUSTOM_TYPE_TAB" ? ( { setDialog(undefined); }} + initialTabKey={dialog.tabKey} isOpen onSubmit={({ id }) => { - updateCustomTypeTab(dialog.tabKey, id); + const newCustomType = renameSection(customType, dialog.tabKey, id); + setCustomType(newCustomType); + if (tabValue === dialog.tabKey) setTabValue(id); }} - tabIds={customType.tabs + tabIds={customTypeSM.tabs .filter((tab) => tab.key !== dialog.tabKey) .map((tab) => tab.key.toLowerCase())} /> ) : undefined} - {dialog?.type === "DELETE_CUSTOM_TYPE" ? ( + {dialog?.type === "DELETE_CUSTOM_TYPE_TAB" ? ( { setDialog(undefined); }} isOpen onSubmit={() => { - deleteCustomTypeTab(dialog.tabKey); + const newCustomType = deleteSection(customType, dialog.tabKey); + setCustomType(newCustomType); + if (tabValue === dialog.tabKey) { - const otherTabValue = customType.tabs.find( + const otherTabValue = customTypeSM.tabs.find( (tab) => tab.key !== dialog.tabKey, )?.key; if (otherTabValue !== undefined) setTabValue(otherTabValue); diff --git a/packages/slice-machine/lib/builders/SliceBuilder/FieldZones/index.tsx b/packages/slice-machine/lib/builders/SliceBuilder/FieldZones/index.tsx index 1fb204e5bd..70d1176a85 100644 --- a/packages/slice-machine/lib/builders/SliceBuilder/FieldZones/index.tsx +++ b/packages/slice-machine/lib/builders/SliceBuilder/FieldZones/index.tsx @@ -141,7 +141,7 @@ const FieldZones: React.FunctionComponent = ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument `slice.primary${transformKeyAccessor(key)}` } - dataCy="slice-non-repeatable-zone" + dataCy="static-zone" isRepeatableCustomType={undefined} /> = ({ validationSchema={validationSchema} initialValues={initialValues} > - {({ errors, values, setValues, setFieldValue }) => ( + {({ errors, values, setValues, setFieldValue, submitForm }) => (
    = ({ onChange={(e: React.ChangeEvent) => handleLabelChange(e, values, setValues) } + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") { + void submitForm(); + } + }} name="label" placeholder="Field Name" type="text" @@ -206,6 +212,11 @@ const NewField: React.FC = ({ onChange={(e: React.ChangeEvent) => handleIdChange(e, setFieldValue) } + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") { + void submitForm(); + } + }} name="id" placeholder="e.g. buttonLink" type="text" @@ -237,23 +248,13 @@ const NewField: React.FC = ({ - diff --git a/packages/slice-machine/lib/builders/common/Zone/Card/index.jsx b/packages/slice-machine/lib/builders/common/Zone/Card/index.jsx index 5a7dc1ed59..4912b83a51 100644 --- a/packages/slice-machine/lib/builders/common/Zone/Card/index.jsx +++ b/packages/slice-machine/lib/builders/common/Zone/Card/index.jsx @@ -86,6 +86,8 @@ const FieldZone = ({ draggableId: `list-item-${item.key}`, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment isRepeatableCustomType, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access + dataCy: `list-item-${item.key}`, }; // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions diff --git a/packages/slice-machine/lib/builders/common/Zone/index.jsx b/packages/slice-machine/lib/builders/common/Zone/index.jsx index 1b279fe73b..9087087486 100644 --- a/packages/slice-machine/lib/builders/common/Zone/index.jsx +++ b/packages/slice-machine/lib/builders/common/Zone/index.jsx @@ -94,6 +94,7 @@ const Zone = ({ onClick={enterSelectMode} startIcon="add" grey="grey" + disabled={newFieldData !== null} > Add a new field diff --git a/packages/slice-machine/lib/models/common/CustomType/group.ts b/packages/slice-machine/lib/models/common/CustomType/group.ts deleted file mode 100644 index de777e29b3..0000000000 --- a/packages/slice-machine/lib/models/common/CustomType/group.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NestableWidget } from "@prismicio/types-internal/lib/customtypes"; -import { GroupSM } from "../Group"; - -export const Group = { - addWidget( - group: GroupSM, - newField: { key: string; value: NestableWidget }, - ): GroupSM { - return { - ...group, - config: { - ...group.config, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - fields: [...(group.config?.fields || []), newField], - }, - }; - }, - deleteWidget(group: GroupSM, wkey: string): GroupSM { - return { - ...group, - config: { - ...group.config, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - fields: (group.config?.fields || []).filter(({ key }) => key !== wkey), - }, - }; - }, - reorderWidget(group: GroupSM, start: number, end: number): GroupSM { - const reorderedWidget: { key: string; value: NestableWidget } | undefined = - group.config?.fields?.[start]; - if (!reorderedWidget) - throw new Error(`Unable to reorder the widget at index ${start}.`); - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const reorderedArea = (group.config?.fields || []).reduce< - Array<{ key: string; value: NestableWidget }> - >((acc, field, index) => { - const elems = [field, reorderedWidget]; - switch (index) { - case start: - return acc; - case end: - return [...acc, ...(end > start ? elems : elems.reverse())]; - default: - return [...acc, field]; - } - }, []); - - return { - ...group, - config: { - ...group.config, - fields: reorderedArea, - }, - }; - }, - replaceWidget( - group: GroupSM, - previousKey: string, - newKey: string, - value: NestableWidget, - ): GroupSM { - return { - ...group, - config: { - ...group.config, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - fields: (group.config?.fields || []).map((t) => { - if (t.key === previousKey) { - return { - key: newKey, - value, - }; - } - return t; - }), - }, - }; - }, -}; diff --git a/packages/slice-machine/lib/models/common/CustomType/sliceZone.ts b/packages/slice-machine/lib/models/common/CustomType/sliceZone.ts index 1dafcf2464..6f8285db49 100644 --- a/packages/slice-machine/lib/models/common/CustomType/sliceZone.ts +++ b/packages/slice-machine/lib/models/common/CustomType/sliceZone.ts @@ -3,7 +3,6 @@ import { CompositeSlice, LegacySlice, } from "@prismicio/types-internal/lib/customtypes/widgets/slices"; -import { SlicesSM } from "../Slices"; import { ComponentUI } from "../ComponentUI"; export type NonSharedSliceInSliceZone = { @@ -14,31 +13,3 @@ export interface SliceZoneSlice { type: SlicesTypes; payload: ComponentUI | NonSharedSliceInSliceZone; } - -export const SliceZone = { - addSharedSlice(sz: SlicesSM, key: string): SlicesSM { - const value = sz.value.concat([ - { - key, - value: { - type: "SharedSlice", - }, - }, - ]); - return { - ...sz, - value, - }; - }, - removeSharedSlice(sz: SlicesSM, key: string): SlicesSM { - const value = sz.value.filter(({ key: k }) => k !== key); - - return { - ...sz, - value, - }; - }, - createEmpty(key: string): SlicesSM { - return { key, value: [] }; - }, -}; diff --git a/packages/slice-machine/lib/models/common/CustomType/tab.ts b/packages/slice-machine/lib/models/common/CustomType/tab.ts index 80e34a1c1e..1bcff975bf 100644 --- a/packages/slice-machine/lib/models/common/CustomType/tab.ts +++ b/packages/slice-machine/lib/models/common/CustomType/tab.ts @@ -11,8 +11,6 @@ import { Groups, GroupSM } from "../Group"; import { SlicesSM, SliceZone } from "../Slices"; -import { SliceZone as SliceZoneOperations } from "./sliceZone"; - export const TabFields = t.array( t.type({ key: t.string, @@ -89,144 +87,3 @@ export const Tabs = { }; export type TabField = NestableWidget | UID | GroupSM; - -interface OrganisedFields { - fields: ReadonlyArray<{ key: string; value: TabField }>; - groups: ReadonlyArray<{ key: string; value: GroupSM }>; - sliceZone?: DynamicSlices; -} - -export const Tab = { - init(id: string): TabSM { - return { key: id, value: [] }; - }, - // eslint-disable-next-line @typescript-eslint/ban-types - updateSliceZone(tab: TabSM): Function { - return (mutate: (v: SlicesSM) => TabSM) => { - return { - ...tab, - sliceZone: tab.sliceZone && mutate(tab.sliceZone), - }; - }; - }, - updateGroup(tab: TabSM, groupId: string) { - return (mutate: (v: GroupSM) => GroupSM): TabSM => { - return { - ...tab, - value: tab.value.map((field) => { - if (field.key === groupId && field.value.type === "Group") { - return { - key: groupId, - value: mutate(field.value), - }; - } - return field; - }), - }; - }; - }, - addWidget(tab: TabSM, id: string, widget: TabField): TabSM { - const elem = { key: id, value: widget }; - - return { - ...tab, - value: [...tab.value, elem], - }; - }, - replaceWidget( - tab: TabSM, - previousKey: string, - newKey: string, - value: TabField, - ): TabSM { - return { - ...tab, - value: tab.value.map((t) => { - if (t.key === previousKey) { - return { - key: newKey, - value, - }; - } - return t; - }), - }; - }, - reorderWidget(tab: TabSM, start: number, end: number): TabSM { - const reorderedWidget: { key: string; value: TabField } | undefined = - tab.value[start]; - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!reorderedWidget) - throw new Error(`Unable to reorder the widget at index ${start}.`); - - const reorderedArea = tab.value.reduce< - Array<{ key: string; value: TabField }> - >((acc, widget, index: number) => { - const elems = [widget, reorderedWidget]; - switch (index) { - case start: - return acc; - case end: - return [...acc, ...(end > start ? elems : elems.reverse())]; - default: - return [...acc, widget]; - } - }, []); - return { - ...tab, - value: reorderedArea, - }; - }, - removeWidget(tab: TabSM, id: string): TabSM { - const newTab = { - ...tab, - value: tab.value.filter((e) => e.key !== id), - }; - return newTab; - }, - createSliceZone(tab: TabSM, key: string): TabSM { - return { - ...tab, - sliceZone: SliceZoneOperations.createEmpty(key), - }; - }, - deleteSliceZone(tab: TabSM): TabSM { - const { sliceZone: _, ...restTab } = tab; - - return restTab; - }, - - organiseFields(tabSM: TabSM) { - const { fields, groups } = tabSM.value.reduce( - (acc: OrganisedFields, current: { key: string; value: TabField }) => { - switch (current.value.type) { - case "UID": - return acc; - case "Group": - return { - ...acc, - groups: [ - ...acc.groups, - { key: current.key, value: current.value }, - ], - }; - return acc; - default: - return { - ...acc, - fields: [ - ...acc.fields, - current as { key: string; value: TabField }, - ], - }; - } - }, - { fields: [], groups: [] }, - ); - return { - fields, - groups, - sliceZone: tabSM.sliceZone, - }; - }, -}; diff --git a/packages/slice-machine/lib/models/common/widgets/Group/ListItem/index.jsx b/packages/slice-machine/lib/models/common/widgets/Group/ListItem/index.jsx index e77e9e4602..1aa8817af9 100644 --- a/packages/slice-machine/lib/models/common/widgets/Group/ListItem/index.jsx +++ b/packages/slice-machine/lib/models/common/widgets/Group/ListItem/index.jsx @@ -21,7 +21,14 @@ import sliceBuilderArray from "@lib/models/common/widgets/sliceBuilderArray"; import Hint from "@lib/builders/common/Zone/Card/components/Hints"; import ListItem from "@components/ListItem"; -import useSliceMachineActions from "@src/modules/useSliceMachineActions"; +import { useCustomTypeState } from "@src/features/customTypes/customTypesBuilder/CustomTypeProvider"; +import { CustomTypes } from "@lib/models/common/CustomType"; +import { + addGroupField, + deleteGroupField, + reorderGroupField, + updateGroupField, +} from "@src/domain/customType"; /* eslint-disable */ const CustomListItem = ({ @@ -40,12 +47,8 @@ const CustomListItem = ({ const [selectMode, setSelectMode] = useState(false); const [newFieldData, setNewFieldData] = useState(null); const [editModalData, setEditModalData] = useState({ isOpen: false }); - const { - addFieldIntoGroup, - deleteFieldIntoGroup, - replaceFieldIntoGroup, - reorderFieldIntoGroup, - } = useSliceMachineActions(); + const { customType, setCustomType } = useCustomTypeState(); + const customTypeSM = CustomTypes.toSM(customType); const onSelectFieldType = (widgetTypeName) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -68,8 +71,14 @@ const CustomListItem = ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment const newWidget = widget.create(label); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument - addFieldIntoGroup(tabId, groupItem.key, id, newWidget); + const newCustomType = addGroupField({ + customType: customTypeSM, + sectionId: tabId, + groupItem, + newField: newWidget, + newFieldId: id, + }); + setCustomType(CustomTypes.fromSM(newCustomType)); }; const onSaveField = ({ apiId: previousKey, newKey, value }) => { @@ -77,8 +86,16 @@ const CustomListItem = ({ if (ensureWidgetTypeExistence(Widgets, value.type)) { return; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument - replaceFieldIntoGroup(tabId, groupItem.key, previousKey, newKey, value); + + const newCustomType = updateGroupField({ + customType: customTypeSM, + sectionId: tabId, + groupItem, + previousKey, + newKey, + value, + }); + setCustomType(CustomTypes.fromSM(newCustomType)); }; const onDragEnd = (result) => { @@ -87,21 +104,32 @@ const CustomListItem = ({ return; } - reorderFieldIntoGroup( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - tabId, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - groupItem.key, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - result.source.index, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access - result.destination.index, - ); + const { source, destination } = result; + if (!destination) { + return; + } + + const { index: sourceIndex } = source; + const { index: destinationIndex } = destination; + + const newCustomType = reorderGroupField({ + customType: customTypeSM, + sectionId: tabId, + groupItem, + sourceIndex, + destinationIndex, + }); + setCustomType(CustomTypes.fromSM(newCustomType)); }; const onDeleteItem = (key) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - deleteFieldIntoGroup(tabId, groupItem.key, key); + const newCustomType = deleteGroupField({ + customType: customTypeSM, + sectionId: tabId, + groupItem, + fieldKey: key, + }); + setCustomType(CustomTypes.fromSM(newCustomType)); }; const enterEditMode = (field) => { diff --git a/packages/slice-machine/src/components/CodeBlock/CodeBlock.tsx b/packages/slice-machine/src/components/CodeBlock/CodeBlock.tsx index 2ba6bf91ae..e8cf69159f 100644 --- a/packages/slice-machine/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/slice-machine/src/components/CodeBlock/CodeBlock.tsx @@ -82,6 +82,7 @@ const Copy = ({ code, onCopy }: { code: string; onCopy?: () => void }) => { size="small" onClick={copy} icon={isCopied ? "check" : "contentCopy"} + data-cy={isCopied ? "copied-icon-button" : "copy-icon-button"} /> ); }; diff --git a/packages/slice-machine/src/components/Window/Window.tsx b/packages/slice-machine/src/components/Window/Window.tsx index 3633859955..22d1b0495c 100644 --- a/packages/slice-machine/src/components/Window/Window.tsx +++ b/packages/slice-machine/src/components/Window/Window.tsx @@ -54,11 +54,12 @@ export const WindowTabsList: FC = ({ type WindowTabsTriggerProps = Pick< Tabs.TabsTriggerProps, "children" | "value" -> & { menu?: ReactNode }; +> & { menu?: ReactNode; iconButtonTestId?: { [name: string]: string } }; export const WindowTabsTrigger: FC = ({ children, menu, + iconButtonTestId, ...otherProps }) => ( = ({
    - + {menu} diff --git a/packages/slice-machine/src/domain/customType.ts b/packages/slice-machine/src/domain/customType.ts index 76fa767168..814381fa03 100644 --- a/packages/slice-machine/src/domain/customType.ts +++ b/packages/slice-machine/src/domain/customType.ts @@ -2,8 +2,85 @@ import { CustomType, DynamicSection, DynamicSlicesConfig, + GroupFieldType, + NestableWidget, } from "@prismicio/types-internal/lib/customtypes"; +import { CustomTypeFormat } from "@slicemachine/manager/*"; +import { CustomTypeSM, TabField } from "@lib/models/common/CustomType"; +import type { AnyWidget } from "@lib/models/common/widgets/Widget"; +import * as Widgets from "@lib/models/common/widgets/withGroup"; + +type DeleteSliceZoneSliceArgs = { + customType: CustomType; + sectionId: string; + sliceId: string; +}; + +type AddFieldArgs = { + customType: CustomTypeSM; + tabId: string; + field: TabField; + fieldId: string; +}; + +type DeleteFieldArgs = { + customType: CustomTypeSM; + tabId: string; + fieldId: string; +}; + +type UpdateFieldArgs = { + customType: CustomTypeSM; + tabId: string; + previousKey: string; + newKey: string; + value: TabField; +}; + +type ReorderFieldArgs = { + customType: CustomTypeSM; + tabId: string; + sourceIndex: number; + destinationIndex: number; +}; + +type AddGroupField = { + customType: CustomTypeSM; + sectionId: string; + groupItem: { key: string }; + newField: NestableWidget; + newFieldId: string; +}; + +type DeleteGroupField = { + customType: CustomTypeSM; + sectionId: string; + groupItem: { key: string }; + fieldKey: string; +}; + +type UpdateGroupFieldArgs = { + customType: CustomTypeSM; + sectionId: string; + groupItem: { key: string }; + previousKey: string; + newKey: string; + value: NestableWidget; +}; + +type ReorderGroupFieldArgs = { + customType: CustomTypeSM; + sectionId: string; + groupItem: { key: string }; + sourceIndex: number; + destinationIndex: number; +}; + +export function getFormat(custom: CustomType): CustomTypeFormat { + return custom.format ?? "custom"; +} + export function getSectionEntries( customType: CustomType, ): [string, DynamicSection][] { @@ -105,6 +182,83 @@ export function createSectionSliceZone( }; } +export function deleteSectionSliceZone( + customType: CustomType, + sectionId: string, +): CustomType { + const section = getSection(customType, sectionId); + + if (section === undefined) { + return customType; + } + + const sliceZoneKey = Object.keys(section).find( + (key) => section[key].type === "Slices", + ); + + if (sliceZoneKey === undefined) { + return customType; + } + + const newSection = { ...section }; + delete newSection[sliceZoneKey]; + + return { + ...customType, + json: { + ...customType.json, + [sectionId]: newSection, + }, + }; +} + +export function deleteSliceZoneSlice( + args: DeleteSliceZoneSliceArgs, +): CustomType { + const { customType, sectionId, sliceId } = args; + + const section = getSection(customType, sectionId); + + if (section === undefined) { + return customType; + } + + const sliceZoneKey = Object.keys(section).find( + (key) => section[key].type === "Slices", + ); + + if (sliceZoneKey === undefined) { + return customType; + } + + const sliceZone = section[sliceZoneKey]; + + if (sliceZone.type !== "Slices") { + return customType; + } + + const { [sliceId]: _, ...restChoices } = sliceZone.config?.choices ?? {}; + + const newCustomType = { + ...customType, + json: { + ...customType.json, + [sectionId]: { + ...section, + [sliceZoneKey]: { + ...sliceZone, + config: { + ...sliceZone.config, + choices: restChoices, + }, + }, + }, + }, + }; + + return newCustomType; +} + export function convertToPageType(customType: CustomType): CustomType { let newCustomType: CustomType = { ...customType, @@ -120,3 +274,381 @@ export function convertToPageType(customType: CustomType): CustomType { return newCustomType; } + +export function createSection(customType: CustomType, sectionId: string) { + const newCustomType = { + ...customType, + json: { + ...customType.json, + [sectionId]: {}, + }, + }; + + return newCustomType; +} + +export function deleteSection(customType: CustomType, sectionId: string) { + const newCustomType = { + ...customType, + json: { + ...customType.json, + }, + }; + delete newCustomType.json[sectionId]; + + return newCustomType; +} + +export function renameSection( + customType: CustomType, + sectionId: string, + newSectionId: string, +) { + if (sectionId === newSectionId) { + return customType; + } + + const newJson = Object.keys(customType.json).reduce( + (acc: CustomType["json"], key) => { + if (key === sectionId) { + acc[newSectionId] = customType.json[key]; + } else { + acc[key] = customType.json[key]; + } + return acc; + }, + {}, + ); + + const newCustomType = { + ...customType, + json: newJson, + }; + + return newCustomType; +} + +export function addField(args: AddFieldArgs): CustomTypeSM { + const { customType, tabId, field, fieldId } = args; + + try { + if ( + field.type === "Range" || + field.type === "IntegrationFields" || + field.type === "Separator" + ) { + throw new Error("Unsupported Field Type."); + } + + const CurrentWidget: AnyWidget = Widgets[field.type]; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + CurrentWidget.schema.validateSync(field, { stripUnknown: false }); + + const newCustomType: CustomTypeSM = { + ...customType, + tabs: customType.tabs.map((tab) => + tab.key === tabId + ? { + ...tab, + value: [...tab.value, { key: fieldId, value: field }], + } + : tab, + ), + }; + + return newCustomType; + } catch (err) { + console.error("Add field: Model is invalid for field. Full error:", err); + + throw new Error(`Add field: Model is invalid for field "${field.type}".`); + } +} + +export function deleteField(args: DeleteFieldArgs): CustomTypeSM { + const { customType, tabId, fieldId } = args; + const newCustomType: CustomTypeSM = { + ...customType, + tabs: customType.tabs.map((tab) => + tab.key === tabId + ? { + ...tab, + value: tab.value.filter((field) => field.key !== fieldId), + } + : tab, + ), + }; + + return newCustomType; +} + +export function updateField(args: UpdateFieldArgs): CustomTypeSM { + const { customType, tabId, previousKey, newKey, value } = args; + + const newCustomType: CustomTypeSM = { + ...customType, + tabs: customType.tabs.map((tab) => + tab.key === tabId + ? { + ...tab, + value: tab.value.map((field) => + field.key === previousKey + ? { + key: newKey, + value, + } + : field, + ), + } + : tab, + ), + }; + + return newCustomType; +} + +export function reorderField(args: ReorderFieldArgs): CustomTypeSM { + const { customType, tabId, sourceIndex, destinationIndex } = args; + + const newCustomType: CustomTypeSM = { + ...customType, + tabs: customType.tabs.map((tab) => { + if (tab.key === tabId) { + const reorderedArea = tab.value.reduce< + Array<{ key: string; value: TabField }> + >((acc, widget, index: number) => { + if (index === sourceIndex) { + return [ + ...acc.slice(0, destinationIndex), + widget, + ...acc.slice(destinationIndex), + ]; + } else if ( + sourceIndex < destinationIndex && + index > sourceIndex && + index <= destinationIndex + ) { + return [ + ...acc.slice(0, index - 1), + widget, + ...acc.slice(index - 1, index), + ]; + } else if ( + sourceIndex > destinationIndex && + index >= destinationIndex && + index < sourceIndex + ) { + return [ + ...acc.slice(0, index), + widget, + ...acc.slice(index, index + 1), + ]; + } else { + return [...acc, widget]; + } + }, []); + return { + ...tab, + value: reorderedArea, + }; + } else { + return tab; + } + }), + }; + + return newCustomType; +} + +export function addGroupField(args: AddGroupField): CustomTypeSM { + const { customType, sectionId, groupItem, newField, newFieldId } = args; + + const newCustomType: CustomTypeSM = { + ...customType, + tabs: customType.tabs.map((tab) => + tab.key === sectionId + ? { + ...tab, + value: tab.value.map((field) => { + if ( + field.key === groupItem.key && + field.value.type === GroupFieldType + ) { + return { + key: groupItem.key, + value: { + ...field.value, + config: { + ...field.value.config, + fields: [ + ...(field.value.config?.fields ?? []), + { + key: newFieldId, + value: newField, + }, + ], + }, + }, + }; + } + return field; + }), + } + : tab, + ), + }; + + return newCustomType; +} + +export function deleteGroupField(args: DeleteGroupField): CustomTypeSM { + const { customType, sectionId, groupItem, fieldKey } = args; + + const newCustomType: CustomTypeSM = { + ...customType, + tabs: customType.tabs.map((tab) => + tab.key === sectionId + ? { + ...tab, + value: tab.value.map((field) => { + if ( + field.key === groupItem.key && + field.value.type === GroupFieldType + ) { + return { + key: groupItem.key, + value: { + ...field.value, + config: { + ...field.value.config, + fields: (field.value.config?.fields ?? []).filter( + (field) => field.key !== fieldKey, + ), + }, + }, + }; + } + return field; + }), + } + : tab, + ), + }; + + return newCustomType; +} + +export function updateGroupField(args: UpdateGroupFieldArgs): CustomTypeSM { + const { customType, sectionId, groupItem, previousKey, newKey, value } = args; + + const newCustomType: CustomTypeSM = { + ...customType, + tabs: customType.tabs.map((tab) => + tab.key === sectionId + ? { + ...tab, + value: tab.value.map((field) => { + if ( + field.key === groupItem.key && + field.value.type === GroupFieldType + ) { + return { + key: groupItem.key, + value: { + ...field.value, + config: { + ...field.value.config, + fields: (field.value.config?.fields ?? []).map( + (field) => { + if (field.key === previousKey) { + return { + key: newKey, + value, + }; + } + return field; + }, + ), + }, + }, + }; + } + return field; + }), + } + : tab, + ), + }; + + return newCustomType; +} + +export function reorderGroupField(args: ReorderGroupFieldArgs): CustomTypeSM { + const { customType, sectionId, groupItem, sourceIndex, destinationIndex } = + args; + + const newCustomType: CustomTypeSM = { + ...customType, + tabs: customType.tabs.map((tab) => + tab.key === sectionId + ? { + ...tab, + value: tab.value.map((field) => { + if ( + field.key === groupItem.key && + field.value.type === GroupFieldType + ) { + const reorderedArea = (field.value.config?.fields ?? []).reduce< + Array<{ key: string; value: NestableWidget }> + >((acc, widget, index: number) => { + if (index === sourceIndex) { + return [ + ...acc.slice(0, destinationIndex), + widget, + ...acc.slice(destinationIndex), + ]; + } else if ( + sourceIndex < destinationIndex && + index > sourceIndex && + index <= destinationIndex + ) { + return [ + ...acc.slice(0, index - 1), + widget, + ...acc.slice(index - 1, index), + ]; + } else if ( + sourceIndex > destinationIndex && + index >= destinationIndex && + index < sourceIndex + ) { + return [ + ...acc.slice(0, index), + widget, + ...acc.slice(index, index + 1), + ]; + } else { + return [...acc, widget]; + } + }, []); + + return { + key: groupItem.key, + value: { + ...field.value, + config: { + ...field.value.config, + fields: reorderedArea, + }, + }, + }; + } + return field; + }), + } + : tab, + ), + }; + + return newCustomType; +} diff --git a/packages/slice-machine/src/features/autoSave/AutoSaveStatus.tsx b/packages/slice-machine/src/features/autoSave/AutoSaveStatus.tsx new file mode 100644 index 0000000000..59893b650c --- /dev/null +++ b/packages/slice-machine/src/features/autoSave/AutoSaveStatus.tsx @@ -0,0 +1,41 @@ +import { FC } from "react"; +import { Box, Icon, ProgressCircle, Text } from "@prismicio/editor-ui"; + +import { AutoSaveStatusType } from "./useAutoSave"; + +type AutoSaveStatusProps = { + status: AutoSaveStatusType; +}; + +export const AutoSaveStatus: FC = (props) => { + const { status } = props; + let autoSaveStatusInfo; + + switch (status) { + case "saving": + autoSaveStatusInfo = { + icon: , + text: "Saving...", + }; + break; + case "error": + autoSaveStatusInfo = { + icon: , + text: "Failed to save", + }; + break; + case "saved": + autoSaveStatusInfo = { + icon: , + text: "Auto-saved", + }; + break; + } + + return ( + + {autoSaveStatusInfo.icon} + {autoSaveStatusInfo.text} + + ); +}; diff --git a/packages/slice-machine/src/features/autoSave/useAutoSave.tsx b/packages/slice-machine/src/features/autoSave/useAutoSave.tsx new file mode 100644 index 0000000000..c3ccd3b669 --- /dev/null +++ b/packages/slice-machine/src/features/autoSave/useAutoSave.tsx @@ -0,0 +1,112 @@ +import { useCallback, useState, useEffect } from "react"; +import { uniqueId } from "lodash"; +import { toast } from "react-toastify"; +import { Box, Button, Text } from "@prismicio/editor-ui"; + +type UseAutoSaveArgs = { + debounceDelay?: number; + errorMessage?: string; + retryDelay?: number; +}; + +type AutoSaveState = { + pendingSave: (() => Promise) | null; + nextSave: (() => Promise) | null; +}; + +export type AutoSaveStatusType = "saving" | "saved" | "error"; + +export const useAutoSave = (args: UseAutoSaveArgs = {}) => { + const { + debounceDelay = 500, + errorMessage = "An error happened while saving", + retryDelay = 2000, + } = args; + const [autoSaveState, setAutoSaveState] = useState({ + pendingSave: null, + nextSave: null, + }); + const [autoSaveStatusInternal, setAutoSaveStatusInternal] = + useState("saved"); + const [autoSaveStatus, setAutoSaveStatus] = useState( + autoSaveStatusInternal, + ); + + const setNextSave = useCallback((saveAction: () => Promise) => { + setAutoSaveState((prevState) => ({ + ...prevState, + nextSave: saveAction, + })); + }, []); + + const executeSave = useCallback(async () => { + if (autoSaveState.pendingSave) { + setAutoSaveStatusInternal("saving"); + + try { + await autoSaveState.pendingSave(); + + setAutoSaveStatusInternal("saved"); + setAutoSaveState((prevState) => ({ + pendingSave: prevState.nextSave, + nextSave: null, + })); + } catch (error) { + setAutoSaveStatusInternal("error"); + console.error(errorMessage, error); + + const toastId = uniqueId(); + toast.error( + + {errorMessage} + + , + { + autoClose: false, + closeOnClick: false, + draggable: false, + toastId, + }, + ); + } + } + }, [autoSaveState, errorMessage, retryDelay]); + + useEffect(() => { + if ( + autoSaveStatusInternal !== "saving" && + (autoSaveState.pendingSave || autoSaveState.nextSave) + ) { + setAutoSaveState((prevState) => ({ + pendingSave: prevState.nextSave, + nextSave: null, + })); + + executeSave().catch((error) => + console.error("Error in save effect:", error), + ); + } + }, [autoSaveStatusInternal, autoSaveState, executeSave]); + + useEffect(() => { + const debounceTimeout = setTimeout(() => { + setAutoSaveStatus(autoSaveStatusInternal); + }, debounceDelay); + + return () => { + clearTimeout(debounceTimeout); + }; + }, [autoSaveStatusInternal, debounceDelay]); + + return { autoSaveStatus, setNextSave }; +}; diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypeProvider.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypeProvider.tsx new file mode 100644 index 0000000000..5a41b621d6 --- /dev/null +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypeProvider.tsx @@ -0,0 +1,94 @@ +import { + Dispatch, + ReactNode, + SetStateAction, + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { managerClient } from "@src/managerClient"; +import useSliceMachineActions from "@src/modules/useSliceMachineActions"; +import { + AutoSaveStatusType, + useAutoSave, +} from "@src/features/autoSave/useAutoSave"; +import { CustomType } from "@prismicio/types-internal/lib/customtypes"; +import { getFormat } from "@src/domain/customType"; + +import { CUSTOM_TYPES_MESSAGES } from "../customTypesMessages"; + +type CustomTypeContext = { + customType: CustomType; + autoSaveStatus: AutoSaveStatusType; + setCustomType: Dispatch>; +}; + +type CustomTypeStateProviderProps = { + children: ReactNode | ((value: CustomTypeContext) => ReactNode); + defaultCustomType: CustomType; +}; + +const CustomTypeContextValue = createContext({ + autoSaveStatus: "saved", + customType: {} as CustomType, + setCustomType: () => void 0, +}); + +export function CustomTypeStateProvider(props: CustomTypeStateProviderProps) { + const { children, defaultCustomType } = props; + const isMounted = useRef(false); + const [customType, setCustomType] = useState(defaultCustomType); + const format = getFormat(customType); + const customTypeMessages = CUSTOM_TYPES_MESSAGES[format]; + const { autoSaveStatus, setNextSave } = useAutoSave({ + errorMessage: customTypeMessages.autoSaveFailed, + }); + const { saveCustomTypeSuccess } = useSliceMachineActions(); + + useEffect( + () => { + if (isMounted.current) { + setNextSave(async () => { + const { errors } = await managerClient.customTypes.updateCustomType({ + model: customType, + }); + + if (errors.length > 0) { + throw errors; + } + + // Update available custom type store with new custom type + saveCustomTypeSuccess(customType); + }); + } else { + isMounted.current = true; + } + }, + // Prevent saveCustomTypeSuccess to trigger infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + [customType, setNextSave], + ); + + const contextValue: CustomTypeContext = useMemo( + () => ({ + autoSaveStatus, + customType, + setCustomType, + }), + [autoSaveStatus, customType, setCustomType], + ); + + return ( + + {typeof children === "function" ? children(contextValue) : children} + + ); +} + +export function useCustomTypeState() { + return useContext(CustomTypeContextValue); +} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypesBuilderPage.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypesBuilderPage.tsx index 59deaa72c4..8a0603b20a 100644 --- a/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypesBuilderPage.tsx +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypesBuilderPage.tsx @@ -1,4 +1,4 @@ -import { Button, Box } from "@prismicio/editor-ui"; +import { Box } from "@prismicio/editor-ui"; import Head from "next/head"; import { useRouter } from "next/router"; import { type FC, useEffect } from "react"; @@ -13,23 +13,19 @@ import { AppLayoutHeader, } from "@components/AppLayout"; import { CustomTypeBuilder } from "@lib/builders/CustomTypeBuilder"; -import { type CustomTypeSM, CustomTypes } from "@lib/models/common/CustomType"; -import { hasLocal, hasRemote } from "@lib/models/common/ModelData"; +import { CustomTypeSM, CustomTypes } from "@lib/models/common/CustomType"; +import { hasLocal } from "@lib/models/common/ModelData"; import { readBuilderPageDynamicSegment } from "@src/features/customTypes/customTypesConfig"; import { selectCustomTypeById } from "@src/modules/availableCustomTypes"; -import { LoadingKeysEnum } from "@src/modules/loading/types"; -import { isLoading } from "@src/modules/loading"; -import { - isSelectedCustomTypeTouched, - selectCurrentCustomType, -} from "@src/modules/selectedCustomType"; -import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import type { SliceMachineStoreType } from "@src/redux/type"; +import { getFormat } from "@src/domain/customType"; +import { AutoSaveStatus } from "@src/features/autoSave/AutoSaveStatus"; import { CUSTOM_TYPES_CONFIG } from "../customTypesConfig"; import { CUSTOM_TYPES_MESSAGES } from "../customTypesMessages"; import { EditDropdown } from "../EditDropdown"; import { PageSnippetDialog } from "./PageSnippetDialog"; +import { CustomTypeStateProvider } from "./CustomTypeProvider"; export const CustomTypesBuilderPage: FC = () => { const router = useRouter(); @@ -42,8 +38,6 @@ export const CustomTypesBuilderPage: FC = () => { }), ); - const { cleanupCustomTypeStore } = useSliceMachineActions(); - useEffect(() => { // TODO(DT-1801): When creating a custom type, don't redirect to the builder page until the custom type is created if (!selectedCustomType || !hasLocal(selectedCustomType)) { @@ -51,14 +45,6 @@ export const CustomTypesBuilderPage: FC = () => { } }, [selectedCustomType, router]); - useEffect(() => { - return () => { - cleanupCustomTypeStore(); - }; - // we don't want to re-run this effect when the cleanupCustomTypeStore is redefined - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - if (!selectedCustomType || !hasLocal(selectedCustomType)) { return ; } @@ -70,9 +56,6 @@ export const CustomTypesBuilderPage: FC = () => { ); @@ -80,70 +63,53 @@ export const CustomTypesBuilderPage: FC = () => { type CustomTypesBuilderPageWithProviderProps = { customType: CustomTypeSM; - remoteCustomType: CustomTypeSM | undefined; }; const CustomTypesBuilderPageWithProvider: React.FC< CustomTypesBuilderPageWithProviderProps -> = ({ customType, remoteCustomType }) => { - const { initCustomTypeStore, saveCustomType } = useSliceMachineActions(); - - useEffect( - () => { - initCustomTypeStore(customType, remoteCustomType); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - /* leave this empty to prevent local updates to disappear */ - ], - ); - - const { currentCustomType, hasPendingModifications, isSavingCustomType } = - useSelector((store: SliceMachineStoreType) => ({ - currentCustomType: selectCurrentCustomType(store), - hasPendingModifications: isSelectedCustomTypeTouched(store), - isSavingCustomType: isLoading(store, LoadingKeysEnum.SAVE_CUSTOM_TYPE), - })); - - if (currentCustomType === null) { +> = ({ customType: customTypeFromStore }) => { + if (customTypeFromStore === null) { return ; } - - const config = CUSTOM_TYPES_CONFIG[currentCustomType.format]; - const messages = CUSTOM_TYPES_MESSAGES[currentCustomType.format]; - return ( - - - - - {currentCustomType.format === "page" ? ( - - ) : undefined} - - - - - - - - - + + {({ autoSaveStatus, customType }) => { + const format = getFormat(customType); + const config = CUSTOM_TYPES_CONFIG[customTypeFromStore.format]; + const messages = CUSTOM_TYPES_MESSAGES[customTypeFromStore.format]; + + return ( + <> + + + + + + {customType.format === "page" ? ( + + ) : undefined} + + + + + + + + + + ); + }} + ); }; diff --git a/packages/slice-machine/src/features/customTypes/customTypesMessages.ts b/packages/slice-machine/src/features/customTypes/customTypesMessages.ts index 27e49e615e..58862bafca 100644 --- a/packages/slice-machine/src/features/customTypes/customTypesMessages.ts +++ b/packages/slice-machine/src/features/customTypes/customTypesMessages.ts @@ -12,6 +12,7 @@ export const CUSTOM_TYPES_MESSAGES = { inputPlaceholder: `ID to query the page type in the API (e.g. 'BlogPost')`, blankSlateDescription: "Page types are models that your editors will use to create website pages in the Page Builder.", + autoSaveFailed: "Failed to save page type, check console logs.", }, custom: { name: ({ start, plural }: CustomTypesMessagesNameArgs) => @@ -21,5 +22,6 @@ export const CUSTOM_TYPES_MESSAGES = { inputPlaceholder: `ID to query the custom type in the API (e.g. 'Author')`, blankSlateDescription: "Custom types are models that your editors can use to create menus or objects in the Page Builder.", + autoSaveFailed: "Failed to save custom type, check console logs.", }, }; diff --git a/packages/slice-machine/src/features/slices/actions/addSlicesToSliceZone.ts b/packages/slice-machine/src/features/slices/actions/addSlicesToSliceZone.ts index 5a4fd9e449..f800a81edd 100644 --- a/packages/slice-machine/src/features/slices/actions/addSlicesToSliceZone.ts +++ b/packages/slice-machine/src/features/slices/actions/addSlicesToSliceZone.ts @@ -1,8 +1,7 @@ import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; import { telemetry } from "@src/apiClient"; -import { managerClient } from "@src/managerClient"; -import { CustomTypeSM, CustomTypes } from "@lib/models/common/CustomType"; +import { CustomTypeSM } from "@lib/models/common/CustomType"; export type AddSlicesToSliceZoneArgs = { customType: CustomTypeSM; @@ -10,7 +9,7 @@ export type AddSlicesToSliceZoneArgs = { slices: SharedSlice[]; }; -export async function addSlicesToSliceZone({ +export function addSlicesToSliceZone({ customType, tabId, slices, @@ -40,10 +39,6 @@ export async function addSlicesToSliceZone({ }; }); - await managerClient.customTypes.updateCustomType({ - model: CustomTypes.fromSM(newCustomType), - }); - void telemetry.track({ event: "custom-type:slice-zone-updated", customTypeId: customType.id, diff --git a/packages/slice-machine/src/features/slices/convertLegacySlice/ConvertLegacySliceButton.tsx b/packages/slice-machine/src/features/slices/convertLegacySlice/ConvertLegacySliceButton.tsx index eb14c39ed1..f4e8e37bf3 100644 --- a/packages/slice-machine/src/features/slices/convertLegacySlice/ConvertLegacySliceButton.tsx +++ b/packages/slice-machine/src/features/slices/convertLegacySlice/ConvertLegacySliceButton.tsx @@ -11,7 +11,6 @@ import { import { NonSharedSliceInSliceZone } from "@models/common/CustomType/sliceZone"; import { ComponentUI } from "@models/common/ComponentUI"; -import { CustomTypes } from "@models/common/CustomType"; import { getLibraries } from "@src/modules/slices"; import { SliceMachineStoreType } from "@src/redux/type"; import { managerClient } from "@src/managerClient"; @@ -29,6 +28,7 @@ import { IdenticalSlice, LegacySliceConversionType, } from "./types"; +import { useCustomTypeState } from "@src/features/customTypes/customTypesBuilder/CustomTypeProvider"; type ConvertLegacySliceButtonProps = NonSharedSliceCardProps; @@ -36,15 +36,11 @@ export const ConvertLegacySliceButton: FC = ({ slice, path, }) => { - const { - refreshState, - openToaster, - initCustomTypeStore, - saveCustomTypeSuccess, - } = useSliceMachineActions(); + const { refreshState, openToaster } = useSliceMachineActions(); const [isLoading, setIsLoading] = useState(false); const [dialog, setDialog] = useState(); + const { setCustomType } = useCustomTypeState(); const { libraries: allLibraries } = useSelector( (store: SliceMachineStoreType) => ({ @@ -159,9 +155,7 @@ export const ConvertLegacySliceButton: FC = ({ break; } - const customTypeSM = CustomTypes.toSM(customType); - initCustomTypeStore(customTypeSM, customTypeSM); - saveCustomTypeSuccess(customType); + setCustomType(customType); }; const formProps = { diff --git a/packages/slice-machine/src/features/slices/sliceCards/SharedSliceCard.tsx b/packages/slice-machine/src/features/slices/sliceCards/SharedSliceCard.tsx index 08e8ef516e..64ca6d3c71 100644 --- a/packages/slice-machine/src/features/slices/sliceCards/SharedSliceCard.tsx +++ b/packages/slice-machine/src/features/slices/sliceCards/SharedSliceCard.tsx @@ -206,6 +206,7 @@ export const SharedSliceCard: FC = (props) => { !disabled && action.onRemove()} + data-cy="remove-slice-button" /> ) : action.type === "status" ? ( { - const { refreshState, initSliceStore, initCustomTypeStore } = - useSliceMachineActions(); + const { refreshState, initSliceStore } = useSliceMachineActions(); // eslint-disable-next-line react-hooks/exhaustive-deps const handleRefreshState = useCallback(refreshState, []); const { data: serverState } = useSwr("getState", async () => { @@ -21,25 +18,16 @@ const useServerState = () => { }); // Whether or not current slice or custom type builder is touched, and its related server state. - const { - selectedSlice, - sliceIsTouched, - selectedCustomType, - customTypeIsTouched, - } = useSelector((store: SliceMachineStoreType) => ({ - selectedSlice: store.selectedSlice, - sliceIsTouched: isSelectedSliceTouched( - store, - store.selectedSlice?.from ?? "", - store.selectedSlice?.model.id ?? "", - ), - selectedCustomType: Boolean(store.selectedCustomType?.initialModel.id) - ? store.availableCustomTypes[ - store.selectedCustomType?.initialModel.id ?? "" - ] - : null, - customTypeIsTouched: isSelectedCustomTypeTouched(store), - })); + const { selectedSlice, sliceIsTouched } = useSelector( + (store: SliceMachineStoreType) => ({ + selectedSlice: store.selectedSlice, + sliceIsTouched: isSelectedSliceTouched( + store, + store.selectedSlice?.from ?? "", + store.selectedSlice?.model.id ?? "", + ), + }), + ); useEffect(() => { let canceled = false; @@ -55,25 +43,6 @@ const useServerState = () => { } } - // If custom type builder is untouched, update from server state. - if ( - selectedCustomType && - hasLocal(selectedCustomType) && - !customTypeIsTouched - ) { - const serverCustomType = serverState.customTypes.find( - (customType) => customType.id === selectedCustomType.local.id, - ); - - if (serverCustomType) { - const remoteCustomType = serverState.remoteCustomTypes.find( - (customType) => customType.id === selectedCustomType.local.id, - ); - - initCustomTypeStore(serverCustomType, remoteCustomType); - } - } - handleRefreshState(serverState); Sentry.setUser({ id: serverState.env.shortId }); diff --git a/packages/slice-machine/src/modules/availableCustomTypes/index.ts b/packages/slice-machine/src/modules/availableCustomTypes/index.ts index 88c0373fb8..0a2348f862 100644 --- a/packages/slice-machine/src/modules/availableCustomTypes/index.ts +++ b/packages/slice-machine/src/modules/availableCustomTypes/index.ts @@ -21,7 +21,6 @@ import { normalizeFrontendCustomType, normalizeFrontendCustomTypes, } from "@lib/models/common/normalizers/customType"; -import { saveCustomTypeCreator } from "../selectedCustomType/actions"; import { omit } from "lodash"; import { deleteSliceCreator } from "../slices"; import { filterSliceFromCustomType } from "@lib/utils/shared/customTypes"; @@ -37,6 +36,12 @@ import { CUSTOM_TYPES_MESSAGES } from "@src/features/customTypes/customTypesMess import { CustomTypes } from "@lib/models/common/CustomType"; import { ToastMessageWithPath } from "@components/ToasterContainer"; +export const saveCustomTypeCreator = createAsyncAction( + "CUSTOM_TYPE/SAVE.REQUEST", + "CUSTOM_TYPE/SAVE.RESPONSE", + "CUSTOM_TYPE/SAVE.FAILURE", +)(); + // Action Creators export const createCustomTypeCreator = createAsyncAction( "CUSTOM_TYPES/CREATE.REQUEST", diff --git a/packages/slice-machine/src/modules/selectedCustomType/actions.ts b/packages/slice-machine/src/modules/selectedCustomType/actions.ts deleted file mode 100644 index 7eee87d606..0000000000 --- a/packages/slice-machine/src/modules/selectedCustomType/actions.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { ActionType, createAction, createAsyncAction } from "typesafe-actions"; -import { NestableWidget } from "@prismicio/types-internal/lib/customtypes"; -import { CustomTypeSM, TabField } from "@lib/models/common/CustomType"; - -export type SelectedCustomTypeActions = - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType; - -export const initCustomTypeStoreCreator = createAction("CUSTOM_TYPE/INIT")<{ - model: CustomTypeSM; - remoteModel: CustomTypeSM | undefined; -}>(); - -export const cleanupCustomTypeStoreCreator = createAction( - "CUSTOM_TYPE/CLEANUP", -)(); - -// Async actions -export const saveCustomTypeCreator = createAsyncAction( - "CUSTOM_TYPE/SAVE.REQUEST", - "CUSTOM_TYPE/SAVE.RESPONSE", - "CUSTOM_TYPE/SAVE.FAILURE", -)(); - -// Tab actions -export const createTabCreator = createAction("CUSTOM_TYPE/CREATE_TAB")<{ - tabId: string; -}>(); - -export const updateTabCreator = createAction("CUSTOM_TYPE/UPDATE_TAB")<{ - tabId: string; - newTabId: string; -}>(); - -export const deleteTabCreator = createAction("CUSTOM_TYPE/DELETE_TAB")<{ - tabId: string; -}>(); - -// Field actions -export const addFieldCreator = createAction("CUSTOM_TYPE/ADD_FIELD")<{ - tabId: string; - fieldId: string; - field: TabField; -}>(); - -export const deleteFieldCreator = createAction("CUSTOM_TYPE/DELETE_FIELD")<{ - tabId: string; - fieldId: string; -}>(); - -export const replaceFieldCreator = createAction("CUSTOM_TYPE/REPLACE_FIELD")<{ - tabId: string; - previousFieldId: string; - newFieldId: string; - value: TabField; -}>(); - -export const reorderFieldCreator = createAction("CUSTOM_TYPE/REORDER_FIELD")<{ - tabId: string; - start: number; - end: number; -}>(); - -// Slice zone actions -export const createSliceZoneCreator = createAction( - "CUSTOM_TYPE/CREATE_SLICE_ZONE", -)<{ - tabId: string; -}>(); - -export const deleteSliceZoneCreator = createAction( - "CUSTOM_TYPE/DELETE_SLICE_ZONE", -)<{ - tabId: string; -}>(); - -export type ReplaceSharedSliceCreatorPayload = { - tabId: string; - sliceKeys: string[]; - preserve: string[]; -}; - -export const deleteSharedSliceCreator = createAction( - "CUSTOM_TYPE/DELETE_SHARED_SLICE", -)<{ - tabId: string; - sliceId: string; -}>(); - -export const renameSelectedCustomTypeLabel = createAction( - "CUSTOM_TYPE/RENAME_CUSTOM_TYPE", -)<{ - newLabel: string; -}>(); - -// Group actions (can be grouped into the field actions probably) -export const addFieldIntoGroupCreator = createAction( - "CUSTOM_TYPE/GROUP/ADD_FIELD", -)<{ - tabId: string; - groupId: string; - fieldId: string; - field: NestableWidget; -}>(); - -export const replaceFieldIntoGroupCreator = createAction( - "CUSTOM_TYPE/GROUP/REPLACE_FIELD", -)<{ - tabId: string; - groupId: string; - previousFieldId: string; - newFieldId: string; - value: NestableWidget; -}>(); - -export const reorderFieldIntoGroupCreator = createAction( - "CUSTOM_TYPE/GROUP/REORDER_FIELD", -)<{ - tabId: string; - groupId: string; - start: number; - end: number; -}>(); - -export const deleteFieldIntoGroupCreator = createAction( - "CUSTOM_TYPE/GROUP/DELETE_FIELD", -)<{ - tabId: string; - groupId: string; - fieldId: string; -}>(); diff --git a/packages/slice-machine/src/modules/selectedCustomType/index.ts b/packages/slice-machine/src/modules/selectedCustomType/index.ts deleted file mode 100644 index fcfe6c9c0d..0000000000 --- a/packages/slice-machine/src/modules/selectedCustomType/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Actions -export * from "./actions"; - -// Selectors -export * from "./selectors"; - -// Reducer -export { selectedCustomTypeReducer } from "./reducer"; - -// Saga Exports -export { watchSelectedCustomTypeSagas } from "./sagas"; diff --git a/packages/slice-machine/src/modules/selectedCustomType/reducer.ts b/packages/slice-machine/src/modules/selectedCustomType/reducer.ts deleted file mode 100644 index d0be3a5dfa..0000000000 --- a/packages/slice-machine/src/modules/selectedCustomType/reducer.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { Reducer } from "redux"; -import { SelectedCustomTypeStoreType } from "./types"; -import { getType } from "typesafe-actions"; -import { - createTabCreator, - updateTabCreator, - SelectedCustomTypeActions, - initCustomTypeStoreCreator, - cleanupCustomTypeStoreCreator, - addFieldCreator, - deleteTabCreator, - renameSelectedCustomTypeLabel, - createSliceZoneCreator, - deleteSliceZoneCreator, - deleteFieldCreator, - deleteSharedSliceCreator, - reorderFieldCreator, - replaceFieldCreator, - addFieldIntoGroupCreator, - deleteFieldIntoGroupCreator, - reorderFieldIntoGroupCreator, - replaceFieldIntoGroupCreator, - saveCustomTypeCreator, -} from "./actions"; -import { Tab } from "@models/common/CustomType/tab"; -import { SliceZone } from "@models/common/CustomType/sliceZone"; -import { AnyWidget } from "@models/common/widgets/Widget"; -import * as Widgets from "@models/common/widgets/withGroup"; -import StateHelpers from "./stateHelpers"; -import { CustomType } from "@models/common/CustomType"; -import { SlicesSM } from "@lib/models/common/Slices"; -import { GroupSM } from "@lib/models/common/Group"; -import { Group } from "@lib/models/common/CustomType/group"; - -// Reducer -export const selectedCustomTypeReducer: Reducer< - SelectedCustomTypeStoreType, - SelectedCustomTypeActions -> = (state = null, action) => { - switch (action.type) { - case getType(cleanupCustomTypeStoreCreator): - return null; - case getType(initCustomTypeStoreCreator): - return { - ...state, - model: action.payload.model, - initialModel: action.payload.model, - remoteModel: action.payload.remoteModel, - }; - case getType(saveCustomTypeCreator.success): { - if (!state) return state; - - return { - ...state, - initialModel: state.model, - }; - } - case getType(createTabCreator): - if (!state) return state; - const { tabId } = action.payload; - if (state.model.tabs.find((e) => e.key === tabId)) { - return state; - } - return { - ...state, - model: { - ...state.model, - tabs: [...state.model.tabs, Tab.init(tabId)], - }, - }; - case getType(updateTabCreator): { - if (!state) return state; - const { tabId, newTabId } = action.payload; - if (newTabId === tabId) { - return state; - } - return { - ...state, - model: { - ...state.model, - tabs: state.model.tabs.map((t) => { - if (t.key === tabId) { - return { - ...t, - key: newTabId, - }; - } - return t; - }), - }, - }; - } - case getType(renameSelectedCustomTypeLabel): { - if (!state) return state; - return { - ...state, - model: { - ...state.model, - label: action.payload.newLabel, - }, - }; - } - case getType(deleteTabCreator): { - if (!state) return state; - const { tabId } = action.payload; - return StateHelpers.deleteTab(state, tabId); - } - case getType(addFieldCreator): { - const { tabId, field, fieldId } = action.payload; - try { - if ( - field.type === "Range" || - field.type === "IntegrationFields" || - field.type === "Separator" - ) { - throw new Error("Unsupported Field Type."); - } - const CurrentWidget: AnyWidget = Widgets[field.type]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - CurrentWidget.schema.validateSync(field, { stripUnknown: false }); - return StateHelpers.updateTab( - state, - tabId, - )((tab) => Tab.addWidget(tab, fieldId, field)); - } catch (err) { - console.error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `[store/addWidget] Model is invalid for widget "${field.type}".\nFull error: ${err}`, - ); - return state; - } - } - case getType(deleteFieldCreator): { - const { tabId, fieldId } = action.payload; - return StateHelpers.updateTab( - state, - tabId, - )((tab) => Tab.removeWidget(tab, fieldId)); - } - case getType(replaceFieldCreator): { - const { tabId, previousFieldId, newFieldId, value } = action.payload; - try { - if ( - value.type === "Range" || - value.type === "IntegrationFields" || - value.type === "Separator" - ) { - throw new Error("Unsupported Field Type."); - } - const CurrentWidget: AnyWidget = Widgets[value.type]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - CurrentWidget.schema.validateSync(value, { stripUnknown: false }); - return StateHelpers.updateTab( - state, - tabId, - )((tab) => Tab.replaceWidget(tab, previousFieldId, newFieldId, value)); - } catch (err) { - return state; - } - } - case getType(reorderFieldCreator): { - const { tabId, start, end } = action.payload; - return StateHelpers.updateTab( - state, - tabId, - )((tab) => Tab.reorderWidget(tab, start, end)); - } - case getType(createSliceZoneCreator): { - if (!state) return state; - const { tabId } = action.payload; - const tabIndex = state.model.tabs.findIndex((t) => t.key === tabId); - - if (tabIndex === -1) { - console.error(`No tabId ${tabId} found in tabs`); - return state; - } - - const existingSliceZones = CustomType.getSliceZones(state.model).filter( - (e) => e, - ); - return StateHelpers.updateTab( - state, - tabId, - )((tab) => { - const i = findAvailableKey(tabIndex, existingSliceZones); - return Tab.createSliceZone(tab, `slices${i !== 0 ? i.toString() : ""}`); - }); - } - case getType(deleteSliceZoneCreator): { - if (!state) return state; - const { tabId } = action.payload; - const tabIndex = state.model.tabs.findIndex((t) => t.key === tabId); - - if (tabIndex === -1) { - console.error(`No tabId ${tabId} found in tabs`); - return state; - } - - return StateHelpers.updateTab( - state, - tabId, - )((tab) => { - return Tab.deleteSliceZone(tab); - }); - } - case getType(deleteSharedSliceCreator): { - const { tabId, sliceId } = action.payload; - return StateHelpers.updateTab( - state, - tabId, - )((tab) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - Tab.updateSliceZone(tab)((sliceZone: SlicesSM) => - SliceZone.removeSharedSlice(sliceZone, sliceId), - ), - ); - } - case getType(addFieldIntoGroupCreator): { - const { tabId, groupId, fieldId, field } = action.payload; - return StateHelpers.updateTab( - state, - tabId, - )((tab) => - Tab.updateGroup( - tab, - groupId, - )((group: GroupSM) => - Group.addWidget(group, { key: fieldId, value: field }), - ), - ); - } - case getType(replaceFieldIntoGroupCreator): { - const { tabId, groupId, previousFieldId, newFieldId, value } = - action.payload; - return StateHelpers.updateTab( - state, - tabId, - )((tab) => - Tab.updateGroup( - tab, - groupId, - )((group: GroupSM) => - Group.replaceWidget(group, previousFieldId, newFieldId, value), - ), - ); - } - case getType(deleteFieldIntoGroupCreator): { - const { tabId, groupId, fieldId } = action.payload; - return StateHelpers.updateTab( - state, - tabId, - )((tab) => - Tab.updateGroup( - tab, - groupId, - )((group: GroupSM) => Group.deleteWidget(group, fieldId)), - ); - } - case getType(reorderFieldIntoGroupCreator): { - const { tabId, groupId, start, end } = action.payload; - return StateHelpers.updateTab( - state, - tabId, - )((tab) => - Tab.updateGroup( - tab, - groupId, - )((group: GroupSM) => Group.reorderWidget(group, start, end)), - ); - } - default: - return state; - } -}; - -const findAvailableKey = ( - startI: number, - existingSliceZones: (SlicesSM | null)[], -) => { - for (let i = startI; i < Infinity; i++) { - const key = `slices${i.toString()}`; - if (!existingSliceZones.find((e) => e?.key === key)) { - return i; - } - } - return -1; -}; diff --git a/packages/slice-machine/src/modules/selectedCustomType/sagas.ts b/packages/slice-machine/src/modules/selectedCustomType/sagas.ts deleted file mode 100644 index 74a89138df..0000000000 --- a/packages/slice-machine/src/modules/selectedCustomType/sagas.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { call, fork, put, select, takeLatest } from "redux-saga/effects"; -import { openToasterCreator, ToasterType } from "../toaster"; -import { getType } from "typesafe-actions"; -import { withLoader } from "../loading"; -import { LoadingKeysEnum } from "../loading/types"; -import { saveCustomTypeCreator } from "./actions"; -import { selectCurrentCustomType } from "./index"; -import { saveCustomType, telemetry } from "@src/apiClient"; -import { ToastMessageWithPath } from "@components/ToasterContainer"; -import { CUSTOM_TYPES_MESSAGES } from "@src/features/customTypes/customTypesMessages"; - -export function* saveCustomTypeSaga() { - try { - const currentCustomType = (yield select( - selectCurrentCustomType, - )) as ReturnType; - - if (!currentCustomType) { - return; - } - - const { errors } = (yield call( - saveCustomType, - currentCustomType, - )) as Awaited>; - if (errors.length) { - throw errors; - } - void telemetry.track({ - event: "custom-type:saved", - id: currentCustomType.id, - name: currentCustomType.label ?? currentCustomType.id, - format: currentCustomType.format, - type: currentCustomType.repeatable ? "repeatable" : "single", - }); - yield put(saveCustomTypeCreator.success({ customType: currentCustomType })); - const formatName = CUSTOM_TYPES_MESSAGES[currentCustomType.format].name({ - start: true, - plural: false, - }); - yield put( - openToasterCreator({ - content: ToastMessageWithPath({ - message: `${formatName} saved successfully at `, - path: `./customtypes/${currentCustomType.id}/index.json`, - }), - type: ToasterType.SUCCESS, - }), - ); - } catch (e) { - // Unknown errors - yield put( - openToasterCreator({ - content: "Internal Error: Custom type not saved", - type: ToasterType.ERROR, - }), - ); - } -} - -// Saga watchers -function* watchSaveCustomType() { - yield takeLatest( - getType(saveCustomTypeCreator.request), - withLoader(saveCustomTypeSaga, LoadingKeysEnum.SAVE_CUSTOM_TYPE), - ); -} - -// Saga Exports -export function* watchSelectedCustomTypeSagas() { - yield fork(watchSaveCustomType); -} diff --git a/packages/slice-machine/src/modules/selectedCustomType/selectors.ts b/packages/slice-machine/src/modules/selectedCustomType/selectors.ts deleted file mode 100644 index 41cd7158da..0000000000 --- a/packages/slice-machine/src/modules/selectedCustomType/selectors.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SliceMachineStoreType } from "@src/redux/type"; -import equal from "fast-deep-equal"; -import { PoolOfFields } from "@src/modules/selectedCustomType/types"; -import { CustomTypeSM, TabSM } from "@lib/models/common/CustomType"; - -// Selectors -export const selectCurrentCustomType = ( - store: SliceMachineStoreType, -): CustomTypeSM | null => { - if (!store.selectedCustomType) return null; - return store.selectedCustomType.model; -}; - -export const selectCurrentPoolOfFields = ( - store: SliceMachineStoreType, -): PoolOfFields => { - if (!store.selectedCustomType) return []; - return store.selectedCustomType.model.tabs.reduce( - (acc: PoolOfFields, curr: TabSM) => { - return [...acc, ...curr.value]; - }, - [], - ); -}; - -export const isSelectedCustomTypeTouched = (store: SliceMachineStoreType) => { - if (!store.selectedCustomType) return false; - - return !equal( - store.selectedCustomType.initialModel, - store.selectedCustomType.model, - ); -}; diff --git a/packages/slice-machine/src/modules/selectedCustomType/stateHelpers.ts b/packages/slice-machine/src/modules/selectedCustomType/stateHelpers.ts deleted file mode 100644 index 019bdcac7c..0000000000 --- a/packages/slice-machine/src/modules/selectedCustomType/stateHelpers.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TabSM } from "@lib/models/common/CustomType"; -import { SelectedCustomTypeStoreType } from "@src/modules/selectedCustomType/types"; - -const updateTab = - (state: SelectedCustomTypeStoreType, tabId: string) => - (mutate: (v: TabSM) => TabSM): SelectedCustomTypeStoreType => { - if (!state) return state; - - const tabs = state.model.tabs.map((tab) => { - if (tab.key === tabId) return mutate(tab); - else return tab; - }); - - return { - ...state, - model: { - ...state.model, - tabs, - }, - }; - }; - -const deleteTab = ( - state: SelectedCustomTypeStoreType, - tabId: string, -): SelectedCustomTypeStoreType => { - if (!state) return state; - const tabs = state.model.tabs.filter((v) => v.key !== tabId); - - return { - ...state, - model: { - ...state.model, - tabs, - }, - }; -}; - -export default { - deleteTab, - updateTab, -}; diff --git a/packages/slice-machine/src/modules/selectedCustomType/types.ts b/packages/slice-machine/src/modules/selectedCustomType/types.ts deleted file mode 100644 index 69bc8a6c2b..0000000000 --- a/packages/slice-machine/src/modules/selectedCustomType/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { CustomTypeSM, TabField } from "@lib/models/common/CustomType"; - -export type PoolOfFields = ReadonlyArray<{ key: string; value: TabField }>; - -export type SelectedCustomTypeStoreType = { - model: CustomTypeSM; - initialModel: CustomTypeSM; - remoteModel: CustomTypeSM | undefined; -} | null; diff --git a/packages/slice-machine/src/modules/useSliceMachineActions.ts b/packages/slice-machine/src/modules/useSliceMachineActions.ts index a05706d2b7..31e752f655 100644 --- a/packages/slice-machine/src/modules/useSliceMachineActions.ts +++ b/packages/slice-machine/src/modules/useSliceMachineActions.ts @@ -21,36 +21,13 @@ import { createCustomTypeCreator, deleteCustomTypeCreator, renameAvailableCustomType, + saveCustomTypeCreator, } from "./availableCustomTypes"; import { createSlice, deleteSliceCreator, renameSliceCreator } from "./slices"; import { UserContextStoreType, UserReviewType } from "./userContext/types"; import { GenericToastTypes, openToasterCreator } from "./toaster"; -import { - initCustomTypeStoreCreator, - createTabCreator, - deleteTabCreator, - updateTabCreator, - addFieldCreator, - deleteFieldCreator, - reorderFieldCreator, - replaceFieldCreator, - deleteSharedSliceCreator, - createSliceZoneCreator, - deleteSliceZoneCreator, - saveCustomTypeCreator, - addFieldIntoGroupCreator, - deleteFieldIntoGroupCreator, - reorderFieldIntoGroupCreator, - replaceFieldIntoGroupCreator, - cleanupCustomTypeStoreCreator, - renameSelectedCustomTypeLabel, -} from "./selectedCustomType"; import type { SliceBuilderState } from "@builders/SliceBuilder"; -import { - CustomTypeSM, - CustomTypes, - TabField, -} from "@lib/models/common/CustomType"; +import { CustomTypes } from "@lib/models/common/CustomType"; import { CustomType, NestableWidget, @@ -163,15 +140,6 @@ const useSliceMachineActions = () => { createCustomTypeCreator.request({ id, label, repeatable, format }), ); - // Custom type module - const initCustomTypeStore = ( - model: CustomTypeSM, - remoteModel: CustomTypeSM | undefined, - ) => dispatch(initCustomTypeStoreCreator({ model, remoteModel })); - const cleanupCustomTypeStore = () => - dispatch(cleanupCustomTypeStoreCreator()); - const saveCustomType = () => dispatch(saveCustomTypeCreator.request()); - /** * Success actions = sync store state from external actions. If its name * contains "Creator", it means it is still used in a saga and that `.request` @@ -200,72 +168,6 @@ const useSliceMachineActions = () => { /** End of sucess actions */ - const createCustomTypeTab = (tabId: string) => - dispatch(createTabCreator({ tabId })); - const deleteCustomTypeTab = (tabId: string) => - dispatch(deleteTabCreator({ tabId })); - const updateCustomTypeTab = (tabId: string, newTabId: string) => - dispatch(updateTabCreator({ tabId, newTabId })); - const addCustomTypeField = ( - tabId: string, - fieldId: string, - field: TabField, - ) => dispatch(addFieldCreator({ tabId, fieldId, field })); - const deleteCustomTypeField = (tabId: string, fieldId: string) => - dispatch(deleteFieldCreator({ tabId, fieldId })); - const reorderCustomTypeField = (tabId: string, start: number, end: number) => - dispatch(reorderFieldCreator({ tabId, start, end })); - const renameSelectedCustomType = (newLabel: string) => - dispatch(renameSelectedCustomTypeLabel({ newLabel })); - const replaceCustomTypeField = ( - tabId: string, - previousFieldId: string, - newFieldId: string, - value: TabField, - ) => - dispatch( - replaceFieldCreator({ tabId, previousFieldId, newFieldId, value }), - ); - const createSliceZone = (tabId: string) => - dispatch(createSliceZoneCreator({ tabId })); - const deleteSliceZone = (tabId: string) => - dispatch(deleteSliceZoneCreator({ tabId })); - const deleteCustomTypeSharedSlice = (tabId: string, sliceId: string) => - dispatch(deleteSharedSliceCreator({ tabId, sliceId })); - const addFieldIntoGroup = ( - tabId: string, - groupId: string, - fieldId: string, - field: NestableWidget, - ) => dispatch(addFieldIntoGroupCreator({ tabId, groupId, fieldId, field })); - const deleteFieldIntoGroup = ( - tabId: string, - groupId: string, - fieldId: string, - ) => dispatch(deleteFieldIntoGroupCreator({ tabId, groupId, fieldId })); - const reorderFieldIntoGroup = ( - tabId: string, - groupId: string, - start: number, - end: number, - ) => dispatch(reorderFieldIntoGroupCreator({ tabId, groupId, start, end })); - const replaceFieldIntoGroup = ( - tabId: string, - groupId: string, - previousFieldId: string, - newFieldId: string, - value: NestableWidget, - ) => - dispatch( - replaceFieldIntoGroupCreator({ - tabId, - groupId, - previousFieldId, - newFieldId, - value, - }), - ); - // Slice module const initSliceStore = (component: ComponentUI) => dispatch(initSliceStoreCreator(component)); @@ -472,27 +374,9 @@ const useSliceMachineActions = () => { stopLoadingReview, startLoadingReview, createCustomType, - renameSelectedCustomType, deleteCustomTypeSuccess, renameAvailableCustomTypeSuccess, - initCustomTypeStore, - cleanupCustomTypeStore, - saveCustomType, saveCustomTypeSuccess, - createCustomTypeTab, - updateCustomTypeTab, - deleteCustomTypeTab, - addCustomTypeField, - deleteCustomTypeField, - reorderCustomTypeField, - replaceCustomTypeField, - createSliceZone, - deleteSliceZone, - deleteCustomTypeSharedSlice, - addFieldIntoGroup, - deleteFieldIntoGroup, - reorderFieldIntoGroup, - replaceFieldIntoGroup, initSliceStore, addSliceWidget, replaceSliceWidget, diff --git a/packages/slice-machine/src/redux/reducer.ts b/packages/slice-machine/src/redux/reducer.ts index 0ddb980e43..c460e54337 100644 --- a/packages/slice-machine/src/redux/reducer.ts +++ b/packages/slice-machine/src/redux/reducer.ts @@ -10,7 +10,6 @@ import { userContextReducer } from "@src/modules/userContext"; import { environmentReducer } from "@src/modules/environment"; import { simulatorReducer } from "@src/modules/simulator"; import { availableCustomTypesReducer } from "@src/modules/availableCustomTypes"; -import { selectedCustomTypeReducer } from "@src/modules/selectedCustomType"; import { slicesReducer } from "@src/modules/slices"; import { routerReducer } from "connected-next-router"; import { selectedSliceReducer } from "@src/modules/selectedSlice/reducer"; @@ -25,7 +24,6 @@ const createReducer = (): Reducer => environment: environmentReducer, simulator: simulatorReducer, availableCustomTypes: availableCustomTypesReducer, - selectedCustomType: selectedCustomTypeReducer, slices: slicesReducer, selectedSlice: selectedSliceReducer, router: routerReducer, diff --git a/packages/slice-machine/src/redux/saga.ts b/packages/slice-machine/src/redux/saga.ts index f911c293d8..37e998c88b 100644 --- a/packages/slice-machine/src/redux/saga.ts +++ b/packages/slice-machine/src/redux/saga.ts @@ -2,7 +2,6 @@ import { fork } from "redux-saga/effects"; import { watchSimulatorSagas } from "@src/modules/simulator"; import { watchAvailableCustomTypesSagas } from "@src/modules/availableCustomTypes"; -import { watchSelectedCustomTypeSagas } from "@src/modules/selectedCustomType"; import { selectedSliceSagas } from "@src/modules/selectedSlice/sagas"; import { watchSliceSagas } from "@src/modules/slices"; import { watchToasterSagas } from "@src/modules/toaster"; @@ -16,7 +15,6 @@ export default function* rootSaga() { yield fork(watchAvailableCustomTypesSagas); yield fork(watchSliceSagas); yield fork(watchToasterSagas); - yield fork(watchSelectedCustomTypeSagas); yield fork(screenshotsSagas); yield fork(selectedSliceSagas); yield fork(watchChangesPushSagas); diff --git a/packages/slice-machine/src/redux/type.ts b/packages/slice-machine/src/redux/type.ts index 79821b090e..48064271db 100644 --- a/packages/slice-machine/src/redux/type.ts +++ b/packages/slice-machine/src/redux/type.ts @@ -4,7 +4,6 @@ import { UserContextStoreType } from "@src/modules/userContext/types"; import { EnvironmentStoreType } from "@src/modules/environment/types"; import { SimulatorStoreType } from "@src/modules/simulator/types"; import { AvailableCustomTypesStoreType } from "@src/modules/availableCustomTypes/types"; -import { SelectedCustomTypeStoreType } from "@src/modules/selectedCustomType/types"; import { SlicesStoreType } from "@src/modules/slices/types"; import { RouterState } from "connected-next-router/types"; import { SelectedSliceStoreType } from "@src/modules/selectedSlice/types"; @@ -17,7 +16,6 @@ export type SliceMachineStoreType = { environment: EnvironmentStoreType; simulator: SimulatorStoreType; availableCustomTypes: AvailableCustomTypesStoreType; - selectedCustomType: SelectedCustomTypeStoreType; slices: SlicesStoreType; selectedSlice: SelectedSliceStoreType; router: RouterState; diff --git a/packages/slice-machine/test/lib/models/common/CustomType/sliceZone.test.ts b/packages/slice-machine/test/lib/models/common/CustomType/sliceZone.test.ts deleted file mode 100644 index abeff2b3f2..0000000000 --- a/packages/slice-machine/test/lib/models/common/CustomType/sliceZone.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { SliceZone } from "@lib/models/common/CustomType/sliceZone"; -// @ts-expect-error TS(2307) FIXME: Cannot find module '@slicemachine/core/build/model... Remove this comment to see the full error message -import { SlicesSM } from "@slicemachine/core/build/models/Slices"; - -const mockSliceZone: SlicesSM = { - key: "slices", - value: [ - { key: "slice_in_zone_0", value: { type: "SharedSlice" } }, - { key: "slice_in_zone_1", value: { type: "SharedSlice" } }, - { key: "slice_in_zone_2", value: { type: "SharedSlice" } }, - ], -}; - -describe("Slice Zone", () => { - describe("Remove shared slices", () => { - it("removes the correct slice", () => { - const keyToRemove = "slice_in_zone_1"; - - const result = SliceZone.removeSharedSlice(mockSliceZone, keyToRemove); - - expect(result.value.map((slice) => slice.key).includes(keyToRemove)).toBe( - false, - ); - expect(result.value.length).toEqual(2); - }); - - it("removes nothing if the key does not exist", () => { - const keyToRemove = "invalid_key"; - - const result = SliceZone.removeSharedSlice(mockSliceZone, keyToRemove); - - expect(result).toEqual(mockSliceZone); - }); - }); - - describe("Add shared slices", () => { - it("adds a new slice to the slice zone", () => { - const newKey = "new_slice_in_zone"; - - const result = SliceZone.addSharedSlice(mockSliceZone, newKey); - - expect(result.value.map((slice) => slice.key)).toEqual( - // @ts-expect-error TS(7006) FIXME: Parameter 'slice' implicitly has an 'any' type. - mockSliceZone.value.map((slice) => slice.key).concat([newKey]), - ); - expect(result.value.length).toEqual(4); - }); - }); -}); diff --git a/packages/slice-machine/test/pages/custom-types.test.tsx b/packages/slice-machine/test/pages/custom-types.test.tsx deleted file mode 100644 index 776edfd8a7..0000000000 --- a/packages/slice-machine/test/pages/custom-types.test.tsx +++ /dev/null @@ -1,625 +0,0 @@ -// @vitest-environment jsdom - -import { describe, test, afterEach, beforeEach, expect, vi } from "vitest"; -import Router from "next/router"; -import mockRouter from "next-router-mock"; -import { Analytics } from "@segment/analytics-node"; -import userEvent from "@testing-library/user-event"; - -import { createSliceMachineManager } from "@slicemachine/manager"; -import { createSliceMachineManagerMSWHandler } from "@slicemachine/manager/test"; -import pkg from "../../package.json"; -import { - render, - fireEvent, - act, - screen, - waitFor, - within, -} from "../__testutils__"; -import { createTestPlugin } from "../__testutils__/createTestPlugin"; -import { createTestProject } from "../__testutils__/createTestProject"; - -import CreateCustomTypeBuilder from "../../pages/custom-types/[customTypeId]"; - -vi.mock("next/router", () => import("next-router-mock")); - -describe("Custom Type Builder", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - beforeEach(() => { - mockRouter.setCurrentUrl("/"); - }); - - const libraries = [ - { - path: "./slices", - isLocal: true, - name: "slices", - meta: { - isNodeModule: false, - isDownloaded: false, - isManual: true, - }, - components: [ - { - from: "slices", - href: "slices", - pathToSlice: "./slices", - fileName: "index", - extension: "js", - screenshots: {}, - mocks: [ - { - variation: "default", - name: "Default", - slice_type: "test_slice", - items: [], - primary: { - title: [ - { - type: "heading1", - text: "Cultivate granular e-services", - spans: [], - }, - ], - description: [ - { - type: "paragraph", - text: "Anim in commodo exercitation qui. Elit cillum officia mollit dolore. Commodo voluptate sit est proident ea proident dolor esse ad.", - spans: [], - }, - ], - }, - }, - ], - model: { - id: "test_slice", - type: "SharedSlice", - name: "TestSlice", - description: "TestSlice", - variations: [ - { - id: "default", - name: "Default", - docURL: "...", - version: "sktwi1xtmkfgx8626", - description: "TestSlice", - primary: [ - { - key: "title", - value: { - type: "StructuredText", - config: { - single: "heading1", - label: "Title", - placeholder: "This is where it all begins...", - }, - }, - }, - { - key: "description", - value: { - type: "StructuredText", - config: { - single: "paragraph", - label: "Description", - placeholder: "A nice description of your feature", - }, - }, - }, - ], - items: [], - imageUrl: - "https://images.prismic.io/slice-machine/621a5ec4-0387-4bc5-9860-2dd46cbc07cd_default_ss.png?auto=compress,format", - }, - ], - }, - screenshotUrls: {}, - }, - ], - }, - ]; - - test("should send a tracking event when the user adds a field", async () => { - const customTypeId = "a-page"; - - void Router.push({ - pathname: "custom-types/[customTypeId]", - query: { customTypeId }, - }); - - render(, { - preloadedState: { - availableCustomTypes: { - [customTypeId]: { - local: { - id: customTypeId, - label: customTypeId, - repeatable: true, - format: "custom", - status: true, - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - }, - }, - // @ts-expect-error TS2739: Type '{ manifest: { apiEndpoint: string; }; }' is missing the following properties from type 'FrontEndEnvironment': repo, packageManager, supportsSliceSimulator, endpoints - environment: { - manifest: { apiEndpoint: "https://foo.cdn.prismic.io/api/v2" }, - }, - // @ts-expect-error TS(2741) FIXME: Property 'remoteModel' is missing in type '{ model... Remove this comment to see the full error message - selectedCustomType: { - model: { - id: "a-page", - label: "a-page", - repeatable: true, - format: "custom", - status: true, - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - initialModel: { - id: "a-page", - label: "a-page", - repeatable: true, - format: "custom", - status: true, - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - }, - slices: { - remoteSlices: [], - // @ts-expect-error TS(2322) FIXME: Type '{ path: string; isLocal: boolean; name: stri... Remove this comment to see the full error message - libraries: libraries, - }, - }, - }); - - const addButton = await screen.findByText("Add a new field"); - fireEvent.click(addButton); - - const richText = screen.getByText("Rich Text"); - fireEvent.click(richText); - - const nameInput = screen.getByLabelText("label-input"); - fireEvent.change(nameInput, { target: { value: "New Field" } }); - - const saveFieldButton = screen.getByText("Add"); - - // eslint-disable-next-line @typescript-eslint/await-thenable - await act(() => { - fireEvent.click(saveFieldButton); - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledOnce(); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledWith( - expect.objectContaining({ - event: "SliceMachine Custom Type Field Added", - properties: { - id: "new_field", - name: "a-page", - type: "StructuredText", - zone: "static", - nodeVersion: process.versions.node, - }, - }), - expect.any(Function), - ); - }); - - test("should send a tracking event when the user adds a slice", async () => { - const customTypeId = "a-page"; - - void Router.push({ - pathname: "custom-types/[customTypeId]", - query: { customTypeId }, - }); - - // duplicated state for library context :/ - - render(, { - preloadedState: { - availableCustomTypes: { - [customTypeId]: { - local: { - id: customTypeId, - label: customTypeId, - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - sliceZone: { - key: "slices", - value: [], - }, - }, - ], - }, - }, - }, - // @ts-expect-error TS2739: Type '{ manifest: { apiEndpoint: string; }; }' is missing the following properties from type 'FrontEndEnvironment': repo, packageManager, supportsSliceSimulator, endpoints - environment: { - manifest: { apiEndpoint: "https://foo.cdn.prismic.io/api/v2" }, - }, - // @ts-expect-error TS(2741) FIXME: Property 'remoteModel' is missing in type '{ model... Remove this comment to see the full error message - selectedCustomType: { - model: { - id: "a-page", - label: "a-page", - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - initialModel: { - id: "a-page", - label: "a-page", - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - }, - slices: { - // @ts-expect-error TS(2322) FIXME: Type '{ path: string; isLocal: boolean; name: stri... Remove this comment to see the full error message - libraries, - remoteSlices: [], - }, - }, - }); - - const user = userEvent.setup(); - await user.click(screen.getByText("Select existing")); - - const slicesToSelect = screen.getAllByTestId("shared-slice-card"); - - for (const elem of slicesToSelect) { - // eslint-disable-next-line @typescript-eslint/await-thenable - await act(() => { - fireEvent.click(elem); - }); - } - - const saveButton = within(screen.getByRole("dialog")).getByText("Add"); - - // eslint-disable-next-line @typescript-eslint/await-thenable - await act(() => { - fireEvent.click(saveButton); - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledOnce(); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledWith( - expect.objectContaining({ - event: "SliceMachine Slicezone Updated", - properties: { customTypeId, nodeVersion: process.versions.node }, - }), - expect.any(Function), - ); - }); - - // FIXME: events happening out of order - test.skip("it should send a tracking event when the user saves a custom-type", async (ctx) => { - const adapter = createTestPlugin({ - setup: ({ hook }) => { - hook("custom-type:update", () => void 0); - }, - }); - const cwd = await createTestProject({ adapter }); - const manager = createSliceMachineManager({ - nativePlugins: { [adapter.meta.name]: adapter }, - cwd, - }); - - await manager.telemetry.initTelemetry({ - appName: pkg.name, - appVersion: pkg.version, - }); - await manager.plugins.initPlugins(); - - ctx.msw.use( - createSliceMachineManagerMSWHandler({ - url: "http://localhost:3000/_manager", - sliceMachineManager: manager, - }), - ); - - const customTypeId = "a-page"; - - void Router.push({ - pathname: "custom-types/[customTypeId]", - query: { customTypeId }, - }); - - render(, { - preloadedState: { - availableCustomTypes: { - [customTypeId]: { - local: { - id: customTypeId, - label: customTypeId, - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - }, - }, - // @ts-expect-error TS(2741) FIXME: Property 'remoteModel' is missing in type '{ model... Remove this comment to see the full error message - selectedCustomType: { - model: { - id: "a-page", - label: "a-page", - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - initialModel: { - id: "a-page", - label: "a-page", - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - }, - slices: { - // @ts-expect-error TS(2322) FIXME: Type '{ path: string; isLocal: boolean; name: stri... Remove this comment to see the full error message - libraries: libraries, - remoteSlices: [], - }, - }, - }); - - const addButton = screen.getByText("Add a new field"); - fireEvent.click(addButton); - - const richText = screen.getByText("Rich Text"); - fireEvent.click(richText); - - const nameInput = screen.getByLabelText("label-input"); - fireEvent.change(nameInput, { target: { value: "New Field" } }); - - const saveFieldButton = screen.getByText("Add"); - - act(() => { - fireEvent.click(saveFieldButton); - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledOnce(); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledWith( - expect.objectContaining({ - event: "SliceMachine Custom Type Field Added", - properties: { - id: "new_field", - name: "a-page", - type: "StructuredText", - zone: "static", - nodeVersion: process.versions.node, - }, - }), - expect.any(Function), - ); - - const saveCustomType = screen.getByTestId("builder-save-button"); - - act(() => { - fireEvent.click(saveCustomType); - }); - - await waitFor(() => { - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledTimes(2); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledWith( - expect.objectContaining({ - event: "SliceMachine Custom Type Saved", - properties: { - type: "repeatable", - id: customTypeId, - name: customTypeId, - nodeVersion: process.versions.node, - }, - }), - expect.any(Function), - ); - }); - }); - - test("if saving fails a it should not send the save event", async (ctx) => { - const adapter = createTestPlugin({ - setup: ({ hook }) => { - hook("custom-type:update", () => { - throw new Error("forced failure"); - }); - }, - }); - const cwd = await createTestProject({ adapter }); - const manager = createSliceMachineManager({ - nativePlugins: { [adapter.meta.name]: adapter }, - cwd, - }); - - await manager.telemetry.initTelemetry({ - appName: pkg.name, - appVersion: pkg.version, - }); - await manager.plugins.initPlugins(); - - ctx.msw.use( - createSliceMachineManagerMSWHandler({ - url: "http://localhost:3000/_manager", - sliceMachineManager: manager, - }), - ); - - const customTypeId = "a-page"; - - void Router.push({ - pathname: "custom-types/[customTypeId]", - query: { customTypeId }, - }); - - render(, { - preloadedState: { - availableCustomTypes: { - [customTypeId]: { - local: { - id: customTypeId, - label: customTypeId, - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - }, - }, - // @ts-expect-error TS2739: Type '{ manifest: { apiEndpoint: string; }; }' is missing the following properties from type 'FrontEndEnvironment': repo, packageManager, supportsSliceSimulator, endpoints - environment: { - manifest: { apiEndpoint: "https://foo.cdn.prismic.io/api/v2" }, - }, - // @ts-expect-error TS(2741) FIXME: Property 'remoteModel' is missing in type '{ model... Remove this comment to see the full error message - selectedCustomType: { - model: { - id: "a-page", - label: "a-page", - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - initialModel: { - id: "a-page", - label: "a-page", - repeatable: true, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [], - }, - ], - }, - }, - slices: { - // @ts-expect-error TS(2322) FIXME: Type '{ path: string; isLocal: boolean; name: stri... Remove this comment to see the full error message - libraries, - remoteSlices: [], - }, - }, - }); - - const addButton = screen.getByText("Add a new field"); - // eslint-disable-next-line @typescript-eslint/await-thenable - await act(() => { - fireEvent.click(addButton); - }); - - const richText = screen.getByText("Rich Text"); - // eslint-disable-next-line @typescript-eslint/await-thenable - await act(() => { - fireEvent.click(richText); - }); - - const nameInput = screen.getByLabelText("label-input"); - // eslint-disable-next-line @typescript-eslint/await-thenable - await act(() => { - fireEvent.change(nameInput, { target: { value: "New Field" } }); - }); - - const saveFieldButton = screen.getByText("Add"); - // eslint-disable-next-line @typescript-eslint/await-thenable - await act(() => { - fireEvent.click(saveFieldButton); - }); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledOnce(); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledWith( - expect.objectContaining({ - event: "SliceMachine Custom Type Field Added", - properties: { - id: "new_field", - name: "a-page", - type: "StructuredText", - zone: "static", - nodeVersion: process.versions.node, - }, - }), - expect.any(Function), - ); - - const saveCustomType = screen.getByTestId("builder-save-button"); - - act(() => { - fireEvent.click(saveCustomType); - }); - - await new Promise((r) => setTimeout(r, 500)); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(Analytics.prototype.track).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/slice-machine/test/src/modules/selectedCustomType/__fixtures__/model.json b/packages/slice-machine/test/src/modules/selectedCustomType/__fixtures__/model.json deleted file mode 100644 index e6778290da..0000000000 --- a/packages/slice-machine/test/src/modules/selectedCustomType/__fixtures__/model.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "id": "homepage", - "label": "Homepage", - "status": true, - "repeatable": false, - "json": { - "Main": { - "rt222": { - "type": "StructuredText", - "config": { - "label": "rt", - "placeholder": "", - "allowTargetBlank": true, - "multi": "paragraph,preformatted,heading1,heading2,heading3,heading4,heading5,heading6,strong,em,hyperlink,image,embed,list-item,o-list-item,rtl" - } - }, - "group11": { - "type": "Group", - "config": { - "type": "Group", - "fields": { - "aaa2": { - "type": "Image", - "config": { - "label": "", - "constraint": {}, - "thumbnails": [] - } - }, - "aaa3": { - "type": "StructuredText", - "config": { - "label": "", - "placeholder": "", - "allowTargetBlank": true, - "single": "paragraph,preformatted,heading1,heading2,heading3,heading4,heading5,heading6,strong,em,hyperlink,image,embed,list-item,o-list-item,rtl" - } - }, - "sss4": { - "type": "Image", - "config": { - "label": "", - "constraint": {}, - "thumbnails": [] - } - }, - "text": { - "type": "Text", - "config": { - "label": "", - "placeholder": "" - } - } - } - } - }, - "group9": { - "type": "Group", - "config": { - "fields": { - "txt": { - "type": "Text", - "config": { - "label": "", - "placeholder": "" - } - }, - "text": { - "type": "Text", - "config": { - "label": "", - "placeholder": "" - } - }, - "img": { - "type": "Image", - "config": { - "label": "", - "constraint": {}, - "thumbnails": [] - } - } - } - } - }, - "sdf": { - "type": "Image", - "config": { - "label": "", - "constraint": {}, - "thumbnails": [] - } - }, - "body": { - "type": "Slices", - "fieldset": "Slice Zone", - "config": { - "choices": { - "with_mocks": { - "type": "SharedSlice" - }, - "cards_carousel": { - "type": "SharedSlice" - }, - "images_slider": { - "type": "SharedSlice" - }, - "feature_testimonials": { - "type": "SharedSlice" - }, - "alternate_grid": { - "type": "SharedSlice" - } - } - } - } - } - } -} diff --git a/packages/slice-machine/test/src/modules/selectedCustomType/reducer.test.ts b/packages/slice-machine/test/src/modules/selectedCustomType/reducer.test.ts deleted file mode 100644 index ffde5a51f1..0000000000 --- a/packages/slice-machine/test/src/modules/selectedCustomType/reducer.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { CustomTypes } from "@lib/models/common/CustomType"; -import jsonModel from "./__fixtures__/model.json"; -import { CustomType } from "@prismicio/types-internal/lib/customtypes"; -import { - addFieldCreator, - createSliceZoneCreator, - createTabCreator, - selectedCustomTypeReducer, - deleteFieldCreator, - initCustomTypeStoreCreator, - reorderFieldCreator, - replaceFieldCreator, - updateTabCreator, - cleanupCustomTypeStoreCreator, -} from "@src/modules/selectedCustomType"; -import { SelectedCustomTypeStoreType } from "@src/modules/selectedCustomType/types"; -import * as widgets from "@models/common/widgets/withGroup"; -import equal from "fast-deep-equal"; - -const customTypeAsArray = CustomTypes.toSM(jsonModel as unknown as CustomType); - -const dummyCustomTypesState: SelectedCustomTypeStoreType = { - model: customTypeAsArray, - initialModel: customTypeAsArray, - // @ts-expect-error TS(2322) FIXME: Type 'null' is not assignable to type '({ id: stri... Remove this comment to see the full error message - remoteModel: null, -}; - -describe("[Selected Custom type module]", () => { - describe("[Reducer]", () => { - it("should return the initial state if no action", () => { - // @ts-expect-error TS(2345) FIXME: Argument of type '{}' is not assignable to paramet... Remove this comment to see the full error message - expect(selectedCustomTypeReducer(dummyCustomTypesState, {})).toEqual( - dummyCustomTypesState, - ); - }); - - it("should return the initial state if no matching action", () => { - expect( - // @ts-expect-error TS(2322) FIXME: Type '"NO.MATCH"' is not assignable to type '"CUST... Remove this comment to see the full error message - selectedCustomTypeReducer(dummyCustomTypesState, { type: "NO.MATCH" }), - ).toEqual(dummyCustomTypesState); - }); - - it("should reset the custom type state given CUSTOM_TYPE/CLEANUP action", () => { - expect( - selectedCustomTypeReducer( - dummyCustomTypesState, - cleanupCustomTypeStoreCreator(), - ), - ).toEqual(null); - }); - - it("should update the custom type state given CUSTOM_TYPE/INIT action", () => { - expect( - selectedCustomTypeReducer( - dummyCustomTypesState, - initCustomTypeStoreCreator({ - model: customTypeAsArray, - remoteModel: undefined, - }), - ), - ).toEqual({ - model: customTypeAsArray, - initialModel: customTypeAsArray, - remoteModel: undefined, - }); - }); - it("should update the custom type state given CUSTOM_TYPE/CREATE_TAB action", () => { - const newTabId = "Tab1"; - const previousTabLength = dummyCustomTypesState?.model.tabs.length ?? 0; - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - createTabCreator({ - tabId: newTabId, - }), - ); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const tabs = newState.model.tabs; - expect(tabs.length).toEqual(previousTabLength + 1); - expect(tabs[tabs.length - 1].key).toBe(newTabId); - - /** Don't create a second tab with same key */ - const newState2 = selectedCustomTypeReducer( - dummyCustomTypesState, - createTabCreator({ - tabId: newTabId, - }), - ); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const tabs2 = newState2.model.tabs; - expect(tabs2.length).toBe(tabs.length); - }); - it("should update the custom type state given CUSTOM_TYPE/UPDATE_TAB action if the tab is found", () => { - const newTabId = "Tab1"; - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - updateTabCreator({ - newTabId, - tabId: initialTab.key, - }), - ); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const tabs = newState.model.tabs[0]; - expect(tabs.key).toBe(newTabId); - }); - it("should not update the custom type state given CUSTOM_TYPE/UPDATE_TAB action if the tab is not found", () => { - const newTabId = "Tab1"; - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const unknownTabId = `some___${initialTab.key}`; - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - updateTabCreator({ - newTabId, - tabId: unknownTabId, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const tabs = newState.model.tabs[0]; - expect(tabs.key).toBe(initialTab.key); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(!!newState.model.tabs.find((e) => e.key === unknownTabId)).toBe( - false, - ); - }); - it("should add a field into a custom type given CUSTOM_TYPE/ADD_FIELD action", () => { - const fieldId = "fieldId"; - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - addFieldCreator({ - tabId: initialTab.key, - fieldId, - field: widgets.Boolean.create(fieldId), - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const tabValue = newState.model.tabs[0].value; - expect(tabValue.length).toEqual(initialTab.value.length + 1); - expect(tabValue[tabValue.length - 1].key).toEqual(fieldId); - }); - it("should remove a field into a custom type given CUSTOM_TYPE/DELETE_FIELD action", () => { - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - deleteFieldCreator({ - tabId: initialTab.key, - fieldId: initialTab.value[0].key, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState.model.tabs[0].value.length).toEqual( - initialTab.value.length - 1, - ); - }); - it("should reorder fields into a custom type given CUSTOM_TYPE/REORDER_FIELD action", () => { - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const fieldIdA = initialTab.value[0].key; - const fieldIdB = initialTab.value[1].key; - const fieldIdC = initialTab.value[2].key; - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - reorderFieldCreator({ - tabId: initialTab.key, - start: 0, - end: 1, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState.model.tabs[0].value[0].key).toEqual(fieldIdB); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState.model.tabs[0].value[1].key).toEqual(fieldIdA); - - const newState2 = selectedCustomTypeReducer( - newState, - reorderFieldCreator({ - tabId: initialTab.key, - start: 0, - end: 1, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState2.model.tabs[0].value[0].key).toEqual(fieldIdA); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState2.model.tabs[0].value[1].key).toEqual(fieldIdB); - - const newState3 = selectedCustomTypeReducer( - newState2, - reorderFieldCreator({ - tabId: initialTab.key, - start: 0, - end: 2, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState3.model.tabs[0].value[0].key).toEqual(fieldIdB); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState3.model.tabs[0].value[1].key).toEqual(fieldIdC); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState3.model.tabs[0].value[2].key).toEqual(fieldIdA); - - const newState4 = selectedCustomTypeReducer( - newState3, - reorderFieldCreator({ - tabId: initialTab.key, - start: 2, - end: 0, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState4.model.tabs[0].value[0].key).toEqual(fieldIdA); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState4.model.tabs[0].value[1].key).toEqual(fieldIdB); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState4.model.tabs[0].value[2].key).toEqual(fieldIdC); - }); - }); - it("should create a empty slicezone into a custom type given CUSTOM_TYPE/CREATE_SLICE_ZONE action", () => { - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - createSliceZoneCreator({ - tabId: initialTab.key, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const newTabState = newState.model.tabs[0]; - expect(newTabState.sliceZone).not.toEqual(null); - // @ts-expect-error TS(2532) FIXME: Object is possibly 'undefined'. - expect(newTabState.sliceZone.value.length).toEqual(0); - // @ts-expect-error TS(2532) FIXME: Object is possibly 'undefined'. - expect(newTabState.sliceZone.key).toEqual("slices"); - }); - it("should update the field id into a custom type given CUSTOM_TYPE/REPLACE_FIELD action", () => { - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const field = initialTab.value[0]; - - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - replaceFieldCreator({ - tabId: initialTab.key, - previousFieldId: field.key, - newFieldId: "newKey", - value: field.value, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(equal(newState.model.tabs[0].value, initialTab.value)).toEqual( - false, - ); - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(equal(newState.model.tabs[0].value[0].value, field.value)).toEqual( - true, - ); - }); - it("should not update the field into a custom type given CUSTOM_TYPE/REPLACE_FIELD action if the field is the same", () => { - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const field = initialTab.value[0]; - - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - replaceFieldCreator({ - tabId: initialTab.key, - previousFieldId: field.key, - newFieldId: field.key, - value: field.value, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(equal(newState.model.tabs[0].value, initialTab.value)).toEqual(true); - }); - it("should update the field content into a custom type given CUSTOM_TYPE/REPLACE_FIELD action", () => { - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - const initialTab = dummyCustomTypesState.model.tabs[0]; - const field = initialTab.value[0]; - // @ts-expect-error TS(2532) FIXME: Object is possibly 'undefined'. - const newPlaceholder = `differ-from-${field.value.config.placeholder}`; - - const newState = selectedCustomTypeReducer( - dummyCustomTypesState, - replaceFieldCreator({ - tabId: initialTab.key, - previousFieldId: field.key, - newFieldId: field.key, - value: { - ...field.value, - config: { - ...field.value.config, - // @ts-expect-error TS(2322) FIXME: Type '{ placeholder: string; } | { placeholder: st... Remove this comment to see the full error message - placeholder: newPlaceholder, - }, - }, - }), - ); - - // @ts-expect-error TS(2531) FIXME: Object is possibly 'null'. - expect(newState.model.tabs[0].value[0].value.config.placeholder).toEqual( - newPlaceholder, - ); - }); -}); diff --git a/packages/slice-machine/test/src/modules/selectedCustomType/sagas.test.ts b/packages/slice-machine/test/src/modules/selectedCustomType/sagas.test.ts deleted file mode 100644 index c6174e311f..0000000000 --- a/packages/slice-machine/test/src/modules/selectedCustomType/sagas.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it } from "vitest"; -import { testSaga } from "redux-saga-test-plan"; -import { Analytics } from "@segment/analytics-node"; - -import { saveCustomType } from "@src/apiClient"; -import { saveCustomTypeSaga } from "@src/modules/selectedCustomType/sagas"; -import { openToasterCreator, ToasterType } from "@src/modules/toaster"; -import { - saveCustomTypeCreator, - selectCurrentCustomType, -} from "@src/modules/selectedCustomType"; -import { CustomTypeSM } from "@lib/models/common/CustomType"; - -const customTypeModel: CustomTypeSM = { - id: "about", - label: "My Cool About Page", - repeatable: false, - status: true, - format: "custom", - tabs: [ - { - key: "Main", - value: [ - { - key: "title", - value: { - type: "StructuredText", - config: { - label: "", - placeholder: "", - allowTargetBlank: true, - single: - "paragraph,preformatted,heading1,heading2,heading3,heading4,heading5,heading6,strong,em,hyperlink,image,embed,list-item,o-list-item,rtl", - }, - }, - }, - ], - sliceZone: { - key: "MainSliceZone", - value: [], - }, - }, - ], -}; - -describe("[Selected Custom type sagas]", () => { - describe("[saveCustomTypeSaga]", () => { - it("should call the api and dispatch the good actions on success", async () => { - const saga = testSaga(saveCustomTypeSaga); - - saga.next().select(selectCurrentCustomType); - saga.next(customTypeModel).call(saveCustomType, customTypeModel); - - saga - .next({ errors: [] }) - .put(saveCustomTypeCreator.success({ customType: customTypeModel })); - - saga - .next() - .inspect( - (action: { - payload: { action: { type: string; payload: { type: string } } }; - }) => { - expect(action.payload.action.type).toBe("TOASTER/OPEN"); - expect(action.payload.action.payload.type).toBe( - ToasterType.SUCCESS, - ); - }, - ); - - saga.next().isDone(); - - // Wait for network request to be performed - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(Analytics.prototype.track).toHaveBeenCalledOnce(); - }); - it("should open a error toaster on internal error", () => { - const saga = testSaga(saveCustomTypeSaga).next(); - - saga.throw(new Error()).put( - openToasterCreator({ - content: "Internal Error: Custom type not saved", - type: ToasterType.ERROR, - }), - ); - saga.next().isDone(); - }); - }); -}); diff --git a/packages/slice-machine/test/src/modules/selectedCustomType/selectors.test.ts b/packages/slice-machine/test/src/modules/selectedCustomType/selectors.test.ts deleted file mode 100644 index b523e3699a..0000000000 --- a/packages/slice-machine/test/src/modules/selectedCustomType/selectors.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, test, expect } from "vitest"; -import jsonModel from "./__fixtures__/model.json"; -import { isSelectedCustomTypeTouched } from "@src/modules/selectedCustomType"; -import { CustomType } from "@prismicio/types-internal/lib/customtypes"; -import { CustomTypes } from "@lib/models/common/CustomType"; - -const model = CustomTypes.toSM(jsonModel as unknown as CustomType); - -describe("[Selected Custom type selectors]", () => { - describe("[isSelectedCustomTypeTouched]", () => { - test("it computes correctly the state of the modifications 1/4", () => { - const customTypeStatus = isSelectedCustomTypeTouched({ - selectedCustomType: { - model, - // @ts-expect-error TS(2322) FIXME: Type 'null' is not assignable to type '{ id: strin... Remove this comment to see the full error message - initialModel: null, - }, - }); - - expect(customTypeStatus).toBe(true); - }); - test("it computes correctly the state of the modifications 2/4", () => { - const customTypeStatus = isSelectedCustomTypeTouched({ - // @ts-expect-error TS(2741) FIXME: Property 'remoteModel' is missing in type '{ model... Remove this comment to see the full error message - selectedCustomType: { - model, - initialModel: model, - }, - }); - - expect(customTypeStatus).toBe(false); - }); - test("it computes correctly the state of the modifications 3/4", () => { - const customTypeStatus = isSelectedCustomTypeTouched({ - // @ts-expect-error TS(2741) FIXME: Property 'remoteModel' is missing in type '{ model... Remove this comment to see the full error message - selectedCustomType: { - model, - initialModel: { ...model, label: `differ-from-${model.label}` }, - }, - }); - expect(customTypeStatus).toBe(true); - }); - test("it computes correctly the state of the modifications 4/4", () => { - const customTypeStatus = isSelectedCustomTypeTouched({ - selectedCustomType: { - model, - // @ts-expect-error TS(2740) FIXME: Type '{}' is missing the following properties from... Remove this comment to see the full error message - initialModel: { ...model, tabs: {} }, - }, - }); - expect(customTypeStatus).toBe(true); - }); - }); -}); diff --git a/playwright/pages/PageTypesBuilderPage.ts b/playwright/pages/PageTypesBuilderPage.ts index f0d2be2046..7eff207681 100644 --- a/playwright/pages/PageTypesBuilderPage.ts +++ b/playwright/pages/PageTypesBuilderPage.ts @@ -1,8 +1,12 @@ -import { Page } from "@playwright/test"; +import { Locator, Page } from "@playwright/test"; +import { PageSnippetDialog } from "./components/PageSnippetDialog"; import { TypeBuilderPage } from "./shared/TypeBuilderPage"; export class PageTypeBuilderPage extends TypeBuilderPage { + readonly pageSnippetDialog: PageSnippetDialog; + readonly pageSnippetButton: Locator; + constructor(page: Page) { super(page, { format: "page", @@ -11,12 +15,15 @@ export class PageTypeBuilderPage extends TypeBuilderPage { /** * Components */ - // Handle components here + this.pageSnippetDialog = new PageSnippetDialog(page); /** * Static locators */ - // Handle static locators here + this.pageSnippetButton = this.page.getByRole("button", { + name: "Page snippet", + exact: true, + }); } /** diff --git a/playwright/pages/SliceBuilderPage.ts b/playwright/pages/SliceBuilderPage.ts index 53d2e46691..40aa0d83d8 100644 --- a/playwright/pages/SliceBuilderPage.ts +++ b/playwright/pages/SliceBuilderPage.ts @@ -11,14 +11,10 @@ export class SliceBuilderPage extends BuilderPage { readonly addVariationDialog: AddVariationDialog; readonly renameVariationDialog: RenameVariationDialog; readonly deleteVariationDialog: DeleteVariationDialog; - readonly savedMessage: Locator; readonly simulateTooltipTitle: Locator; readonly simulateTooltipCloseButton: Locator; readonly variationCards: Locator; readonly addVariationButton: Locator; - readonly staticZone: Locator; - readonly staticZonePlaceholder: Locator; - readonly staticZoneListItem: Locator; readonly repeatableZone: Locator; readonly repeatableZonePlaceholder: Locator; readonly repeatableZoneListItem: Locator; @@ -38,9 +34,6 @@ export class SliceBuilderPage extends BuilderPage { * Static locators */ // Global - this.savedMessage = page.getByText("Slice saved successfully", { - exact: false, - }); this.simulateTooltipTitle = page.getByText("Simulate your slices"); this.simulateTooltipCloseButton = page.getByText("Got it"); // Variations @@ -51,13 +44,6 @@ export class SliceBuilderPage extends BuilderPage { this.addVariationButton = page.getByText("Add a new variation", { exact: true, }); - // Static zone - this.staticZone = page.getByTestId("slice-non-repeatable-zone"); - this.staticZonePlaceholder = page.getByText( - "Add a field to your Static Zone", - { exact: true }, - ); - this.staticZoneListItem = this.staticZone.getByRole("listitem"); // Repeatable zone this.repeatableZone = page.getByTestId("slice-repeatable-zone"); this.repeatableZonePlaceholder = page.getByText( @@ -109,12 +95,4 @@ export class SliceBuilderPage extends BuilderPage { async openAddVariationDialog() { await this.addVariationButton.click(); } - - /** - * Assertions - */ - async checkSavedMessage() { - await expect(this.savedMessage).toBeVisible(); - await expect(this.savedMessage).not.toBeVisible(); - } } diff --git a/playwright/pages/components/CreateSliceDialog.ts b/playwright/pages/components/CreateSliceDialog.ts index 38b698fe33..9908a532f1 100644 --- a/playwright/pages/components/CreateSliceDialog.ts +++ b/playwright/pages/components/CreateSliceDialog.ts @@ -3,7 +3,8 @@ import { expect, Locator, Page } from "@playwright/test"; import { Dialog } from "./Dialog"; export class CreateSliceDialog extends Dialog { - readonly createdMessage: Locator; + readonly createdMessageFromTable: Locator; + readonly createdMessageFromSliceZone: Locator; readonly nameInput: Locator; readonly sliceAlreadyExistMessage: Locator; @@ -21,9 +22,13 @@ export class CreateSliceDialog extends Dialog { /** * Static locators */ - this.createdMessage = page.getByText("Slice saved successfully", { + this.createdMessageFromTable = page.getByText("Slice saved successfully", { exact: false, }); + this.createdMessageFromSliceZone = page.getByText( + "New slice added to slice zone", + { exact: false }, + ); this.nameInput = this.dialog.getByTestId("slice-name-input"); this.sliceAlreadyExistMessage = this.dialog.getByText( "Slice name is already taken.", @@ -39,19 +44,24 @@ export class CreateSliceDialog extends Dialog { /** * Actions */ - async createSlice(name: string) { + async createSlice(name: string, from: "table" | "sliceZone" = "table") { await expect(this.title).toBeVisible(); await this.nameInput.fill(name); await this.submitButton.click(); - await this.checkCreatedMessage(); + await this.checkCreatedMessage(from); await expect(this.title).not.toBeVisible(); } /** * Assertions */ - async checkCreatedMessage() { - await expect(this.createdMessage).toBeVisible(); - await expect(this.createdMessage).not.toBeVisible(); + async checkCreatedMessage(from: "table" | "sliceZone" = "table") { + if (from === "table") { + await expect(this.createdMessageFromTable).toBeVisible(); + await expect(this.createdMessageFromTable).not.toBeVisible(); + } else { + await expect(this.createdMessageFromSliceZone).toBeVisible(); + await expect(this.createdMessageFromSliceZone).not.toBeVisible(); + } } } diff --git a/playwright/pages/components/DeleteSliceZoneDialog.ts b/playwright/pages/components/DeleteSliceZoneDialog.ts new file mode 100644 index 0000000000..d8648b4428 --- /dev/null +++ b/playwright/pages/components/DeleteSliceZoneDialog.ts @@ -0,0 +1,41 @@ +import { Page, expect } from "@playwright/test"; + +import { Dialog } from "./Dialog"; + +export class DeleteSliceZoneDialog extends Dialog { + constructor(page: Page) { + super(page, { + title: "Do you really want to delete Slice Zone?", + submitName: "Delete", + }); + + /** + * Components + */ + // Handle components here + + /** + * Static locators + */ + // Handle static locators here + } + + /** + * Dynamic locators + */ + // Handle dynamic locators here + + /** + * Actions + */ + async deleteSliceZone() { + await expect(this.title).toBeVisible(); + await this.submitButton.click(); + await expect(this.dialog).not.toBeVisible(); + } + + /** + * Assertions + */ + // Handle assertions here +} diff --git a/playwright/pages/components/DeleteTabDialog.ts b/playwright/pages/components/DeleteTabDialog.ts new file mode 100644 index 0000000000..47a994ad52 --- /dev/null +++ b/playwright/pages/components/DeleteTabDialog.ts @@ -0,0 +1,41 @@ +import { expect, Page } from "@playwright/test"; + +import { Dialog } from "./Dialog"; + +export class DeleteTabDialog extends Dialog { + constructor(page: Page) { + super(page, { + title: "Remove Tab", + submitName: "Yes, remove tab", + }); + + /** + * Components + */ + // Handle components here + + /** + * Static locators + */ + // Handle static locators here + } + + /** + * Dynamic locators + */ + // Handle dynamic locators here + + /** + * Actions + */ + async deleteTab() { + await expect(this.title).toBeVisible(); + await this.submitButton.click(); + await expect(this.title).not.toBeVisible(); + } + + /** + * Assertions + */ + // Handle assertions here +} diff --git a/playwright/pages/components/DeleteTypeDialog.ts b/playwright/pages/components/DeleteTypeDialog.ts new file mode 100644 index 0000000000..074f1f583d --- /dev/null +++ b/playwright/pages/components/DeleteTypeDialog.ts @@ -0,0 +1,41 @@ +import { expect, Page } from "@playwright/test"; + +import { Dialog } from "./Dialog"; + +export class DeleteTypeDialog extends Dialog { + constructor(page: Page, format: "page" | "custom") { + super(page, { + title: `Delete ${format} type`, + submitName: "Delete", + }); + + /** + * Components + */ + // Handle components here + + /** + * Static locators + */ + // Handle static locators here + } + + /** + * Dynamic locators + */ + // Handle dynamic locators here + + /** + * Actions + */ + async deleteType() { + await expect(this.title).toBeVisible(); + await this.submitButton.click(); + await expect(this.title).not.toBeVisible(); + } + + /** + * Assertions + */ + // Handle assertions here +} diff --git a/playwright/pages/components/Dialog.ts b/playwright/pages/components/Dialog.ts index 4025163afb..cf0ad88f01 100644 --- a/playwright/pages/components/Dialog.ts +++ b/playwright/pages/components/Dialog.ts @@ -11,7 +11,7 @@ export class Dialog { constructor( page: Page, options: { - title: string | RegExp; + title: string | RegExp | Locator; submitName?: string; }, ) { @@ -26,7 +26,10 @@ export class Dialog { * Static locators */ this.dialog = page.getByRole("dialog"); - this.title = this.dialog.getByText(title, { exact: true }); + this.title = + title instanceof RegExp || typeof title === "string" + ? this.dialog.getByText(title, { exact: true }) + : title; this.closeButton = this.dialog.getByRole("button", { name: "Close", exact: true, diff --git a/playwright/pages/components/EditFieldDialog.ts b/playwright/pages/components/EditFieldDialog.ts new file mode 100644 index 0000000000..cf021caa33 --- /dev/null +++ b/playwright/pages/components/EditFieldDialog.ts @@ -0,0 +1,58 @@ +import { Locator, Page, expect } from "@playwright/test"; + +import { Dialog } from "./Dialog"; + +export class EditFieldDialog extends Dialog { + readonly labelInput: Locator; + readonly apiIdInput: Locator; + + constructor(page: Page) { + super(page, { + title: page.getByTestId("item-header-text"), + submitName: "Done", + }); + + /** + * Components + */ + // Handle components here + + /** + * Static locators + */ + this.labelInput = this.dialog.getByPlaceholder( + "Label for content creators (defaults to field type)", + { exact: true }, + ); + this.apiIdInput = this.dialog.getByPlaceholder( + "A unique identifier for the field (e.g. buttonLink)", + { exact: true }, + ); + } + + /** + * Dynamic locators + */ + getTitle(name: string) { + return this.title.getByText(name, { exact: true }); + } + + /** + * Actions + */ + async editField(args: { name: string; newName: string; newId: string }) { + const { name, newName, newId } = args; + + await expect(this.getTitle(name)).toBeVisible(); + await this.labelInput.fill(newName); + await expect(this.getTitle(newName)).toBeVisible(); + await this.apiIdInput.fill(newId); + await this.submitButton.click(); + await expect(this.getTitle(newName)).not.toBeVisible(); + } + + /** + * Assertions + */ + // Handle assertions here +} diff --git a/playwright/pages/components/PageSnippetDialog.ts b/playwright/pages/components/PageSnippetDialog.ts new file mode 100644 index 0000000000..bfbfb275d7 --- /dev/null +++ b/playwright/pages/components/PageSnippetDialog.ts @@ -0,0 +1,48 @@ +import { Locator, Page, expect } from "@playwright/test"; + +import { Dialog } from "./Dialog"; + +export class PageSnippetDialog extends Dialog { + readonly copyIconButton: Locator; + readonly copiedIconButton: Locator; + + constructor(page: Page) { + super(page, { + title: "Page snippet", + }); + + /** + * Components + */ + // Handle components here + + /** + * Static locators + */ + this.copyIconButton = this.dialog.getByTestId("copy-icon-button"); + this.copiedIconButton = this.dialog.getByTestId("copied-icon-button"); + } + + /** + * Dynamic locators + */ + // Handle dynamic locators here + + /** + * Actions + */ + async copyPageSnippet() { + await this.copyIconButton.click(); + + const handle = await this.page.evaluateHandle(() => + navigator.clipboard.readText(), + ); + const clipboardContent = await handle.jsonValue(); + expect(clipboardContent).toContain("prismicio"); + } + + /** + * Assertions + */ + // Handle assertions here +} diff --git a/playwright/pages/components/RenameTabDialog.ts b/playwright/pages/components/RenameTabDialog.ts new file mode 100644 index 0000000000..eeb449d57d --- /dev/null +++ b/playwright/pages/components/RenameTabDialog.ts @@ -0,0 +1,47 @@ +import { expect, Locator, Page } from "@playwright/test"; + +import { Dialog } from "./Dialog"; + +export class RenameTabDialog extends Dialog { + readonly tabIdInput: Locator; + + constructor(page: Page) { + super(page, { + title: "Rename Tab", + submitName: "Save", + }); + + /** + * Components + */ + // Handle components here + + /** + * Static locators + */ + this.tabIdInput = this.dialog.getByPlaceholder( + "A label for selecting the tab (i.e. not used in the API)", + ); + } + + /** + * Dynamic locators + */ + // Handle dynamic locators here + + /** + * Actions + */ + async renameTab(oldId: string, newId: string) { + await expect(this.title).toBeVisible(); + await expect(this.tabIdInput).toHaveValue(oldId); + await this.tabIdInput.fill(newId); + await this.submitButton.click(); + await expect(this.title).not.toBeVisible(); + } + + /** + * Assertions + */ + // Handle assertions here +} diff --git a/playwright/pages/components/RenameTypeDialog.ts b/playwright/pages/components/RenameTypeDialog.ts index 2bb2ba0b68..3e04389dd2 100644 --- a/playwright/pages/components/RenameTypeDialog.ts +++ b/playwright/pages/components/RenameTypeDialog.ts @@ -35,12 +35,15 @@ export class RenameTypeDialog extends Dialog { /** * Actions */ - async renameType(newName: string) { + async renameType(newName: string, from: "table" | "builder" = "table") { await expect(this.title).toBeVisible(); await this.nameInput.fill(newName); await this.submitButton.click(); - await this.checkRenamedMessage(); await expect(this.title).not.toBeVisible(); + + if (from === "table") { + await this.checkRenamedMessage(); + } } /** diff --git a/playwright/pages/shared/BuilderPage.ts b/playwright/pages/shared/BuilderPage.ts index cb7fcc8e61..1af9c3dd77 100644 --- a/playwright/pages/shared/BuilderPage.ts +++ b/playwright/pages/shared/BuilderPage.ts @@ -1,6 +1,7 @@ import { expect, Locator, Page } from "@playwright/test"; import { AddFieldDialog } from "../components/AddFieldDialog"; +import { EditFieldDialog } from "../components/EditFieldDialog"; import { SliceMachinePage } from "../SliceMachinePage"; export type FieldType = @@ -21,10 +22,18 @@ export type FieldType = export class BuilderPage extends SliceMachinePage { readonly addFieldDialog: AddFieldDialog; + readonly editFieldDialog: EditFieldDialog; readonly saveButton: Locator; readonly showCodeSnippetsButton: Locator; readonly hideCodeSnippetsButton: Locator; - readonly addFieldButton: Locator; + readonly autoSaveStatusSaved: Locator; + readonly autoSaveStatusSaving: Locator; + readonly autoSaveStatusError: Locator; + readonly autoSaveRetryButton: Locator; + readonly staticZone: Locator; + readonly staticZoneAddFieldButton: Locator; + readonly staticZonePlaceholder: Locator; + readonly staticZoneListItem: Locator; readonly newFieldNameInput: Locator; readonly newFieldIdInput: Locator; readonly newFieldAddButton: Locator; @@ -36,6 +45,7 @@ export class BuilderPage extends SliceMachinePage { * Components */ this.addFieldDialog = new AddFieldDialog(page); + this.editFieldDialog = new EditFieldDialog(page); /** * Static locators @@ -50,8 +60,24 @@ export class BuilderPage extends SliceMachinePage { name: "Hide code snippets", exact: true, }); + // Auto save status + this.autoSaveStatusSaved = page.getByText("Auto-saved", { exact: true }); + this.autoSaveStatusSaving = page.getByText("Saving...", { exact: true }); + this.autoSaveStatusError = page.getByText("Failed to save", { + exact: true, + }); + this.autoSaveRetryButton = page.getByRole("button", { + name: "Retry", + exact: true, + }); // Static zone - this.addFieldButton = page.getByTestId("add-Static-field"); + this.staticZone = page.getByTestId("static-zone"); + this.staticZoneAddFieldButton = page.getByTestId("add-Static-field"); + this.staticZonePlaceholder = this.staticZone.getByText( + "Add a field to your Static Zone", + { exact: true }, + ); + this.staticZoneListItem = this.staticZone.getByRole("listitem"); // New field this.newFieldNameInput = page.getByPlaceholder("Field Name"); this.newFieldIdInput = page.getByPlaceholder("e.g. buttonLink"); @@ -64,13 +90,40 @@ export class BuilderPage extends SliceMachinePage { /** * Dynamic locators */ - // Handle dynamic locators here + getStaticZoneListItem(id: string) { + return this.page.getByTestId(`list-item-${id}`); + } + + getStaticZoneListItemFieldName(id: string, name: string) { + return this.getStaticZoneListItem(id) + .getByTestId("field-name") + .getByText(name, { exact: true }); + } + + getStaticZoneListItemFieldId(id: string) { + return this.getStaticZoneListItem(id) + .getByTestId("field-id") + .getByText(`data.${id}`, { exact: true }); + } + + getEditFieldButton(id: string) { + return this.getStaticZoneListItem(id).getByRole("button", { + name: "Edit field", + exact: true, + }); + } /** * Actions */ - async addStaticField(type: FieldType, name: string, expectedId: string) { - await this.addFieldButton.click(); + async addStaticField(args: { + type: FieldType; + name: string; + expectedId: string; + }) { + const { type, name, expectedId } = args; + + await this.staticZoneAddFieldButton.click(); await expect(this.addFieldDialog.title).toBeVisible(); await this.addFieldDialog.selectField(type); await this.newFieldNameInput.fill(name); diff --git a/playwright/pages/shared/TypeBuilderPage.ts b/playwright/pages/shared/TypeBuilderPage.ts index acec5af0dc..a5fa92efa9 100644 --- a/playwright/pages/shared/TypeBuilderPage.ts +++ b/playwright/pages/shared/TypeBuilderPage.ts @@ -2,9 +2,14 @@ import { expect, Locator, Page } from "@playwright/test"; import { CreateTypeDialog } from "../components/CreateTypeDialog"; import { RenameTypeDialog } from "../components/RenameTypeDialog"; +import { DeleteTypeDialog } from "../components/DeleteTypeDialog"; import { UseTemplateSlicesDialog } from "../components/UseTemplateSlicesDialog"; import { SelectExistingSlicesDialog } from "../components/SelectExistingSlicesDialog"; import { AddTabDialog } from "../components/AddTabDialog"; +import { RenameTabDialog } from "../components/RenameTabDialog"; +import { DeleteTabDialog } from "../components/DeleteTabDialog"; +import { CreateSliceDialog } from "../components/CreateSliceDialog"; +import { DeleteSliceZoneDialog } from "../components/DeleteSliceZoneDialog"; import { CustomTypesTablePage } from "../CustomTypesTablePage"; import { PageTypesTablePage } from "../PageTypesTablePage"; import { BuilderPage } from "./BuilderPage"; @@ -12,25 +17,34 @@ import { BuilderPage } from "./BuilderPage"; export class TypeBuilderPage extends BuilderPage { readonly createTypeDialog: CreateTypeDialog; readonly renameTypeDialog: RenameTypeDialog; + readonly deleteTypeDialog: DeleteTypeDialog; readonly useTemplateSlicesDialog: UseTemplateSlicesDialog; readonly selectExistingSlicesDialog: SelectExistingSlicesDialog; + readonly createSliceDialog: CreateSliceDialog; readonly addTabDialog: AddTabDialog; + readonly renameTabDialog: RenameTabDialog; + readonly deleteTabDialog: DeleteTabDialog; readonly customTypeTablePage: CustomTypesTablePage; readonly pageTypeTablePage: PageTypesTablePage; + readonly deleteSliceZoneDialog: DeleteSliceZoneDialog; readonly format: "page" | "custom"; - readonly savedMessage: Locator; readonly tab: Locator; readonly tabList: Locator; readonly addTabButton: Locator; - readonly staticZone: Locator; - readonly staticZonePlaceholder: Locator; - readonly staticZoneListItem: Locator; + readonly renameTabButton: Locator; + readonly deleteTabButton: Locator; readonly sliceZoneSwitch: Locator; readonly sliceZoneBlankSlate: Locator; readonly sliceZoneBlankSlateTitle: Locator; - readonly sliceZoneUseTemplateAction: Locator; - readonly sliceZoneSelectExistingAction: Locator; readonly sliceZoneSharedSliceCard: Locator; + readonly sliceZoneBlankSlateUseTemplateAction: Locator; + readonly sliceZoneBlankSlateSelectExistingAction: Locator; + readonly sliceZoneBlankSlateCreateNewAction: Locator; + readonly sliceZoneAddDropdown: Locator; + readonly sliceZoneAddDropdownUseTemplateAction: Locator; + readonly sliceZoneAddDropdownSelectExistingAction: Locator; + readonly sliceZoneAddDropdownCreateNewAction: Locator; + readonly removeSliceButton: Locator; constructor( page: Page, @@ -46,23 +60,22 @@ export class TypeBuilderPage extends BuilderPage { */ this.createTypeDialog = new CreateTypeDialog(page, format); this.renameTypeDialog = new RenameTypeDialog(page, format); + this.deleteTypeDialog = new DeleteTypeDialog(page, format); this.useTemplateSlicesDialog = new UseTemplateSlicesDialog(page); this.selectExistingSlicesDialog = new SelectExistingSlicesDialog(page); + this.createSliceDialog = new CreateSliceDialog(page); this.customTypeTablePage = new CustomTypesTablePage(page); this.pageTypeTablePage = new PageTypesTablePage(page); this.addTabDialog = new AddTabDialog(page); + this.renameTabDialog = new RenameTabDialog(page); + this.deleteTabDialog = new DeleteTabDialog(page); + this.deleteSliceZoneDialog = new DeleteSliceZoneDialog(page); /** * Static locators */ // Global this.format = format; - this.savedMessage = page.getByText( - `${format.charAt(0).toUpperCase()}${format.slice( - 1, - )} type saved successfully`, - { exact: true }, - ); // Tabs this.tabList = page.getByRole("tablist"); this.tab = this.tabList.getByRole("tab"); @@ -70,13 +83,12 @@ export class TypeBuilderPage extends BuilderPage { name: "Add new tab", exact: true, }); - // Static zone - this.staticZone = page.getByTestId("ct-static-zone"); - this.staticZonePlaceholder = this.staticZone.getByText( - "Add a field to your Static Zone", - { exact: true }, - ); - this.staticZoneListItem = this.staticZone.getByRole("listitem"); + this.renameTabButton = page + .getByRole("menu") + .getByText("Rename", { exact: true }); + this.deleteTabButton = page + .getByRole("menu") + .getByText("Remove", { exact: true }); // Slice zone this.sliceZoneSwitch = page.getByTestId("slice-zone-switch"); this.sliceZoneBlankSlate = page.getByTestId("slice-zone-blank-slate"); @@ -86,30 +98,38 @@ export class TypeBuilderPage extends BuilderPage { exact: true, }, ); - this.sliceZoneUseTemplateAction = page.getByText("Use template", { - exact: true, - }); - this.sliceZoneSelectExistingAction = page.getByText("Select existing", { + this.sliceZoneSharedSliceCard = page.getByTestId("shared-slice-card"); + this.sliceZoneBlankSlateUseTemplateAction = + this.sliceZoneBlankSlate.getByText("Use template", { + exact: true, + }); + this.sliceZoneBlankSlateSelectExistingAction = + this.sliceZoneBlankSlate.getByText("Select existing", { + exact: true, + }); + this.sliceZoneBlankSlateCreateNewAction = + this.sliceZoneBlankSlate.getByText("Create new", { + exact: true, + }); + this.sliceZoneAddDropdown = page.getByRole("button", { + name: "Add slices", exact: true, }); - this.sliceZoneSharedSliceCard = page.getByTestId("shared-slice-card"); + this.sliceZoneAddDropdownUseTemplateAction = page + .getByRole("menu") + .getByText("Use template", { exact: true }); + this.sliceZoneAddDropdownSelectExistingAction = page + .getByRole("menu") + .getByText("Select existing", { exact: true }); + this.sliceZoneAddDropdownCreateNewAction = page + .getByRole("menu") + .getByText("Create new", { exact: true }); + this.removeSliceButton = page.getByTestId("remove-slice-button"); } /** * Dynamic locators */ - getStaticZoneListItemFieldName(name: string) { - return this.staticZoneListItem - .getByTestId("field-name") - .getByText(name, { exact: true }); - } - - getStaticZoneListItemFieldId(name: string) { - return this.staticZoneListItem - .getByTestId("field-id") - .getByText(name, { exact: true }); - } - getTab(name: string) { return this.tabList.getByRole("tab", { name, exact: true }); } @@ -118,6 +138,10 @@ export class TypeBuilderPage extends BuilderPage { return this.sliceZoneSharedSliceCard.getByText(name, { exact: false }); } + getTabMenuButton(name: string) { + return this.getTab(name).getByTestId(`tab-${name}-menu-button`); + } + /** * Actions */ @@ -129,6 +153,7 @@ export class TypeBuilderPage extends BuilderPage { await typePage.goto(); await expect(typePage.getRow(name)).toBeVisible(); await typePage.getRow(name).click(); + await expect(this.getBreadcrumbLabel(name)).toBeVisible(); } async openTab(name: string) { @@ -137,14 +162,18 @@ export class TypeBuilderPage extends BuilderPage { await this.checkIfTabIsActive(name); } + async openActionMenu(action: "Rename" | "Remove") { + await this.page + .getByRole("button", { name: "Custom type actions", exact: true }) + .click(); + await this.page + .getByRole("menuitem", { name: action, exact: true }) + .click(); + } + /** * Assertions */ - async checkSavedMessage() { - await expect(this.savedMessage).toBeVisible(); - await expect(this.savedMessage).not.toBeVisible(); - } - async checkIfTabIsActive(name: string) { await expect(this.getTab(name)).toHaveAttribute("aria-selected", "true"); } diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 5ce4124817..30c6104cb6 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -37,10 +37,10 @@ const config = { ], ], - // Whether to report slow test files (> 5min). + // Whether to report slow test files (> 10min). reportSlowTests: { max: 0, - threshold: 300_000, + threshold: 600_000, }, // Directory that will be recursively scanned for test files. diff --git a/playwright/tests/customTypes/customTypesTable.spec.ts b/playwright/tests/customTypes/customTypesTable.spec.ts index 71a31e3dcc..d5b99a207f 100644 --- a/playwright/tests/customTypes/customTypesTable.spec.ts +++ b/playwright/tests/customTypes/customTypesTable.spec.ts @@ -23,10 +23,10 @@ test.run()( await expect(customTypesBuilderPage.staticZoneListItem).toHaveCount(1); await expect( - customTypesBuilderPage.getStaticZoneListItemFieldName("UID"), + customTypesBuilderPage.getStaticZoneListItemFieldId("uid"), ).toBeVisible(); await expect( - customTypesBuilderPage.getStaticZoneListItemFieldId("data.uid"), + customTypesBuilderPage.getStaticZoneListItemFieldName("uid", "UID"), ).toBeVisible(); await expect(customTypesBuilderPage.sliceZoneSwitch).not.toBeChecked(); diff --git a/playwright/tests/pageTypes/pageTypeBuilder.spec.ts b/playwright/tests/pageTypes/pageTypeBuilder.spec.ts deleted file mode 100644 index a31e9ba61b..0000000000 --- a/playwright/tests/pageTypes/pageTypeBuilder.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { expect } from "@playwright/test"; - -import { test } from "../../fixtures"; - -test.run()( - "I can see default SEO & Metadata tab fields", - async ({ pageTypesBuilderPage, reusablePageType }) => { - await pageTypesBuilderPage.goto(reusablePageType.name); - await pageTypesBuilderPage.getTab("SEO & Metadata").click(); - - await expect( - pageTypesBuilderPage.getStaticZoneListItemFieldName("Meta Description"), - ).toBeVisible(); - await expect( - pageTypesBuilderPage.getStaticZoneListItemFieldId( - "data.meta_description", - ), - ).toBeVisible(); - - await expect( - pageTypesBuilderPage.getStaticZoneListItemFieldName("Meta Image"), - ).toBeVisible(); - await expect( - pageTypesBuilderPage.getStaticZoneListItemFieldId("data.meta_image"), - ).toBeVisible(); - - await expect( - pageTypesBuilderPage.getStaticZoneListItemFieldName("Meta Title"), - ).toBeVisible(); - await expect( - pageTypesBuilderPage.getStaticZoneListItemFieldId("data.meta_title"), - ).toBeVisible(); - }, -); - -test.run()( - "I cannot add slices in SEO & Metadata tab by default", - async ({ pageTypesBuilderPage, reusablePageType }) => { - await pageTypesBuilderPage.goto(reusablePageType.name); - await pageTypesBuilderPage.openTab("SEO & Metadata"); - - await expect(pageTypesBuilderPage.sliceZoneSwitch).not.toBeChecked(); - }, -); - -test.run()( - "I cannot add slices in a new tab by default", - async ({ pageTypesBuilderPage, reusablePageType }) => { - await pageTypesBuilderPage.goto(reusablePageType.name); - await pageTypesBuilderPage.addTabButton.click(); - await pageTypesBuilderPage.addTabDialog.createTab("New tab"); - await pageTypesBuilderPage.checkIfTabIsActive("New tab"); - - await expect(pageTypesBuilderPage.sliceZoneSwitch).not.toBeChecked(); - }, -); diff --git a/playwright/tests/pageTypes/pageTypeBuilderCommon.spec.ts b/playwright/tests/pageTypes/pageTypeBuilderCommon.spec.ts new file mode 100644 index 0000000000..980f5c58d8 --- /dev/null +++ b/playwright/tests/pageTypes/pageTypeBuilderCommon.spec.ts @@ -0,0 +1,233 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; + +test.run()( + "I can add a tab", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addTabButton.click(); + await pageTypesBuilderPage.addTabDialog.createTab("New tab"); + + await pageTypesBuilderPage.checkIfTabIsActive("New tab"); + }, +); + +test.run()( + "I can open another tab", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addTabButton.click(); + await pageTypesBuilderPage.addTabDialog.createTab("New tab"); + await pageTypesBuilderPage.openTab("Main"); + + await pageTypesBuilderPage.checkIfTabIsActive("Main"); + }, +); + +test.run()( + "I can rename the active tab", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addTabButton.click(); + await pageTypesBuilderPage.addTabDialog.createTab("New tab"); + await pageTypesBuilderPage.getTabMenuButton("New tab").click(); + await pageTypesBuilderPage.renameTabButton.click(); + await pageTypesBuilderPage.renameTabDialog.renameTab( + "New tab", + "Renamed tab", + ); + + await pageTypesBuilderPage.checkIfTabIsActive("Renamed tab"); + }, +); + +test.run()( + "I can rename another tab", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addTabButton.click(); + await pageTypesBuilderPage.addTabDialog.createTab("New tab"); + await pageTypesBuilderPage.openTab("Main"); + await pageTypesBuilderPage.getTab("New tab").hover(); + await pageTypesBuilderPage.getTabMenuButton("New tab").click(); + await pageTypesBuilderPage.renameTabButton.click(); + await pageTypesBuilderPage.renameTabDialog.renameTab( + "New tab", + "Renamed tab", + ); + + await expect(pageTypesBuilderPage.getTab("Renamed tab")).toBeVisible(); + }, +); + +test.run()( + "I can delete the current tab", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addTabButton.click(); + await pageTypesBuilderPage.addTabDialog.createTab("New tab"); + await pageTypesBuilderPage.getTabMenuButton("New tab").click(); + await pageTypesBuilderPage.deleteTabButton.click(); + await pageTypesBuilderPage.deleteTabDialog.deleteTab(); + + await pageTypesBuilderPage.checkIfTabIsActive("Main"); + await expect(pageTypesBuilderPage.getTab("New tab")).not.toBeVisible(); + }, +); + +test.run()( + "I can delete another tab", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addTabButton.click(); + await pageTypesBuilderPage.addTabDialog.createTab("New tab"); + await pageTypesBuilderPage.openTab("Main"); + await pageTypesBuilderPage.getTab("New tab").hover(); + await pageTypesBuilderPage.getTabMenuButton("New tab").click(); + await pageTypesBuilderPage.deleteTabButton.click(); + await pageTypesBuilderPage.deleteTabDialog.deleteTab(); + + await pageTypesBuilderPage.checkIfTabIsActive("Main"); + await expect(pageTypesBuilderPage.getTab("New tab")).not.toBeVisible(); + }, +); + +test.run()( + "I cannot delete the last tab", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.getTab("SEO & Metadata").hover(); + await pageTypesBuilderPage.getTabMenuButton("SEO & Metadata").click(); + await pageTypesBuilderPage.deleteTabButton.click(); + await pageTypesBuilderPage.deleteTabDialog.deleteTab(); + await pageTypesBuilderPage.getTabMenuButton("Main").click(); + await expect(pageTypesBuilderPage.deleteTabButton).toBeDisabled(); + }, +); + +test.run()( + "I can see my changes auto-saved", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addStaticField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + }); + + await expect(pageTypesBuilderPage.autoSaveStatusSaved).toBeVisible(); + await pageTypesBuilderPage.page.reload(); + await expect(pageTypesBuilderPage.autoSaveStatusSaved).toBeVisible(); + }, +); + +test.run()( + "I can see my changes being saved", + async ({ pageTypesBuilderPage, reusablePageType, procedures }) => { + procedures.mock("customTypes.updateCustomType", ({ data }) => data, { + delay: 2000, + }); + + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addStaticField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + }); + + await expect(pageTypesBuilderPage.autoSaveStatusSaving).toBeVisible(); + await expect(pageTypesBuilderPage.autoSaveStatusSaved).toBeVisible(); + await pageTypesBuilderPage.page.reload(); + await expect(pageTypesBuilderPage.autoSaveStatusSaved).toBeVisible(); + }, +); + +test.run()( + "I can see that my changes failed to save and I can retry", + async ({ pageTypesBuilderPage, reusablePageType, procedures }) => { + procedures.mock("customTypes.updateCustomType", () => ({ errors: [{}] }), { + execute: false, + times: 1, + }); + + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addStaticField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + }); + + await expect(pageTypesBuilderPage.autoSaveStatusError).toBeVisible(); + await pageTypesBuilderPage.autoSaveRetryButton.click(); + await expect(pageTypesBuilderPage.autoSaveStatusSaving).toBeVisible(); + await expect(pageTypesBuilderPage.autoSaveStatusSaved).toBeVisible(); + await pageTypesBuilderPage.page.reload(); + await expect(pageTypesBuilderPage.autoSaveStatusSaved).toBeVisible(); + }, +); + +test.run()( + "I cannot see a save happening when I first load the page", + async ({ pageTypesBuilderPage, reusablePageType, procedures }) => { + procedures.mock("customTypes.updateCustomType", ({ data }) => data, { + delay: 2000, + }); + + await pageTypesBuilderPage.goto(reusablePageType.name); + await expect(pageTypesBuilderPage.autoSaveStatusSaving).not.toBeVisible({ + // As soon as it's visible it's a problem + timeout: 1, + }); + }, +); + +test.run()( + "I can rename the custom type", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.openActionMenu("Rename"); + const newPageTypeName = `${reusablePageType.name}Renamed`; + await pageTypesBuilderPage.renameTypeDialog.renameType( + newPageTypeName, + "builder", + ); + + await expect( + pageTypesBuilderPage.getBreadcrumbLabel(newPageTypeName), + ).toBeVisible(); + }, +); + +test.run()( + "I can delete the custom type", + async ({ pageTypesBuilderPage, reusablePageType, pageTypesTablePage }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.openActionMenu("Remove"); + await pageTypesBuilderPage.deleteTypeDialog.deleteType(); + + await expect(pageTypesTablePage.breadcrumbLabel).toBeVisible(); + await expect( + pageTypesTablePage.getRow(reusablePageType.name), + ).not.toBeVisible(); + }, +); + +test.run()( + "I can see the page code snippet and copy the content", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.pageSnippetButton.click(); + await pageTypesBuilderPage.pageSnippetDialog.copyPageSnippet(); + + await expect( + pageTypesBuilderPage.pageSnippetDialog.copiedIconButton, + ).toBeVisible(); + await expect( + pageTypesBuilderPage.pageSnippetDialog.copiedIconButton, + ).not.toBeVisible(); + await expect( + pageTypesBuilderPage.pageSnippetDialog.copyIconButton, + ).toBeVisible(); + }, +); diff --git a/playwright/tests/pageTypes/pageTypeBuilderFields.spec.ts b/playwright/tests/pageTypes/pageTypeBuilderFields.spec.ts new file mode 100644 index 0000000000..dbc2b66ade --- /dev/null +++ b/playwright/tests/pageTypes/pageTypeBuilderFields.spec.ts @@ -0,0 +1,128 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; + +test.run()( + "I can see default SEO & Metadata tab fields", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.getTab("SEO & Metadata").click(); + + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldId("meta_description"), + ).toBeVisible(); + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldName( + "meta_description", + "Meta Description", + ), + ).toBeVisible(); + + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldId("meta_image"), + ).toBeVisible(); + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldName( + "meta_image", + "Meta Image", + ), + ).toBeVisible(); + + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldId("meta_title"), + ).toBeVisible(); + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldName( + "meta_title", + "Meta Title", + ), + ).toBeVisible(); + }, +); + +test.run()( + "I can add a rich text field", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addStaticField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + }); + + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldId("my_rich_text"), + ).toBeVisible(); + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldName( + "my_rich_text", + "My Rich Text", + ), + ).toBeVisible(); + }, +); + +test.run()( + "I can edit a rich text field", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addStaticField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + }); + + await pageTypesBuilderPage.getEditFieldButton("my_rich_text").click(); + await pageTypesBuilderPage.editFieldDialog.editField({ + name: "My Rich Text", + newName: "My Rich Text Renamed", + newId: "my_rich_text_renamed", + }); + + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldId("my_rich_text_renamed"), + ).toBeVisible(); + await expect( + pageTypesBuilderPage.getStaticZoneListItemFieldName( + "my_rich_text_renamed", + "My Rich Text Renamed", + ), + ).toBeVisible(); + }, +); + +test.run()("I can delete a field", async () => { + // TODO: add test +}); + +test.run()("I can reorder fields", async () => { + // TODO: add test +}); + +test.run()("I can add a group field", async () => { + // TODO: add test +}); + +test.run()("I can edit a group field", async () => { + // TODO: add test +}); + +test.run()("I can delete a group field", async () => { + // TODO: add test +}); + +test.run()("I can add a sub field within a group field", async () => { + // TODO: add test +}); + +test.run()("I can reorder fields within a group fields", async () => { + // TODO: add test +}); + +test.run()("I can see the code snippets", async () => { + // TODO: add test +}); + +test.run()("I cannot remove default UID field", async () => { + // TODO: add test +}); diff --git a/playwright/tests/pageTypes/pageTypeBuilderSliceZone.spec.ts b/playwright/tests/pageTypes/pageTypeBuilderSliceZone.spec.ts new file mode 100644 index 0000000000..53085267d4 --- /dev/null +++ b/playwright/tests/pageTypes/pageTypeBuilderSliceZone.spec.ts @@ -0,0 +1,187 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; +import { generateRandomId } from "../../utils/generateRandomId"; + +test.run()( + "I can enable or disable the slice zone", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.addTabButton.click(); + await pageTypesBuilderPage.addTabDialog.createTab("New tab"); + + await expect(pageTypesBuilderPage.sliceZoneSwitch).not.toBeChecked(); + await pageTypesBuilderPage.sliceZoneSwitch.click(); + await expect(pageTypesBuilderPage.sliceZoneSwitch).toBeChecked(); + + await pageTypesBuilderPage.sliceZoneSwitch.click(); + await pageTypesBuilderPage.deleteSliceZoneDialog.deleteSliceZone(); + await expect(pageTypesBuilderPage.sliceZoneSwitch).not.toBeChecked(); + }, +); + +test.run()( + "I cannot disable the slice zone on the Main tab", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.checkIfTabIsActive("Main"); + await expect(pageTypesBuilderPage.sliceZoneSwitch).not.toBeVisible(); + }, +); + +test.run()( + "I cannot add slices in SEO & Metadata tab by default", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.openTab("SEO & Metadata"); + + await expect(pageTypesBuilderPage.sliceZoneSwitch).not.toBeChecked(); + }, +); + +test.run()( + "I cannot add slices in a new tab by default", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + await pageTypesBuilderPage.addTabButton.click(); + await pageTypesBuilderPage.addTabDialog.createTab("New tab"); + await pageTypesBuilderPage.checkIfTabIsActive("New tab"); + + await expect(pageTypesBuilderPage.sliceZoneSwitch).not.toBeChecked(); + }, +); + +test.run()( + "I can add slices with a template from the add dropdown", + async ({ pageTypesBuilderPage, reusablePageType }) => { + const sliceTemplates = [ + "Hero", + "CustomerLogos", + "AlternateGrid", + "CallToAction", + ]; + + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.sliceZoneAddDropdown.click(); + await pageTypesBuilderPage.sliceZoneAddDropdownUseTemplateAction.click(); + await pageTypesBuilderPage.useTemplateSlicesDialog.useTemplates( + sliceTemplates, + ); + + for (const slice of sliceTemplates) { + await expect( + pageTypesBuilderPage.getSliceZoneSharedSliceCard(slice), + ).toBeVisible(); + } + }, +); + +test.run()( + "I can add a slice with an existing slice from the add dropdown", + async ({ pageTypesBuilderPage, reusablePageType, slice }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.sliceZoneAddDropdown.click(); + await pageTypesBuilderPage.sliceZoneAddDropdownSelectExistingAction.click(); + await pageTypesBuilderPage.selectExistingSlicesDialog.selectExistingSlices([ + slice.name, + ]); + + await expect( + pageTypesBuilderPage.getSliceZoneSharedSliceCard(slice.name), + ).toBeVisible(); + }, +); + +test.run()( + "I can add a slice by creating a new slice from the add dropdown", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.sliceZoneAddDropdown.click(); + await pageTypesBuilderPage.sliceZoneAddDropdownCreateNewAction.click(); + const name = "NewSlice" + generateRandomId(); + await pageTypesBuilderPage.createSliceDialog.createSlice(name, "sliceZone"); + + await expect( + pageTypesBuilderPage.getSliceZoneSharedSliceCard(name), + ).toBeVisible(); + }, +); + +test.run()( + "I can add slices with a template from the blank slate", + async ({ pageTypesBuilderPage, reusablePageType }) => { + const sliceTemplates = [ + "Hero", + "CustomerLogos", + "AlternateGrid", + "CallToAction", + ]; + + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.sliceZoneBlankSlateUseTemplateAction.click(); + await pageTypesBuilderPage.useTemplateSlicesDialog.useTemplates( + sliceTemplates, + ); + + for (const slice of sliceTemplates) { + await expect( + pageTypesBuilderPage.getSliceZoneSharedSliceCard(slice), + ).toBeVisible(); + } + }, +); + +test.run()( + "I can add a slice with an existing slice from the blank slate", + async ({ pageTypesBuilderPage, reusablePageType, slice }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.sliceZoneBlankSlateSelectExistingAction.click(); + await pageTypesBuilderPage.selectExistingSlicesDialog.selectExistingSlices([ + slice.name, + ]); + + await expect( + pageTypesBuilderPage.getSliceZoneSharedSliceCard(slice.name), + ).toBeVisible(); + }, +); + +test.run()( + "I can add a slice by creating a new slice the blank slate", + async ({ pageTypesBuilderPage, reusablePageType }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.sliceZoneBlankSlateCreateNewAction.click(); + const name = "NewSlice" + generateRandomId(); + await pageTypesBuilderPage.createSliceDialog.createSlice(name, "sliceZone"); + + await expect( + pageTypesBuilderPage.getSliceZoneSharedSliceCard(name), + ).toBeVisible(); + }, +); + +test.run()( + "I can remove a slice from the slice zone", + async ({ pageTypesBuilderPage, reusablePageType, slice }) => { + await pageTypesBuilderPage.goto(reusablePageType.name); + + await pageTypesBuilderPage.sliceZoneAddDropdown.click(); + await pageTypesBuilderPage.sliceZoneAddDropdownSelectExistingAction.click(); + await pageTypesBuilderPage.selectExistingSlicesDialog.selectExistingSlices([ + slice.name, + ]); + await pageTypesBuilderPage.removeSliceButton.click(); + + await expect( + pageTypesBuilderPage.getSliceZoneSharedSliceCard(slice.name), + ).not.toBeVisible(); + }, +); diff --git a/playwright/tests/pageTypes/pageTypesTable.spec.ts b/playwright/tests/pageTypes/pageTypesTable.spec.ts index 6e02bf1bb2..1868e389d3 100644 --- a/playwright/tests/pageTypes/pageTypesTable.spec.ts +++ b/playwright/tests/pageTypes/pageTypesTable.spec.ts @@ -17,7 +17,7 @@ test.run()( await pageTypesBuilderPage.goto(name); await expect(pageTypesBuilderPage.sliceZoneBlankSlateTitle).toBeVisible(); - await pageTypesBuilderPage.sliceZoneUseTemplateAction.click(); + await pageTypesBuilderPage.sliceZoneBlankSlateUseTemplateAction.click(); await pageTypesBuilderPage.useTemplateSlicesDialog.useTemplates([ "Hero", "CustomerLogos", @@ -40,10 +40,10 @@ test.run()( await expect(pageTypesBuilderPage.staticZoneListItem).toHaveCount(1); await expect( - pageTypesBuilderPage.getStaticZoneListItemFieldName("UID"), + pageTypesBuilderPage.getStaticZoneListItemFieldId("uid"), ).toBeVisible(); await expect( - pageTypesBuilderPage.getStaticZoneListItemFieldId("data.uid"), + pageTypesBuilderPage.getStaticZoneListItemFieldName("uid", "UID"), ).toBeVisible(); await expect(pageTypesBuilderPage.sliceZoneSwitch).not.toBeVisible(); @@ -64,7 +64,7 @@ test.run()( await pageTypesBuilderPage.goto(name); await expect(pageTypesBuilderPage.sliceZoneBlankSlateTitle).toBeVisible(); - await pageTypesBuilderPage.sliceZoneUseTemplateAction.click(); + await pageTypesBuilderPage.sliceZoneBlankSlateUseTemplateAction.click(); await pageTypesBuilderPage.useTemplateSlicesDialog.useTemplates([ "AlternateGrid", "CallToAction", diff --git a/playwright/tests/slices/sliceBuilder.spec.ts b/playwright/tests/slices/sliceBuilder.spec.ts index 1e48333177..66d86aef6e 100644 --- a/playwright/tests/slices/sliceBuilder.spec.ts +++ b/playwright/tests/slices/sliceBuilder.spec.ts @@ -66,11 +66,11 @@ test.run()( await expect(sliceBuilderPage.staticZoneListItem).toHaveCount(0); - await sliceBuilderPage.addStaticField( - "Rich Text", - "Description", - "description", - ); + await sliceBuilderPage.addStaticField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + }); await expect(sliceBuilderPage.staticZoneListItem).toHaveCount(1); }, diff --git a/playwright/utils/MockManagerProcedures.ts b/playwright/utils/MockManagerProcedures.ts index bf464bedaf..9837ff7f3a 100644 --- a/playwright/utils/MockManagerProcedures.ts +++ b/playwright/utils/MockManagerProcedures.ts @@ -12,8 +12,20 @@ type MockManagerProcedureHandler = (args: { }) => unknown | Promise; type MockManagerProcedureConfig = { + /** + * Whether to execute the procedure or not. Defaults to true. + */ execute?: boolean; + + /** + * Number of times the procedure should be executed. Defaults to unlimited. + */ times?: number; + + /** + * Delay in milliseconds before returning the data. + */ + delay?: number; }; const FORCE_INIT = Symbol(); @@ -112,8 +124,23 @@ export class MockManagerProcedures { } } - await route.fulfill({ - body: Buffer.from(encode(newBodyContents)), - }); + const newBody = Buffer.from(encode(newBodyContents)); + + if (procedure.config.delay) { + await new Promise((resolve, reject) => + setTimeout(() => { + route + .fulfill({ + body: newBody, + }) + .then(resolve) + .catch(reject); + }, procedure.config.delay), + ); + } else { + await route.fulfill({ + body: newBody, + }); + } } }