diff --git a/packages/slice-machine/components/DeleteVariationModal/index.tsx b/packages/slice-machine/components/DeleteVariationModal/index.tsx index 67bf8b23f7..d108c58bbb 100644 --- a/packages/slice-machine/components/DeleteVariationModal/index.tsx +++ b/packages/slice-machine/components/DeleteVariationModal/index.tsx @@ -7,21 +7,19 @@ import { Text, } from "@prismicio/editor-ui"; import { useRouter } from "next/router"; -import type { Dispatch, FC, PropsWithChildren, SetStateAction } from "react"; +import { useState, type FC, type PropsWithChildren } from "react"; -import type { SliceBuilderState } from "@builders/SliceBuilder"; import type { ComponentUI } from "@lib/models/common/ComponentUI"; import type { VariationSM } from "@lib/models/common/Slice"; import { deleteVariation } from "@src/features/slices/sliceBuilder/actions/deleteVariation"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; +import { useSliceState } from "@src/features/slices/sliceBuilder/SliceBuilderProvider"; type DeleteVariationModalProps = { isOpen: boolean; onClose: () => void; slice: ComponentUI; variation: VariationSM | undefined; - sliceBuilderState: SliceBuilderState; - setSliceBuilderState: Dispatch>; }; export const DeleteVariationModal: FC = ({ @@ -29,11 +27,12 @@ export const DeleteVariationModal: FC = ({ onClose, slice, variation, - sliceBuilderState, - setSliceBuilderState, }) => { const router = useRouter(); - const { updateAndSaveSlice } = useSliceMachineActions(); + const [isDeleting, setIsDeleting] = useState(false); + const { saveSliceSuccess } = useSliceMachineActions(); + const { setSlice } = useSliceState(); + return ( = ({ onClick: () => { if (!variation) return; void (async () => { + setIsDeleting(true); try { - await deleteVariation({ + const newSlice = await deleteVariation({ component: slice, router, - setSliceBuilderState, - updateAndSaveSlice, + saveSliceSuccess, variation, }); + setSlice(newSlice); } catch {} + setIsDeleting(false); onClose(); })(); }, - loading: sliceBuilderState.loading, + loading: isDeleting, }} cancel={{ text: "Cancel" }} size="medium" diff --git a/packages/slice-machine/components/Forms/RenameVariationModal/RenameVariationModal.tsx b/packages/slice-machine/components/Forms/RenameVariationModal/RenameVariationModal.tsx index 3eb2d454bf..48c7afdb7a 100644 --- a/packages/slice-machine/components/Forms/RenameVariationModal/RenameVariationModal.tsx +++ b/packages/slice-machine/components/Forms/RenameVariationModal/RenameVariationModal.tsx @@ -8,23 +8,21 @@ import { Text, } from "@prismicio/editor-ui"; import { Formik } from "formik"; -import type { Dispatch, FC, SetStateAction } from "react"; +import { useState, type FC } from "react"; -import type { SliceBuilderState } from "@builders/SliceBuilder"; import type { ComponentUI } from "@lib/models/common/ComponentUI"; import type { VariationSM } from "@lib/models/common/Slice"; import { renameVariation } from "@src/features/slices/sliceBuilder/actions/renameVariation"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import * as styles from "./RenameVariationModal.css"; +import { useSliceState } from "@src/features/slices/sliceBuilder/SliceBuilderProvider"; type RenameVariationModalProps = { isOpen: boolean; onClose: () => void; slice: ComponentUI; variation: VariationSM | undefined; - sliceBuilderState: SliceBuilderState; - setSliceBuilderState: Dispatch>; }; export const RenameVariationModal: FC = ({ @@ -32,10 +30,11 @@ export const RenameVariationModal: FC = ({ onClose, slice, variation, - sliceBuilderState, - setSliceBuilderState, }) => { - const { updateAndSaveSlice } = useSliceMachineActions(); + const [isRenaming, setRenaming] = useState(false); + const { setSlice } = useSliceState(); + const { saveSliceSuccess } = useSliceMachineActions(); + return ( <> = ({ }} onSubmit={async (values) => { if (!variation) return; + setRenaming(true); try { - await renameVariation({ + const newSlice = await renameVariation({ component: slice, - setSliceBuilderState, - updateAndSaveSlice, + saveSliceSuccess, variation, variationName: values.variationName.trim(), }); + setSlice(newSlice); } catch {} + setRenaming(false); onClose(); }} > @@ -102,7 +103,7 @@ export const RenameVariationModal: FC = ({ ok={{ text: "Rename", onClick: () => void formik.submitForm(), - loading: sliceBuilderState.loading, + loading: isRenaming, disabled: !formik.isValid, }} cancel={{ text: "Cancel" }} diff --git a/packages/slice-machine/components/ScreenshotChangesModal/VariationDropZone.tsx b/packages/slice-machine/components/ScreenshotChangesModal/VariationDropZone.tsx index fd6992aa2f..02dbde5485 100644 --- a/packages/slice-machine/components/ScreenshotChangesModal/VariationDropZone.tsx +++ b/packages/slice-machine/components/ScreenshotChangesModal/VariationDropZone.tsx @@ -14,11 +14,13 @@ import { isLoading } from "@src/modules/loading"; import { LoadingKeysEnum } from "@src/modules/loading/types"; import { ScreenshotPreview } from "@components/ScreenshotPreview"; import { ComponentUI } from "@lib/models/common/ComponentUI"; +import { uploadSliceScreenshot } from "@src/features/slices/actions/uploadSliceScreenshot"; interface DropZoneProps { imageTypes?: string[]; variationID: string; slice: ComponentUI; + onUploadSuccess?: (newSlice: ComponentUI) => void; } const DragActiveView = () => { @@ -79,6 +81,7 @@ const DropZone: React.FC = ({ variationID, slice, imageTypes = acceptedImagesTypes, + onUploadSuccess, }) => { const maybeScreenshot = slice.screenshots[variationID]; @@ -90,7 +93,7 @@ const DropZone: React.FC = ({ slice, ]); - const { generateSliceCustomScreenshot } = useSliceMachineActions(); + const { saveSliceCustomScreenshotSuccess } = useSliceMachineActions(); const { isLoadingScreenshot } = useSelector( (state: SliceMachineStoreType) => ({ @@ -102,25 +105,44 @@ const DropZone: React.FC = ({ ); const { FileInputRenderer, fileInputProps } = useCustomScreenshot({ - onHandleFile: (file: File, isDragActive: boolean) => { - generateSliceCustomScreenshot( - variationID, + onHandleFile: async (file: File, isDragActive: boolean) => { + const newSlice = await uploadSliceScreenshot({ slice, file, - isDragActive ? "dragAndDrop" : "upload", - ); + method: isDragActive ? "dragAndDrop" : "upload", + variationId: variationID, + }); setIsHover(false); + + const screenshot = newSlice?.screenshots[variationID]; + if (screenshot) { + // Sync with redux store + saveSliceCustomScreenshotSuccess(variationID, screenshot, newSlice); + onUploadSuccess && onUploadSuccess(newSlice); + } }, }); - const handleFile = (file: File) => { + const handleFile = async (file: File) => { if (file.size > 128000000) { return openToaster( "File is too big. Max file size: 128Mb.", ToasterType.ERROR, ); } - generateSliceCustomScreenshot(variationID, slice, file, "dragAndDrop"); + const newSlice = await uploadSliceScreenshot({ + slice, + file, + method: "dragAndDrop", + variationId: variationID, + }); + + const screenshot = newSlice?.screenshots[variationID]; + if (screenshot) { + // Sync with redux store + saveSliceCustomScreenshotSuccess(variationID, screenshot, newSlice); + onUploadSuccess && onUploadSuccess(newSlice); + } }; const supportsClipboardRead = typeof navigator.clipboard.read === "function"; @@ -149,7 +171,7 @@ const DropZone: React.FC = ({ const maybeFile = event.dataTransfer.files?.[0]; if (maybeFile !== undefined) { if (imageTypes.some((t) => `image/${t}` === maybeFile.type)) { - return handleFile(maybeFile); + return void handleFile(maybeFile); } return openToaster( `Only files of type ${imageTypes.join(", ")} are accepted.`, diff --git a/packages/slice-machine/components/ScreenshotChangesModal/index.tsx b/packages/slice-machine/components/ScreenshotChangesModal/index.tsx index 7221564965..b4edcdc432 100644 --- a/packages/slice-machine/components/ScreenshotChangesModal/index.tsx +++ b/packages/slice-machine/components/ScreenshotChangesModal/index.tsx @@ -171,9 +171,11 @@ const variationSetter = ( const ScreenshotChangesModal = ({ slices, defaultVariationSelector, + onUploadSuccess, }: { slices: ComponentUI[]; defaultVariationSelector?: SliceVariationSelector; + onUploadSuccess?: (newSlice: ComponentUI) => void; }) => { const { closeModals } = useSliceMachineActions(); @@ -275,6 +277,7 @@ const ScreenshotChangesModal = ({ ) : null; })()} diff --git a/packages/slice-machine/components/ScreenshotChangesModal/useCustomScreenshot.tsx b/packages/slice-machine/components/ScreenshotChangesModal/useCustomScreenshot.tsx index 108cb37e18..cf83bba9a9 100644 --- a/packages/slice-machine/components/ScreenshotChangesModal/useCustomScreenshot.tsx +++ b/packages/slice-machine/components/ScreenshotChangesModal/useCustomScreenshot.tsx @@ -6,11 +6,11 @@ import { acceptedImagesTypes } from "@lib/consts"; type HandleFileProp = { inputFile: React.RefObject; children?: React.ReactNode; - handleFile: (file: File | undefined, isDragActive: boolean) => void; + handleFile: (file: File | undefined, isDragActive: boolean) => Promise; isDragActive: boolean; }; type CustomScreenshotProps = { - onHandleFile: (file: File, isDragActive: boolean) => void; + onHandleFile: (file: File, isDragActive: boolean) => Promise; }; const FileInputRenderer: React.FC = ({ @@ -43,7 +43,7 @@ const FileInputRenderer: React.FC = ({ style={{ display: "none" }} accept={acceptedImagesTypes.map((type) => `image/${type}`).join(",")} onChange={(e: React.ChangeEvent) => { - handleFile(e.target.files?.[0], isDragActive); + void handleFile(e.target.files?.[0], isDragActive); }} /> @@ -63,9 +63,9 @@ export default function useCustomScreenshot({ onHandleFile, }: CustomScreenshotProps): CustomScreenshotPayload { const inputFile = useRef(null); - const handleFile = (file: File | undefined, isDragActive: boolean) => { + const handleFile = async (file: File | undefined, isDragActive: boolean) => { if (file) { - onHandleFile(file, isDragActive); + await onHandleFile(file, isDragActive); if (inputFile?.current) { inputFile.current.value = ""; } diff --git a/packages/slice-machine/components/Simulator/index.tsx b/packages/slice-machine/components/Simulator/index.tsx index a69e9e615e..f2736badfc 100644 --- a/packages/slice-machine/components/Simulator/index.tsx +++ b/packages/slice-machine/components/Simulator/index.tsx @@ -1,4 +1,5 @@ import { + FC, Suspense, useCallback, useEffect, @@ -34,7 +35,6 @@ import useThrottle from "@src/hooks/useThrottle"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import IframeRenderer from "./components/IframeRenderer"; -import { ComponentWithSliceProps } from "@src/layouts/WithSlice"; import { selectIframeStatus, selectIsWaitingForIFrameCheck, @@ -44,7 +44,8 @@ import { import FullPage from "./components/FullPage"; import FailedConnect from "./components/FailedConnect"; import SetupModal from "./components/SetupModal"; -import { Slices } from "@lib/models/common/Slice"; +import { Slices, VariationSM } from "@lib/models/common/Slice"; +import { ComponentUI } from "@lib/models/common/ComponentUI"; export enum UiState { LOADING_SETUP = "LOADING_SETUP", @@ -54,7 +55,12 @@ export enum UiState { SUCCESS = "SUCCESS", } -const Simulator: ComponentWithSliceProps = ({ slice, variation }) => { +type SimulatorProps = { + slice: ComponentUI; + variation: VariationSM; +}; + +const Simulator: FC = ({ slice, variation }) => { const { checkSimulatorSetup, connectToSimulatorIframe, saveSliceMock } = useSliceMachineActions(); const { diff --git a/packages/slice-machine/lib/builders/SliceBuilder/FieldZones/index.tsx b/packages/slice-machine/lib/builders/SliceBuilder/FieldZones/index.tsx index 243dbe8264..1393910e03 100644 --- a/packages/slice-machine/lib/builders/SliceBuilder/FieldZones/index.tsx +++ b/packages/slice-machine/lib/builders/SliceBuilder/FieldZones/index.tsx @@ -1,18 +1,24 @@ +import { FC } from "react"; +import { flushSync } from "react-dom"; +import { DropResult } from "react-beautiful-dnd"; +import { NestableWidget } from "@prismicio/types-internal/lib/customtypes"; + import { ensureDnDDestination } from "@lib/utils"; import { transformKeyAccessor } from "@utils/str"; - -import Zone from "../../common/Zone"; -import EditModal from "../../common/EditModal"; - import * as Widgets from "@lib/models/common/widgets"; import sliceBuilderWidgetsArray from "@lib/models/common/widgets/sliceBuilderArray"; - -import { DropResult } from "react-beautiful-dnd"; import { List } from "@src/components/List"; -import useSliceMachineActions from "@src/modules/useSliceMachineActions"; -import { VariationSM, WidgetsArea } from "@lib/models/common/Slice"; - -import { NestableWidget } from "@prismicio/types-internal/lib/customtypes"; +import { WidgetsArea } from "@lib/models/common/Slice"; +import { useSliceState } from "@src/features/slices/sliceBuilder/SliceBuilderProvider"; +import { + addField, + deleteField, + reorderField, + updateField, +} from "@src/domain/slice"; +import Zone from "@lib/builders/common/Zone"; +import EditModal from "@lib/builders/common/EditModal"; +import { AnyWidget } from "@lib/models/common/widgets/Widget"; const dataTipText = ` The non-repeatable zone is for fields
that should appear once, like a
@@ -22,24 +28,18 @@ const dataTipText2 = `The repeatable zone is for a group
of fields that you want to be able to repeat an
indeterminate number of times, like FAQs`; -type FieldZonesProps = { - variation: VariationSM; -}; +const FieldZones: FC = () => { + const { slice, setSlice, variation } = useSliceState(); -const FieldZones: React.FunctionComponent = ({ - variation, -}) => { - const { - addSliceWidget, - replaceSliceWidget, - reorderSliceWidget, - removeSliceWidget, - updateSliceWidgetMock, - deleteSliceWidgetMock, - } = useSliceMachineActions(); const _onDeleteItem = (widgetArea: WidgetsArea) => (key: string) => { - deleteSliceWidgetMock(variation.id, widgetArea, key); - removeSliceWidget(variation.id, widgetArea, key); + const newSlice = deleteField({ + slice, + variationId: variation.id, + widgetArea, + fieldId: key, + }); + + setSlice(newSlice); }; const _onSave = @@ -48,30 +48,23 @@ const FieldZones: React.FunctionComponent = ({ apiId: previousKey, newKey, value, - mockValue, }: { apiId: string; newKey: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockValue: any; }) => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (mockValue) { - updateSliceWidgetMock( - variation.id, - widgetArea, - previousKey, - newKey, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - mockValue, - ); - } else { - deleteSliceWidgetMock(variation.id, widgetArea, newKey); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - replaceSliceWidget(variation.id, widgetArea, previousKey, newKey, value); + const newSlice = updateField({ + slice, + variationId: variation.id, + widgetArea, + previousFieldId: previousKey, + newFieldId: newKey, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + newField: value, + }); + + setSlice(newSlice); }; const _onSaveNewField = @@ -96,24 +89,56 @@ const FieldZones: React.FunctionComponent = ({ ); } - addSliceWidget( - variation.id, - widgetArea, - id, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const newField = widget.create(label) as NestableWidget; + + if ( + newField.type === "Range" || + newField.type === "IntegrationFields" || + newField.type === "Separator" + ) { + throw new Error(`Unsupported Field Type: ${newField.type}`); + } + + try { + const CurrentWidget: AnyWidget = Widgets[newField.type]; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - widget.create(label) as NestableWidget, - ); + CurrentWidget.schema.validateSync(newField, { stripUnknown: false }); + } catch (error) { + throw new Error(`Model is invalid for widget "${newField.type}".`); + } + + const newSlice = addField({ + slice, + variationId: variation.id, + widgetArea, + newFieldId: id, + newField, + }); + + setSlice(newSlice); }; const _onDragEnd = (widgetArea: WidgetsArea) => (result: DropResult) => { if (ensureDnDDestination(result)) return; - reorderSliceWidget( - variation.id, + const { source, destination } = result; + if (!destination) { + return; + } + + const newSlice = reorderField({ + slice, + variationId: variation.id, widgetArea, - result.source.index, - result.destination?.index ?? undefined, - ); + sourceIndex: source.index, + destinationIndex: destination.index, + }); + + // When removing redux and replacing it by a simple useState, react-beautiful-dnd (that is deprecated library) was making the fields flickering on reorder. + // The problem seems to come from the react non-synchronous way to handle our state update that didn't work well with the library. + // It's a hack and since it's used on an old pure JavaScript code with a deprecated library it will be removed when updating the UI of the fields. + flushSync(() => setSlice(newSlice)); }; return ( diff --git a/packages/slice-machine/lib/builders/SliceBuilder/Sidebar/AddVariationModal.tsx b/packages/slice-machine/lib/builders/SliceBuilder/Sidebar/AddVariationModal.tsx index 6f571e7dc3..19cb62b4df 100644 --- a/packages/slice-machine/lib/builders/SliceBuilder/Sidebar/AddVariationModal.tsx +++ b/packages/slice-machine/lib/builders/SliceBuilder/Sidebar/AddVariationModal.tsx @@ -1,13 +1,13 @@ -import { Variation } from "@models/common/Variation"; -import { VariationSM } from "@lib/models/common/Slice"; import React, { useEffect, useState } from "react"; +import Select from "react-select"; import { Formik, Form, Field } from "formik"; -import SliceMachineModal from "@components/SliceMachineModal"; +import { Button, Box } from "@prismicio/editor-ui"; +import { Text, Label, Input } from "theme-ui"; +import { camelCase } from "lodash"; +import SliceMachineModal from "@components/SliceMachineModal"; import Card from "@components/Card/Default"; -import Select from "react-select"; - -import { Text, Box, Button, Label, Input, Flex } from "theme-ui"; +import { VariationSM } from "@lib/models/common/Slice"; const Error = ({ msg }: { msg?: string }) => ( @@ -17,9 +17,14 @@ const Error = ({ msg }: { msg?: string }) => ( ); const AddVariationModal: React.FunctionComponent<{ isOpen: boolean; + loading?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any onClose: () => any; - onSubmit: (id: string, name: string, copiedVariation: VariationSM) => void; + onSubmit: ( + id: string, + name: string, + copiedVariation: VariationSM, + ) => Promise; initialVariation: VariationSM; variations: ReadonlyArray; }> = ({ isOpen, onClose, onSubmit, initialVariation, variations }) => { @@ -31,6 +36,7 @@ const AddVariationModal: React.FunctionComponent<{ value: initialVariation.id, label: initialVariation.name, }); + const [isAddingVariation, setIsAddingVariation] = useState(false); function validateForm({ id, @@ -70,7 +76,7 @@ const AddVariationModal: React.FunctionComponent<{ } function generateId(str: string) { - const slug = Variation.generateId(str); + const slug = camelCase(str); setGeneratedId(slug); } @@ -110,7 +116,9 @@ const AddVariationModal: React.FunctionComponent<{ else { const copiedVariation = variations.find((v) => v.id === origin.value); if (copiedVariation) { - onSubmit(generatedId, name, copiedVariation); + setIsAddingVariation(true); + await onSubmit(generatedId, name, copiedVariation); + setIsAddingVariation(false); handleClose(); } } @@ -148,16 +156,16 @@ const AddVariationModal: React.FunctionComponent<{ sx={{ textAlign: "left" }} HeaderContent={Add new Variation} FooterContent={ - - - - + + } close={handleClose} > - + - + @@ -201,7 +209,7 @@ const AddVariationModal: React.FunctionComponent<{ - + diff --git a/packages/slice-machine/lib/builders/SliceBuilder/Sidebar/index.tsx b/packages/slice-machine/lib/builders/SliceBuilder/Sidebar/index.tsx index a35fc927e6..3e64895848 100644 --- a/packages/slice-machine/lib/builders/SliceBuilder/Sidebar/index.tsx +++ b/packages/slice-machine/lib/builders/SliceBuilder/Sidebar/index.tsx @@ -1,44 +1,36 @@ import { Box, Button, Gradient } from "@prismicio/editor-ui"; import { useRouter } from "next/router"; -import { type Dispatch, type FC, type SetStateAction, useState } from "react"; +import { type FC, useState } from "react"; +import { toast } from "react-toastify"; import AddVariationModal from "@builders/SliceBuilder/Sidebar/AddVariationModal"; -import type { SliceBuilderState } from "@builders/SliceBuilder"; import { DeleteVariationModal } from "@components/DeleteVariationModal"; import { RenameVariationModal } from "@components/Forms/RenameVariationModal"; import ScreenshotChangesModal from "@components/ScreenshotChangesModal"; -import type { ComponentUI } from "@lib/models/common/ComponentUI"; import type { VariationSM } from "@lib/models/common/Slice"; -import { Variation } from "@lib/models/common/Variation"; import { SharedSliceCard } from "@src/features/slices/sliceCards/SharedSliceCard"; import { SLICES_CONFIG } from "@src/features/slices/slicesConfig"; import { useScreenshotChangesModal } from "@src/hooks/useScreenshotChangesModal"; +import { updateSlice } from "@src/apiClient"; +import { copySliceVariation } from "@src/domain/slice"; +import { useSliceState } from "@src/features/slices/sliceBuilder/SliceBuilderProvider"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; - -type SidebarProps = { - slice: ComponentUI; - variation: VariationSM; - sliceBuilderState: SliceBuilderState; - setSliceBuilderState: Dispatch>; -}; +import { SliceToastMessage } from "@components/ToasterContainer"; type DialogState = - | { type: "ADD_VARIATION"; variation?: undefined } - | { type: "RENAME_VARIATION"; variation: VariationSM } - | { type: "DELETE_VARIATION"; variation: VariationSM } + | { type: "ADD_VARIATION"; variation?: undefined; loading?: boolean } + | { type: "RENAME_VARIATION"; variation: VariationSM; loading?: boolean } + | { type: "DELETE_VARIATION"; variation: VariationSM; loading?: boolean } | undefined; -export const Sidebar: FC = (props) => { - const { slice, variation, sliceBuilderState, setSliceBuilderState } = props; - +export const Sidebar: FC = () => { + const { slice, variation, setSlice } = useSliceState(); const [dialog, setDialog] = useState(); - const screenshotChangesModal = useScreenshotChangesModal(); - const { sliceFilterFn, defaultVariationSelector } = + const { sliceFilterFn, defaultVariationSelector, onUploadSuccess } = screenshotChangesModal.modalPayload; - - const { copyVariationSlice, updateSlice } = useSliceMachineActions(); const router = useRouter(); + const { saveSliceSuccess } = useSliceMachineActions(); return ( <> @@ -48,10 +40,16 @@ export const Sidebar: FC = (props) => { action={{ type: "menu", onRename: () => { - setDialog({ type: "RENAME_VARIATION", variation: v }); + setDialog({ + type: "RENAME_VARIATION", + variation: v, + }); }, onRemove: () => { - setDialog({ type: "DELETE_VARIATION", variation: v }); + setDialog({ + type: "DELETE_VARIATION", + variation: v, + }); }, removeDisabled: slice.model.variations.length <= 1, }} @@ -64,6 +62,9 @@ export const Sidebar: FC = (props) => { sliceID: slice.model.id, variationID: v.id, }, + onUploadSuccess: (newSlice) => { + setSlice(newSlice); + }, }); }} replace @@ -103,6 +104,7 @@ export const Sidebar: FC = (props) => { = (props) => { }} slice={slice} variation={dialog?.variation} - sliceBuilderState={sliceBuilderState} - setSliceBuilderState={setSliceBuilderState} /> = (props) => { }} slice={slice} variation={dialog?.variation} - sliceBuilderState={sliceBuilderState} - setSliceBuilderState={setSliceBuilderState} /> = (props) => { onClose={() => { setDialog(undefined); }} - onSubmit={(id, name, copiedVariation) => { - copyVariationSlice(id, name, copiedVariation); + onSubmit={async (id, name, copiedVariation) => { + const { slice: newSlice, variation: newVariation } = + copySliceVariation({ slice, id, name, copiedVariation }); - // We have to immediately save the new variation to prevent an - // infinite loop related to screenshots handling. - const newVariation = Variation.copyValue(copiedVariation, id, name); - const newSlice = { - ...slice, - model: { - ...slice.model, - variations: [...slice.model.variations, newVariation], - }, - }; - updateSlice(newSlice, setSliceBuilderState); + await updateSlice(newSlice); + saveSliceSuccess(newSlice); + setSlice(newSlice); const url = SLICES_CONFIG.getBuilderPagePathname({ libraryName: newSlice.href, sliceName: newSlice.model.name, variationId: newVariation.id, }); + + toast.success( + SliceToastMessage({ + path: `${newSlice.from}/${newSlice.model.name}/model.json`, + }), + ); + void router.replace(url); }} variations={slice.model.variations} diff --git a/packages/slice-machine/lib/builders/SliceBuilder/SimulatorButton/index.tsx b/packages/slice-machine/lib/builders/SliceBuilder/SimulatorButton/index.tsx index 679d003a94..4ccc17b429 100644 --- a/packages/slice-machine/lib/builders/SliceBuilder/SimulatorButton/index.tsx +++ b/packages/slice-machine/lib/builders/SliceBuilder/SimulatorButton/index.tsx @@ -70,24 +70,10 @@ const SimulatorOnboardingTooltip: React.FC< ); }; -const NeedToSaveTooltip: React.FC = () => ( - - - Save your work in order to simulate - - -); - const SimulatorButton: React.FC<{ isSimulatorAvailableForFramework: boolean; - isTouched: boolean; -}> = ({ isSimulatorAvailableForFramework, isTouched }) => { + disabled: boolean; +}> = ({ isSimulatorAvailableForFramework, disabled }) => { const router = useRouter(); const ref = useRef(null); @@ -115,16 +101,9 @@ const SimulatorButton: React.FC<{ } }; - const disabled = !isSimulatorAvailableForFramework || isTouched; - const shouldShowSimulatorTooltip = isSimulatorAvailableForFramework && !hasSeenSimulatorTooltip; - const shouldShowNeedToSaveTooltip = - isSimulatorAvailableForFramework && - shouldShowSimulatorTooltip === false && - isTouched; - return ( ( )} - color="grey" > Simulate {isSimulatorAvailableForFramework === false ? ( - ) : shouldShowNeedToSaveTooltip ? ( - - ) : null} + ) : undefined} ); }; diff --git a/packages/slice-machine/lib/builders/SliceBuilder/index.tsx b/packages/slice-machine/lib/builders/SliceBuilder/index.tsx index b96e3630c3..23655bb9ce 100644 --- a/packages/slice-machine/lib/builders/SliceBuilder/index.tsx +++ b/packages/slice-machine/lib/builders/SliceBuilder/index.tsx @@ -1,23 +1,10 @@ -import { Box, Button } from "@prismicio/editor-ui"; -import { - type Dispatch, - type FC, - type SetStateAction, - useEffect, - useState, -} from "react"; - -import { - type ToastPayload, - handleRemoteResponse, -} from "@src/modules/toaster/utils"; +import { Box } from "@prismicio/editor-ui"; +import { type FC } from "react"; import FieldZones from "./FieldZones"; import { Sidebar } from "./Sidebar"; -import useSliceMachineActions from "src/modules/useSliceMachineActions"; import { useSelector } from "react-redux"; -import { SliceMachineStoreType } from "@src/redux/type"; import SimulatorButton from "@builders/SliceBuilder/SimulatorButton"; import { @@ -28,76 +15,17 @@ import { AppLayoutContent, AppLayoutHeader, } from "@components/AppLayout"; -import { VariationSM } from "@lib/models/common/Slice"; -import { ComponentUI } from "@lib/models/common/ComponentUI"; - import { FloatingBackButton } from "@src/features/slices/sliceBuilder/FloatingBackButton"; import { selectIsSimulatorAvailableForFramework } from "@src/modules/environment"; -import { isSelectedSliceTouched } from "@src/modules/selectedSlice/selectors"; -import { ComponentWithSliceProps } from "@src/layouts/WithSlice"; +import { useSliceState } from "@src/features/slices/sliceBuilder/SliceBuilderProvider"; +import { AutoSaveStatusIndicator } from "@src/features/autoSave/AutoSaveStatusIndicator"; -export type SliceBuilderState = ToastPayload & { loading: boolean }; - -export const initialState: SliceBuilderState = { - loading: false, - done: false, -}; - -const SliceBuilder: ComponentWithSliceProps = ({ slice, variation }) => { - const { openToaster } = useSliceMachineActions(); - const isTouched = useSelector((store: SliceMachineStoreType) => - isSelectedSliceTouched(store, slice.from, slice.model.id), - ); +export const SliceBuilder: FC = () => { + const { slice, autoSaveStatus } = useSliceState(); - // We need to move this state to somewhere global to update the UI if any action from anywhere save or update to the filesystem I'd guess - const [state, setState] = useState(initialState); - - useEffect(() => { - if (isTouched) { - setState(initialState); - } - }, [isTouched]); - - // activate/deactivate Success message - useEffect(() => { - if (state.done) { - handleRemoteResponse(openToaster)(state); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state]); - - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!variation) return ; - else - return ( - - ); -}; - -type SliceBuilderForVariationProps = { - setState: Dispatch>; - slice: ComponentUI; - variation: VariationSM; - isTouched: boolean; - state: SliceBuilderState; -}; -const SliceBuilderForVariation: FC = ({ - setState, - slice, - variation, - isTouched, - state, -}) => { const isSimulatorAvailableForFramework = useSelector( selectIsSimulatorAvailableForFramework, ); - const { updateSlice } = useSliceMachineActions(); return ( @@ -105,20 +33,11 @@ const SliceBuilderForVariation: FC = ({ + - @@ -128,13 +47,8 @@ const SliceBuilderForVariation: FC = ({ gap={16} gridTemplateColumns="320px 1fr" > - - + + diff --git a/packages/slice-machine/lib/models/common/ComponentUI.ts b/packages/slice-machine/lib/models/common/ComponentUI.ts index 8372227120..fd4c302e8d 100644 --- a/packages/slice-machine/lib/models/common/ComponentUI.ts +++ b/packages/slice-machine/lib/models/common/ComponentUI.ts @@ -58,18 +58,4 @@ export const ComponentUI = { return component.model.variations[0]; } }, - - updateVariation(component: ComponentUI, variationId: string) { - return (mutateCallbackFn: (v: VariationSM) => VariationSM): ComponentUI => { - const variations = component.model.variations.map((v) => { - if (v.id === variationId) return mutateCallbackFn(v); - else return v; - }); - - return { - ...component, - model: { ...component.model, variations }, - }; - }; - }, }; diff --git a/packages/slice-machine/lib/models/common/Variation.ts b/packages/slice-machine/lib/models/common/Variation.ts deleted file mode 100644 index 846809b734..0000000000 --- a/packages/slice-machine/lib/models/common/Variation.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { NestableWidget } from "@prismicio/types-internal/lib/customtypes"; -import { VariationSM, WidgetsArea } from "./Slice"; -import { FieldsSM } from "./Fields"; - -import camelCase from "lodash/camelCase"; - -export const Variation = { - generateId(str: string): string { - return camelCase(str); - }, - - reorderWidget( - variation: VariationSM, - widgetsArea: WidgetsArea, - start: number, - end: number, - ): VariationSM { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const widgets = variation[widgetsArea] || []; - const reorderedWidget: { key: string; value: NestableWidget } | undefined = - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - widgets && widgets[start]; - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!reorderedWidget) - throw new Error( - `Unable to reorder the widget at index ${start}. the list of widgets contains only ${widgets.length} elements.`, - ); - - const reorderedArea = widgets.reduce((acc, widget, index) => { - const elems = [widget, reorderedWidget]; - switch (index) { - case start: - return acc; - case end: - return [...acc, ...(end > start ? elems : elems.reverse())]; - default: - return [...acc, widget]; - } - }, []); - return { - ...variation, - [widgetsArea]: reorderedArea, - }; - }, - - replaceWidget( - variation: VariationSM, - widgetsArea: WidgetsArea, - previousKey: string, - newKey: string, - newValue: NestableWidget, - ): VariationSM { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const widgets = variation[widgetsArea] || []; - - return { - ...variation, - [widgetsArea]: widgets.reduce((acc: FieldsSM, { key, value }) => { - if (key === previousKey) { - return acc.concat([{ key: newKey, value: newValue }]); - } else { - return acc.concat([{ key, value }]); - } - }, []), - }; - }, - - addWidget( - variation: VariationSM, - widgetsArea: WidgetsArea, - key: string, - value: NestableWidget, - ): VariationSM { - return { - ...variation, - [widgetsArea]: variation[widgetsArea]?.concat([{ key, value }]), - }; - }, - - deleteWidget( - variation: VariationSM, - widgetsArea: WidgetsArea, - widgetKey: string, - ): VariationSM { - return { - ...variation, - [widgetsArea]: variation[widgetsArea]?.filter( - ({ key }) => widgetKey !== key, - ), - }; - }, - - copyValue(variation: VariationSM, key: string, name: string): VariationSM { - return { - ...variation, - id: key, - name, - }; - }, -}; diff --git a/packages/slice-machine/pages/slices/[lib]/[sliceName]/[variation]/index.tsx b/packages/slice-machine/pages/slices/[lib]/[sliceName]/[variation]/index.tsx index c8b96666c3..845ba9678e 100644 --- a/packages/slice-machine/pages/slices/[lib]/[sliceName]/[variation]/index.tsx +++ b/packages/slice-machine/pages/slices/[lib]/[sliceName]/[variation]/index.tsx @@ -1,19 +1,33 @@ import Head from "next/head"; -import SliceBuilder from "lib/builders/SliceBuilder"; -import useCurrentSlice from "@src/hooks/useCurrentSlice"; -import { createComponentWithSlice } from "@src/layouts/WithSlice"; +import { useRouter } from "next/router"; -const SliceBuilderWithSlice = createComponentWithSlice(SliceBuilder); +import SliceBuilder from "@lib/builders/SliceBuilder"; +import { SliceBuilderProvider } from "@src/features/slices/sliceBuilder/SliceBuilderProvider"; +import useCurrentSlice from "@src/hooks/useCurrentSlice"; export default function SlicePage() { - const { slice } = useCurrentSlice(); + const router = useRouter(); + const { slice: initialSlice, variation: defaultVariation } = + useCurrentSlice(); + + if (initialSlice === undefined || defaultVariation === undefined) { + void router.replace("/"); + + return null; + } return ( - <> - - {slice ? {slice.model.name} - Slice Machine : null} - - - + + {({ slice }) => { + return ( + <> + + {slice.model.name} - Slice Machine + + + + ); + }} + ); } diff --git a/packages/slice-machine/pages/slices/[lib]/[sliceName]/[variation]/simulator.tsx b/packages/slice-machine/pages/slices/[lib]/[sliceName]/[variation]/simulator.tsx index acbc55e673..5810870ea1 100644 --- a/packages/slice-machine/pages/slices/[lib]/[sliceName]/[variation]/simulator.tsx +++ b/packages/slice-machine/pages/slices/[lib]/[sliceName]/[variation]/simulator.tsx @@ -1,22 +1,25 @@ import Head from "next/head"; +import { useRouter } from "next/router"; + import Simulator from "@components/Simulator"; import useCurrentSlice from "@src/hooks/useCurrentSlice"; -import { createComponentWithSlice } from "@src/layouts/WithSlice"; - -const SimulatorWithSlice = createComponentWithSlice(Simulator); export default function SimulatorPage() { - const { slice } = useCurrentSlice(); + const router = useRouter(); + const { slice, variation } = useCurrentSlice(); + + if (slice === undefined || variation === undefined) { + void router.replace("/"); + + return null; + } return ( <> - - {slice ? `Simulator: ${slice.model.name}` : "Simulator"} - Slice - Machine - + {`Simulator: ${slice.model.name} - Slice Machine`} - + ); } diff --git a/packages/slice-machine/src/domain/slice.ts b/packages/slice-machine/src/domain/slice.ts index c846ed240e..50485c28e7 100644 --- a/packages/slice-machine/src/domain/slice.ts +++ b/packages/slice-machine/src/domain/slice.ts @@ -2,9 +2,49 @@ import type { CompositeSlice, LegacySlice, } from "@prismicio/types-internal/lib/customtypes/widgets/slices"; +import { NestableWidget } from "@prismicio/types-internal/lib/customtypes"; import type { ComponentUI } from "@lib/models/common/ComponentUI"; -import type { VariationSM } from "@lib/models/common/Slice"; +import type { VariationSM, WidgetsArea } from "@lib/models/common/Slice"; + +type CopySliceVariationArgs = { + copiedVariation: VariationSM; + id: string; + name: string; + slice: ComponentUI; +}; + +type DeleteFieldArgs = { + slice: ComponentUI; + variationId: string; + widgetArea: WidgetsArea; + fieldId: string; +}; + +type UpdateFieldArgs = { + slice: ComponentUI; + variationId: string; + widgetArea: WidgetsArea; + previousFieldId: string; + newFieldId: string; + newField: NestableWidget; +}; + +type AddFieldArgs = { + slice: ComponentUI; + variationId: string; + widgetArea: WidgetsArea; + newFieldId: string; + newField: NestableWidget; +}; + +type ReorderFieldArgs = { + slice: ComponentUI; + variationId: string; + widgetArea: WidgetsArea; + sourceIndex: number; + destinationIndex: number; +}; export function countMissingScreenshots(slice: ComponentUI): number { return slice.model.variations.length - Object.keys(slice.screenshots).length; @@ -78,3 +118,128 @@ export function getFieldMappingFingerprint( ), }; } + +export function copySliceVariation(args: CopySliceVariationArgs) { + const { slice, id, name, copiedVariation } = args; + const newVariation = { ...copiedVariation, id, name }; + + return { + slice: { + ...slice, + model: { + ...slice.model, + variations: slice.model.variations.concat([newVariation]), + }, + }, + variation: newVariation, + }; +} + +export function deleteField(args: DeleteFieldArgs): ComponentUI { + const { slice, variationId, widgetArea, fieldId } = args; + + return { + ...slice, + model: { + ...slice.model, + variations: slice.model.variations.map((v) => { + if (v.id === variationId) { + return { + ...v, + [widgetArea]: v[widgetArea]?.filter((w) => w.key !== fieldId), + }; + } + return v; + }), + }, + }; +} + +export function updateField(args: UpdateFieldArgs): ComponentUI { + const { + slice, + variationId, + widgetArea, + previousFieldId, + newFieldId, + newField, + } = args; + + return { + ...slice, + model: { + ...slice.model, + variations: slice.model.variations.map((v) => { + if (v.id === variationId) { + return { + ...v, + [widgetArea]: v[widgetArea]?.map((w) => { + if (w.key === previousFieldId) { + return { + key: newFieldId, + value: newField, + }; + } + return w; + }), + }; + } + return v; + }), + }, + }; +} + +export function addField(args: AddFieldArgs): ComponentUI { + const { slice, variationId, widgetArea, newFieldId, newField } = args; + + return { + ...slice, + model: { + ...slice.model, + variations: slice.model.variations.map((v) => { + if (v.id === variationId) { + return { + ...v, + [widgetArea]: v[widgetArea]?.concat([ + { + key: newFieldId, + value: newField, + }, + ]), + }; + } + return v; + }), + }, + }; +} + +export function reorderField(args: ReorderFieldArgs): ComponentUI { + const { slice, variationId, widgetArea, sourceIndex, destinationIndex } = + args; + + const fields = + slice.model.variations.find((v) => v.id === variationId)?.[widgetArea] ?? + []; + + const reorderedFields = [...fields]; + const [removedField] = reorderedFields.splice(sourceIndex, 1); + reorderedFields.splice(destinationIndex, 0, removedField); + + return { + ...slice, + model: { + ...slice.model, + variations: slice.model.variations.map((v) => { + if (v.id === variationId) { + return { + ...v, + [widgetArea]: reorderedFields, + }; + } + return v; + }), + }, + }; +} diff --git a/packages/slice-machine/src/features/slices/actions/uploadSliceScreenshot.ts b/packages/slice-machine/src/features/slices/actions/uploadSliceScreenshot.ts new file mode 100644 index 0000000000..b13388b378 --- /dev/null +++ b/packages/slice-machine/src/features/slices/actions/uploadSliceScreenshot.ts @@ -0,0 +1,45 @@ +import { toast } from "react-toastify"; + +import { ComponentUI } from "@lib/models/common/ComponentUI"; +import { generateSliceCustomScreenshot, telemetry } from "@src/apiClient"; + +type UploadSliceScreenshotArgs = { + file: File; + method: "upload" | "dragAndDrop"; + slice: ComponentUI; + variationId: string; +}; + +export async function uploadSliceScreenshot(args: UploadSliceScreenshotArgs) { + const { variationId, slice, file, method } = args; + + try { + const form = new FormData(); + form.append("file", file); + form.append("libraryName", slice.from); + form.append("sliceName", slice.model.name); + form.append("variationId", variationId); + const { errors, url } = await generateSliceCustomScreenshot({ + libraryName: slice.from, + sliceId: slice.model.id, + variationId, + file, + }); + + if (errors.length > 0) { + throw errors; + } + + void telemetry.track({ event: "screenshot-taken", type: "custom", method }); + + return { + ...slice, + screenshots: { ...slice.screenshots, [variationId]: { url } }, + }; + } catch (error) { + const message = `Screenshot not saved`; + console.error(message, error); + + toast.error(message); + } +} diff --git a/packages/slice-machine/src/features/slices/convertLegacySlice/ConvertLegacySliceAsNewVariationDialog.tsx b/packages/slice-machine/src/features/slices/convertLegacySlice/ConvertLegacySliceAsNewVariationDialog.tsx index 464532e4da..bb37c5e793 100644 --- a/packages/slice-machine/src/features/slices/convertLegacySlice/ConvertLegacySliceAsNewVariationDialog.tsx +++ b/packages/slice-machine/src/features/slices/convertLegacySlice/ConvertLegacySliceAsNewVariationDialog.tsx @@ -1,5 +1,6 @@ import { useState, type FC } from "react"; import { Formik } from "formik"; +import { camelCase } from "lodash"; import { Box, Dialog, @@ -13,7 +14,6 @@ import { SelectItem, } from "@prismicio/editor-ui"; -import { Variation } from "@models/common/Variation"; import { LibraryUI } from "@models/common/LibraryUI"; import * as styles from "./ConvertLegacySliceButton.css"; @@ -51,7 +51,7 @@ export const ConvertLegacySliceAsNewVariationDialog: FC = ({ initialValues={{ libraryID: localSharedSlices[0]?.from, sliceID: localSharedSlices[0]?.model.id, - variationID: Variation.generateId(slice.key), + variationID: camelCase(slice.key), variationName: sliceName, }} validate={(values) => { @@ -136,7 +136,7 @@ export const ConvertLegacySliceAsNewVariationDialog: FC = ({ }; if (inferIDFromName) { - values.variationID = Variation.generateId( + values.variationID = camelCase( values.variationName, ); } @@ -156,14 +156,14 @@ export const ConvertLegacySliceAsNewVariationDialog: FC = ({ ) : null} { setInferIDFromName(false); void formik.setFieldValue( "variationID", - Variation.generateId(value.slice(0, 30)), + camelCase(value.slice(0, 30)), ); }} data-cy="variation-id-input" diff --git a/packages/slice-machine/src/features/slices/sliceBuilder/SliceBuilderProvider.tsx b/packages/slice-machine/src/features/slices/sliceBuilder/SliceBuilderProvider.tsx new file mode 100644 index 0000000000..580f5d5513 --- /dev/null +++ b/packages/slice-machine/src/features/slices/sliceBuilder/SliceBuilderProvider.tsx @@ -0,0 +1,114 @@ +import { + Dispatch, + ReactNode, + SetStateAction, + createContext, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { useIsFirstRender } from "@prismicio/editor-support/React"; +import { useRouter } from "next/router"; + +import { + AutoSaveStatus, + useAutoSave, +} from "@src/features/autoSave/useAutoSave"; +import { ComponentUI } from "@lib/models/common/ComponentUI"; +import { readSliceMocks, updateSlice } from "@src/apiClient"; +import useSliceMachineActions from "@src/modules/useSliceMachineActions"; +import { VariationSM } from "@lib/models/common/Slice"; + +type SliceContext = { + slice: ComponentUI; + autoSaveStatus: AutoSaveStatus; + setSlice: Dispatch>; + variation: VariationSM; +}; + +type SliceBuilderProviderProps = { + children: ReactNode | ((value: SliceContext) => ReactNode); + initialSlice: ComponentUI; +}; + +const SliceContextValue = createContext({ + autoSaveStatus: "saved", + slice: {} as ComponentUI, + setSlice: () => void 0, + variation: {} as VariationSM, +}); + +export function SliceBuilderProvider(props: SliceBuilderProviderProps) { + const { children, initialSlice } = props; + + const isFirstRender = useIsFirstRender(); + const [slice, setSlice] = useState(initialSlice); + const { autoSaveStatus, setNextSave } = useAutoSave({ + errorMessage: "Failed to save slice, check console logs.", + }); + const { saveSliceSuccess } = useSliceMachineActions(); + const router = useRouter(); + + const variation = useMemo(() => { + const variationName = router.query.variation; + const v = slice.model.variations.find( + (variation) => variation.id === variationName, + ); + + if (v) { + return v; + } + + throw new Error("Variation not found"); + }, [slice, router]); + + useEffect( + () => { + // Prevent a save to be triggered on first render + if (!isFirstRender) { + setNextSave(async () => { + const { errors: updateSliceErrors } = await updateSlice(slice); + + if (updateSliceErrors.length > 0) { + throw updateSliceErrors; + } + + const { errors: readSliceMockErrors, mocks } = await readSliceMocks({ + libraryID: slice.from, + sliceID: slice.model.id, + }); + + if (readSliceMockErrors.length > 0) { + throw readSliceMockErrors; + } + + saveSliceSuccess({ ...slice, mocks }); + }); + } + }, + // Prevent saveSliceSuccess from triggering an infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + [slice, setNextSave], + ); + + const contextValue: SliceContext = useMemo( + () => ({ + autoSaveStatus, + slice, + setSlice, + variation, + }), + [autoSaveStatus, slice, setSlice, variation], + ); + + return ( + + {typeof children === "function" ? children(contextValue) : children} + + ); +} + +export function useSliceState() { + return useContext(SliceContextValue); +} diff --git a/packages/slice-machine/src/features/slices/sliceBuilder/actions/deleteVariation.ts b/packages/slice-machine/src/features/slices/sliceBuilder/actions/deleteVariation.ts index 474cdbd793..eb6b8330bd 100644 --- a/packages/slice-machine/src/features/slices/sliceBuilder/actions/deleteVariation.ts +++ b/packages/slice-machine/src/features/slices/sliceBuilder/actions/deleteVariation.ts @@ -1,7 +1,6 @@ import type { NextRouter } from "next/router"; -import type { Dispatch, SetStateAction } from "react"; +import { toast } from "react-toastify"; -import type { SliceBuilderState } from "@builders/SliceBuilder"; import { SliceToastMessage } from "@components/ToasterContainer"; import type { ComponentUI } from "@lib/models/common/ComponentUI"; import type { VariationSM } from "@lib/models/common/Slice"; @@ -16,16 +15,13 @@ import { SLICES_CONFIG } from "@src/features/slices/slicesConfig"; type DeleteVariationArgs = { component: ComponentUI; router: NextRouter; - setSliceBuilderState: Dispatch>; - updateAndSaveSlice: (component: ComponentUI) => void; + saveSliceSuccess: (component: ComponentUI) => void; variation: VariationSM; }; export async function deleteVariation( args: DeleteVariationArgs, -): Promise { - args.setSliceBuilderState({ loading: true, done: false }); - +): Promise { try { // The slice may have been edited so we need to update the file system. const { errors: updateSliceErrors } = await updateSlice(args.component); @@ -62,27 +58,20 @@ export async function deleteVariation( variationId: slice.variations[0].id, }); await args.router.replace(url); - args.updateAndSaveSlice({ ...args.component, model: slice, mocks }); + + const newComponent = { ...args.component, model: slice, mocks }; + args.saveSliceSuccess(newComponent); // Finally, display a success toast. const path = `${args.component.from}/${args.component.model.name}/model.json`; - args.setSliceBuilderState({ - loading: false, - done: true, - error: false, - message: SliceToastMessage({ path }), - }); + toast.success(SliceToastMessage({ path })); + + return newComponent; } catch (error) { const message = `Could not delete variation \`${args.variation.name}\``; console.error(message, error); - // Display a failure toast. - args.setSliceBuilderState({ - loading: false, - done: true, - error: true, - message, - }); + toast.error(message); throw error; } diff --git a/packages/slice-machine/src/features/slices/sliceBuilder/actions/renameVariation.ts b/packages/slice-machine/src/features/slices/sliceBuilder/actions/renameVariation.ts index e814927473..6a2b96731c 100644 --- a/packages/slice-machine/src/features/slices/sliceBuilder/actions/renameVariation.ts +++ b/packages/slice-machine/src/features/slices/sliceBuilder/actions/renameVariation.ts @@ -1,6 +1,3 @@ -import type { Dispatch, SetStateAction } from "react"; - -import type { SliceBuilderState } from "@builders/SliceBuilder"; import { SliceToastMessage } from "@components/ToasterContainer"; import type { ComponentUI } from "@lib/models/common/ComponentUI"; import type { VariationSM } from "@lib/models/common/Slice"; @@ -10,20 +7,18 @@ import { renameSliceVariation, updateSlice, } from "@src/apiClient"; +import { toast } from "react-toastify"; type RenameVariationArgs = { component: ComponentUI; - setSliceBuilderState: Dispatch>; - updateAndSaveSlice: (component: ComponentUI) => void; + saveSliceSuccess: (component: ComponentUI) => void; variation: VariationSM; variationName: string; }; export async function renameVariation( args: RenameVariationArgs, -): Promise { - args.setSliceBuilderState({ loading: true, done: false }); - +): Promise { try { // The slice may have been edited so we need to update the file system. const { errors: updateSliceErrors } = await updateSlice(args.component); @@ -53,27 +48,20 @@ export async function renameVariation( libraryID: args.component.from, sliceID: args.component.model.id, }); - args.updateAndSaveSlice({ ...args.component, model: slice, mocks }); + + const newComponent = { ...args.component, model: slice, mocks }; + args.saveSliceSuccess(newComponent); // Finally, display a success toast. const path = `${args.component.from}/${args.component.model.name}/model.json`; - args.setSliceBuilderState({ - loading: false, - done: true, - error: false, - message: SliceToastMessage({ path }), - }); + toast.success(SliceToastMessage({ path })); + + return newComponent; } catch (error) { const message = `Could not rename variation \`${args.variation.name}\``; console.error(message, error); - // Display a failure toast. - args.setSliceBuilderState({ - loading: false, - done: true, - error: true, - message, - }); + toast.error(message); throw error; } diff --git a/packages/slice-machine/src/hooks/useCurrentSlice.tsx b/packages/slice-machine/src/hooks/useCurrentSlice.tsx index 12b0ed4f92..5580e55fc6 100644 --- a/packages/slice-machine/src/hooks/useCurrentSlice.tsx +++ b/packages/slice-machine/src/hooks/useCurrentSlice.tsx @@ -1,18 +1,15 @@ +import { useRouter } from "next/router"; +import { useSelector } from "react-redux"; + import { ComponentUI } from "@lib/models/common/ComponentUI"; import { VariationSM } from "@lib/models/common/Slice"; -import { selectCurrentSlice } from "@src/modules/selectedSlice/selectors"; -import useSliceMachineActions from "@src/modules/useSliceMachineActions"; +import { selectCurrentSlice } from "@src/modules/slices/selector"; import { SliceMachineStoreType } from "@src/redux/type"; -import { useRouter } from "next/router"; -import { useEffect } from "react"; -import { useSelector } from "react-redux"; - type UseCurrentSliceRet = { slice?: ComponentUI; variation?: VariationSM }; const useCurrentSlice = (): UseCurrentSliceRet => { const router = useRouter(); - const { initSliceStore } = useSliceMachineActions(); const { slice } = useSelector((store: SliceMachineStoreType) => ({ slice: selectCurrentSlice( @@ -22,14 +19,6 @@ const useCurrentSlice = (): UseCurrentSliceRet => { ), })); - useEffect(() => { - if (slice) { - initSliceStore(slice); - } else { - void router.replace("/"); - } - }, [initSliceStore, slice, router]); - if (!slice) { return {}; } @@ -39,7 +28,6 @@ const useCurrentSlice = (): UseCurrentSliceRet => { ); if (!variation) { - void router.replace("/"); return {}; } diff --git a/packages/slice-machine/src/hooks/useScreenshotChangesModal.ts b/packages/slice-machine/src/hooks/useScreenshotChangesModal.ts index 45d9dbe103..38499fd65d 100644 --- a/packages/slice-machine/src/hooks/useScreenshotChangesModal.ts +++ b/packages/slice-machine/src/hooks/useScreenshotChangesModal.ts @@ -6,6 +6,7 @@ import { SliceVariationSelector } from "@components/ScreenshotChangesModal"; type ModalPayload = { sliceFilterFn: (s: ComponentUI[]) => ComponentUI[]; defaultVariationSelector?: SliceVariationSelector; + onUploadSuccess?: (newSlice: ComponentUI) => void; }; type Payload = { diff --git a/packages/slice-machine/src/hooks/useServerState.ts b/packages/slice-machine/src/hooks/useServerState.ts index 6357643ef3..5f273c6b3d 100644 --- a/packages/slice-machine/src/hooks/useServerState.ts +++ b/packages/slice-machine/src/hooks/useServerState.ts @@ -1,48 +1,22 @@ import { useEffect, useCallback } from "react"; import * as Sentry from "@sentry/nextjs"; -import { useSelector } from "react-redux"; import useSwr from "swr"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; -import { isSelectedSliceTouched } from "@src/modules/selectedSlice/selectors"; -import { SliceMachineStoreType } from "@src/redux/type"; import ServerState from "@lib/models/server/ServerState"; import { getState } from "@src/apiClient"; const useServerState = () => { - const { refreshState, initSliceStore } = useSliceMachineActions(); + const { refreshState } = useSliceMachineActions(); // eslint-disable-next-line react-hooks/exhaustive-deps const handleRefreshState = useCallback(refreshState, []); const { data: serverState } = useSwr("getState", async () => { return await getState(); }); - // Whether or not current slice or custom type builder is touched, and its related server state. - const { selectedSlice, sliceIsTouched } = useSelector( - (store: SliceMachineStoreType) => ({ - selectedSlice: store.selectedSlice, - sliceIsTouched: isSelectedSliceTouched( - store, - store.selectedSlice?.from ?? "", - store.selectedSlice?.model.id ?? "", - ), - }), - ); - useEffect(() => { let canceled = false; if (serverState && !canceled) { - // If slice builder is untouched, update from server state. - if (selectedSlice && !sliceIsTouched) { - const serverSlice = serverState.libraries - .find((l) => l.name === selectedSlice?.from) - ?.components.find((c) => c.model.id === selectedSlice?.model.id); - - if (serverSlice) { - initSliceStore(serverSlice); - } - } - handleRefreshState(serverState); Sentry.setUser({ id: serverState.env.shortId }); diff --git a/packages/slice-machine/src/layouts/WithSlice.tsx b/packages/slice-machine/src/layouts/WithSlice.tsx deleted file mode 100644 index 1cbe7cdb04..0000000000 --- a/packages/slice-machine/src/layouts/WithSlice.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { ReactNode } from "react"; -import { ComponentUI } from "@lib/models/common/ComponentUI"; -import { VariationSM } from "@lib/models/common/Slice"; -import useCurrentSlice from "@src/hooks/useCurrentSlice"; -import { AppProps } from "next/app"; - -import { replace } from "connected-next-router"; - -export type ComponentWithSliceProps = React.FC<{ - slice: ComponentUI; - variation: VariationSM; -}>; - -export const createComponentWithSlice = (C: ComponentWithSliceProps) => { - const Wrapper: React.FC<{ pageProps?: AppProps }> & { - CustomLayout?: React.FC<{ children: ReactNode }>; - } = ({ pageProps }) => { - const { slice, variation } = useCurrentSlice(); - if (!slice || !variation) { - void replace("/"); - return null; - } - return ; - }; - return Wrapper; -}; diff --git a/packages/slice-machine/src/modules/screenshots/actions.ts b/packages/slice-machine/src/modules/screenshots/actions.ts deleted file mode 100644 index c9344d786e..0000000000 --- a/packages/slice-machine/src/modules/screenshots/actions.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ActionType, createAsyncAction } from "typesafe-actions"; -import type { ScreenshotGenerationMethod } from "@lib/models/common/Screenshots"; -import { ComponentUI, ScreenshotUI } from "@lib/models/common/ComponentUI"; - -export type SelectedSliceActions = ActionType< - typeof generateSliceCustomScreenshotCreator ->; - -export const generateSliceCustomScreenshotCreator = createAsyncAction( - "SLICE/GENERATE_CUSTOM_SCREENSHOT.REQUEST", - "SLICE/GENERATE_CUSTOM_SCREENSHOT.RESPONSE", - "SLICE/GENERATE_CUSTOM_SCREENSHOT.FAILURE", -)< - { - variationId: string; - component: ComponentUI; - file: Blob; - method: ScreenshotGenerationMethod; - }, - { variationId: string; screenshot: ScreenshotUI; component: ComponentUI } ->(); diff --git a/packages/slice-machine/src/modules/screenshots/sagas.ts b/packages/slice-machine/src/modules/screenshots/sagas.ts deleted file mode 100644 index cce25a01a8..0000000000 --- a/packages/slice-machine/src/modules/screenshots/sagas.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - call, - fork, - takeLatest, - put, - SagaReturnType, -} from "redux-saga/effects"; -import { getType } from "typesafe-actions"; -import { withLoader } from "../loading"; -import { LoadingKeysEnum } from "../loading/types"; -import { generateSliceCustomScreenshotCreator } from "./actions"; -import { generateSliceCustomScreenshot, telemetry } from "@src/apiClient"; -import { openToasterCreator, ToasterType } from "@src/modules/toaster"; - -export function* generateSliceCustomScreenshotSaga({ - payload, -}: ReturnType) { - const { variationId, component, file, method } = payload; - try { - const form = new FormData(); - form.append("file", file); - form.append("libraryName", component.from); - form.append("sliceName", component.model.name); - form.append("variationId", variationId); - const response = (yield call(generateSliceCustomScreenshot, { - libraryName: component.from, - sliceId: component.model.id, - variationId, - file, - })) as SagaReturnType; - - if (!response?.url) { - throw Error("No screenshot saved"); - } - - void telemetry.track({ event: "screenshot-taken", type: "custom", method }); - - yield put( - generateSliceCustomScreenshotCreator.success({ - variationId, - screenshot: { - url: response.url, - }, - component, - }), - ); - } catch (e) { - yield put( - openToasterCreator({ - content: "Internal Error: Custom screenshot not saved", - type: ToasterType.ERROR, - }), - ); - } -} - -function* watchGenerateSliceCustomScreenshot() { - yield takeLatest( - getType(generateSliceCustomScreenshotCreator.request), - withLoader( - generateSliceCustomScreenshotSaga, - LoadingKeysEnum.GENERATE_SLICE_CUSTOM_SCREENSHOT, - ), - ); -} - -// Saga Exports -export function* screenshotsSagas() { - yield fork(watchGenerateSliceCustomScreenshot); -} diff --git a/packages/slice-machine/src/modules/selectedSlice/actions.ts b/packages/slice-machine/src/modules/selectedSlice/actions.ts deleted file mode 100644 index 25425465a0..0000000000 --- a/packages/slice-machine/src/modules/selectedSlice/actions.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { ActionType, createAction, createAsyncAction } from "typesafe-actions"; -import { NestableWidget } from "@prismicio/types-internal/lib/customtypes"; -import type { SliceBuilderState } from "@builders/SliceBuilder"; -import { ComponentUI } from "@lib/models/common/ComponentUI"; -import { renameSliceCreator } from "../slices"; -import { SelectedSliceStoreType } from "./types"; -import { refreshStateCreator } from "../environment"; -import { generateSliceCustomScreenshotCreator } from "../screenshots/actions"; -import { VariationSM, WidgetsArea } from "@lib/models/common/Slice"; -import { SharedSliceContent } from "@prismicio/types-internal/lib/content"; - -export type SelectedSliceActions = - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType; - -export const updateSelectedSliceMocks = createAction( - "SELECTED_SLICE/UPDATE_MOCKS", -)<{ - mocks: SharedSliceContent[]; -}>(); - -export const initSliceStoreCreator = - createAction("SLICE/INIT")(); - -export const addSliceWidgetCreator = createAction("SLICE/ADD_WIDGET")<{ - variationId: string; - widgetsArea: WidgetsArea; - key: string; - value: NestableWidget; -}>(); - -export const replaceSliceWidgetCreator = createAction("SLICE/REPLACE_WIDGET")<{ - variationId: string; - widgetsArea: WidgetsArea; - previousKey: string; - newKey: string; - value: NestableWidget; -}>(); - -export const reorderSliceWidgetCreator = createAction("SLICE/REORDER_WIDGET")<{ - variationId: string; - widgetsArea: WidgetsArea; - start: number; - end: number | undefined; -}>(); - -export const removeSliceWidgetCreator = createAction("SLICE/REMOVE_WIDGET")<{ - variationId: string; - widgetsArea: WidgetsArea; - key: string; -}>(); - -export const updateSliceWidgetMockCreator = createAction( - "SLICE/UPDATE_WIDGET_MOCK", -)<{ - variationId: string; - widgetArea: WidgetsArea; - previousKey: string; - newKey: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockValue: any; -}>(); - -export const deleteSliceWidgetMockCreator = createAction( - "SLICE/DELETE_WIDGET_MOCK", -)<{ - variationId: string; - widgetArea: WidgetsArea; - newKey: string; -}>(); - -export const updateSliceCreator = createAsyncAction( - "SLICE/UPDATE.REQUEST", - "SLICE/UPDATE.RESPONSE", - "SLICE/UPDATE.FAILURE", -)< - { - component: ComponentUI; - setSliceBuilderState: (sliceBuilderState: SliceBuilderState) => void; - }, - { - component: ComponentUI; - } ->(); - -export const updateAndSaveSliceCreator = createAction("SLICE/UPDATE_AND_SAVE")<{ - component: ComponentUI; -}>(); - -export const copyVariationSliceCreator = createAction("SLICE/COPY_VARIATION")<{ - key: string; - name: string; - copied: VariationSM; -}>(); diff --git a/packages/slice-machine/src/modules/selectedSlice/reducer.ts b/packages/slice-machine/src/modules/selectedSlice/reducer.ts deleted file mode 100644 index 5b42aa9fd7..0000000000 --- a/packages/slice-machine/src/modules/selectedSlice/reducer.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { AnyWidget } from "@lib/models/common/widgets/Widget"; -import { Reducer } from "redux"; -import { getType } from "typesafe-actions"; -import { - addSliceWidgetCreator, - copyVariationSliceCreator, - initSliceStoreCreator, - removeSliceWidgetCreator, - updateAndSaveSliceCreator, - reorderSliceWidgetCreator, - replaceSliceWidgetCreator, - SelectedSliceActions, - updateSelectedSliceMocks, -} from "./actions"; -import { SelectedSliceStoreType } from "./types"; -import * as Widgets from "../../../lib/models/common/widgets"; -import { Variation } from "@lib/models/common/Variation"; -import { ComponentUI } from "@lib/models/common/ComponentUI"; -import { SliceSM } from "@lib/models/common/Slice"; -import { renameSliceCreator } from "../slices"; -import { refreshStateCreator } from "../environment"; -import { generateSliceCustomScreenshotCreator } from "../screenshots/actions"; - -// Reducer -export const selectedSliceReducer: Reducer< - SelectedSliceStoreType, - SelectedSliceActions -> = (prevState = null, action) => { - switch (action.type) { - case getType(initSliceStoreCreator): { - if (!action.payload) return null; - return action.payload; - } - case getType(refreshStateCreator): - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (prevState === null || !action.payload.libraries) return prevState; - - const updatedSlice = action.payload.libraries - .find((l) => l.name === prevState.from) - ?.components.find((c) => c.model.id === prevState.model.id); - - if (updatedSlice === undefined) { - return prevState; - } - return { - ...prevState, - screenshots: updatedSlice.screenshots, - }; - case getType(addSliceWidgetCreator): { - if (!prevState) return prevState; - const { variationId, widgetsArea, key, value } = action.payload; - try { - if ( - value.type !== "Range" && - value.type !== "Separator" && - value.type !== "IntegrationFields" - ) { - 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 ComponentUI.updateVariation( - prevState, - variationId, - )((v) => Variation.addWidget(v, widgetsArea, key, value)); - } - return prevState; - } catch (err) { - console.error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `[store/addWidget] Model is invalid for widget "${value.type}".\nFull error: ${err}`, - ); - return prevState; - } - } - case getType(replaceSliceWidgetCreator): { - if (!prevState) return prevState; - const { variationId, widgetsArea, previousKey, newKey, value } = - action.payload; - try { - if ( - value.type !== "Range" && - value.type !== "Separator" && - value.type !== "IntegrationFields" - ) { - 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 ComponentUI.updateVariation( - prevState, - variationId, - )((v) => - Variation.replaceWidget(v, widgetsArea, previousKey, newKey, value), - ); - } - return prevState; - } catch (err) { - console.error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `[store/replaceWidget] Model is invalid for widget "${value.type}".\nFull error: ${err}`, - ); - return prevState; - } - } - case getType(generateSliceCustomScreenshotCreator.success): { - if (!prevState) return prevState; - const { component, screenshot, variationId } = action.payload; - return { - ...component, - screenshots: { - ...component.screenshots, - [variationId]: screenshot, - }, - }; - } - case getType(reorderSliceWidgetCreator): { - if (!prevState) return prevState; - const { variationId, widgetsArea, start, end } = action.payload; - if (end === undefined) return prevState; - - return ComponentUI.updateVariation( - prevState, - variationId, - )((v) => Variation.reorderWidget(v, widgetsArea, start, end)); - } - case getType(removeSliceWidgetCreator): { - if (!prevState) return prevState; - const { variationId, widgetsArea, key } = action.payload; - - return ComponentUI.updateVariation( - prevState, - variationId, - )((v) => Variation.deleteWidget(v, widgetsArea, key)); - } - case getType(copyVariationSliceCreator): { - if (!prevState) return prevState; - const { key, name, copied } = action.payload; - const newVariation = Variation.copyValue(copied, key, name); - - const model: SliceSM = { - ...prevState.model, - variations: prevState.model.variations.concat([newVariation]), - }; - return { - ...prevState, - model, - }; - } - case getType(renameSliceCreator.success): { - if (!prevState) return prevState; - const { renamedSlice } = action.payload; - - return { - ...prevState, - model: renamedSlice, - }; - } - case getType(updateAndSaveSliceCreator): { - return prevState - ? { ...prevState, ...action.payload.component } - : prevState; - } - case getType(updateSelectedSliceMocks): { - if (!prevState) return prevState; - const { mocks } = action.payload; - return { - ...prevState, - mocks, - }; - } - default: - return prevState; - } -}; diff --git a/packages/slice-machine/src/modules/selectedSlice/sagas.ts b/packages/slice-machine/src/modules/selectedSlice/sagas.ts deleted file mode 100644 index 418ecdd27a..0000000000 --- a/packages/slice-machine/src/modules/selectedSlice/sagas.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - call, - fork, - takeLatest, - put, - SagaReturnType, -} from "redux-saga/effects"; - -import { getType } from "typesafe-actions"; -import { withLoader } from "../loading"; -import { LoadingKeysEnum } from "../loading/types"; -import { updateSliceCreator } from "./actions"; -import { readSliceMocks, updateSlice } from "@src/apiClient"; -import { openToasterCreator, ToasterType } from "@src/modules/toaster"; -import { SliceToastMessage } from "@components/ToasterContainer"; - -export function* updateSliceSaga({ - payload, -}: ReturnType) { - const { component, setSliceBuilderState } = payload; - - try { - setSliceBuilderState({ loading: true, done: false }); - const { errors } = (yield call(updateSlice, component)) as SagaReturnType< - typeof updateSlice - >; - if (errors.length > 0) { - return setSliceBuilderState({ - loading: false, - done: true, - error: true, - message: errors[0].message, - }); - } - setSliceBuilderState({ - loading: false, - done: true, - error: false, - message: SliceToastMessage({ - path: `${component.from}/${component.model.name}/model.json`, - }), - }); - - const { mocks } = (yield call(readSliceMocks, { - libraryID: component.from, - sliceID: component.model.id, - })) as SagaReturnType; - - yield put( - updateSliceCreator.success({ component: { ...component, mocks } }), - ); - } catch (e) { - yield put( - openToasterCreator({ - content: "Internal Error: Models & mocks not generated", - type: ToasterType.ERROR, - }), - ); - } -} - -function* watchSaveSlice() { - yield takeLatest( - getType(updateSliceCreator.request), - withLoader(updateSliceSaga, LoadingKeysEnum.SAVE_SLICE), - ); -} - -// Saga Exports -export function* selectedSliceSagas() { - yield fork(watchSaveSlice); -} diff --git a/packages/slice-machine/src/modules/selectedSlice/selectors.ts b/packages/slice-machine/src/modules/selectedSlice/selectors.ts deleted file mode 100644 index 8c8d8a8aa6..0000000000 --- a/packages/slice-machine/src/modules/selectedSlice/selectors.ts +++ /dev/null @@ -1,55 +0,0 @@ -import equal from "fast-deep-equal"; -import { SliceMachineStoreType } from "@src/redux/type"; -import { getLibraries } from "../slices"; - -export const selectSliceById = ( - store: SliceMachineStoreType, - libraryName: string, - sliceId: string, -) => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - const libraries = getLibraries(store) || []; - - const library = libraries.find((library) => library.name === libraryName); - const slice = library?.components.find((c) => c.model.id === sliceId); - - return slice; -}; - -export const selectCurrentSlice = ( - store: SliceMachineStoreType, - lib: string, - sliceName: string, -) => { - const openedModel = store.selectedSlice; - if (openedModel?.model.name === sliceName) { - return openedModel; - } - - const library = getLibraries(store)?.find( - (l) => l.name.replace(/\//g, "--") === lib, - ); - const slice = library?.components.find((c) => c.model.name === sliceName); - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return slice || null; -}; - -export const isSelectedSliceTouched = ( - store: SliceMachineStoreType, - lib: string, - sliceId: string, -): boolean => { - const selectedSlice = store.selectedSlice; - const library = getLibraries(store)?.find((l) => l.name === lib); - const librarySlice = library?.components.find((c) => c.model.id === sliceId); - - if (!selectedSlice || !librarySlice) return false; - - const sameVariations = equal( - librarySlice.model.variations, - selectedSlice.model.variations, - ); - - return !sameVariations; -}; diff --git a/packages/slice-machine/src/modules/selectedSlice/types.ts b/packages/slice-machine/src/modules/selectedSlice/types.ts deleted file mode 100644 index 69c78a8577..0000000000 --- a/packages/slice-machine/src/modules/selectedSlice/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ComponentUI } from "@lib/models/common/ComponentUI"; - -export type SelectedSliceStoreType = ComponentUI | null; diff --git a/packages/slice-machine/src/modules/simulator/index.ts b/packages/slice-machine/src/modules/simulator/index.ts index efcee6f175..04e9b838fd 100644 --- a/packages/slice-machine/src/modules/simulator/index.ts +++ b/packages/slice-machine/src/modules/simulator/index.ts @@ -32,7 +32,6 @@ import { updateSliceMock } from "../slices"; import { modalOpenCreator } from "../modal"; import { ModalKeysEnum } from "../modal/types"; -import { updateSelectedSliceMocks } from "../selectedSlice/actions"; export const initialState: SimulatorStoreType = { setupSteps: null, @@ -266,7 +265,6 @@ export function* saveSliceMockSaga({ ); yield put(updateSliceMock(payload)); - yield put(updateSelectedSliceMocks({ mocks: payload.mocks })); yield put(saveSliceMockCreator.success()); } catch (error) { yield put( diff --git a/packages/slice-machine/src/modules/slices/index.ts b/packages/slice-machine/src/modules/slices/index.ts index 8b8f2842dc..b7914e3d08 100644 --- a/packages/slice-machine/src/modules/slices/index.ts +++ b/packages/slice-machine/src/modules/slices/index.ts @@ -25,13 +25,10 @@ import { modalCloseCreator } from "@src/modules/modal"; import { refreshStateCreator } from "@src/modules/environment"; import { SliceMachineStoreType } from "@src/redux/type"; import { openToasterCreator, ToasterType } from "@src/modules/toaster"; -import { - updateAndSaveSliceCreator, - updateSliceCreator, -} from "../selectedSlice/actions"; -import { generateSliceCustomScreenshotCreator } from "../screenshots/actions"; -import { selectSliceById } from "../selectedSlice/selectors"; import { SlicesStoreType } from "./types"; +import { ComponentUI, ScreenshotUI } from "@lib/models/common/ComponentUI"; +import { selectSliceById } from "./selector"; +import { ScreenshotGenerationMethod } from "@lib/models/common/Screenshots"; // Action Creators export const createSlice = createAction("SLICES/CREATE_SLICE")<{ @@ -70,6 +67,35 @@ export const deleteSliceCreator = createAsyncAction( libName: string; } >(); + +export const updateSliceCreator = createAsyncAction( + "SLICE/UPDATE.REQUEST", + "SLICE/UPDATE.RESPONSE", + "SLICE/UPDATE.FAILURE", +)< + { + component: ComponentUI; + setSliceBuilderState: () => void; + }, + { + component: ComponentUI; + } +>(); + +export const generateSliceCustomScreenshotCreator = createAsyncAction( + "SLICE/GENERATE_CUSTOM_SCREENSHOT.REQUEST", + "SLICE/GENERATE_CUSTOM_SCREENSHOT.RESPONSE", + "SLICE/GENERATE_CUSTOM_SCREENSHOT.FAILURE", +)< + { + variationId: string; + component: ComponentUI; + file: Blob; + method: ScreenshotGenerationMethod; + }, + { variationId: string; screenshot: ScreenshotUI; component: ComponentUI } +>(); + export const updateSliceMock = createAction("SLICE/UPDATE_MOCK")(); @@ -79,7 +105,6 @@ type SlicesActions = | ActionType | ActionType | ActionType - | ActionType | ActionType | ActionType; @@ -161,24 +186,6 @@ export const slicesReducer: Reducer = ( return { ...state, libraries: newLibraries }; } - case getType(updateAndSaveSliceCreator): { - const { component: newComponent } = action.payload; - return { - ...state, - libraries: state.libraries.map((library) => - library.name === newComponent.from - ? { - ...library, - components: library.components.map((component) => - component.model.id === newComponent.model.id - ? newComponent - : component, - ), - } - : library, - ), - }; - } case getType(generateSliceCustomScreenshotCreator.success): { const { component, screenshot, variationId } = action.payload; diff --git a/packages/slice-machine/src/modules/slices/selector.ts b/packages/slice-machine/src/modules/slices/selector.ts new file mode 100644 index 0000000000..d4d59cdbe5 --- /dev/null +++ b/packages/slice-machine/src/modules/slices/selector.ts @@ -0,0 +1,30 @@ +import { SliceMachineStoreType } from "@src/redux/type"; + +import { getLibraries } from "./index"; + +export const selectSliceById = ( + store: SliceMachineStoreType, + libraryName: string, + sliceId: string, +) => { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + const libraries = getLibraries(store) || []; + + const library = libraries.find((library) => library.name === libraryName); + const slice = library?.components.find((c) => c.model.id === sliceId); + + return slice; +}; + +export const selectCurrentSlice = ( + store: SliceMachineStoreType, + lib: string, + sliceName: string, +) => { + const library = getLibraries(store)?.find( + (l) => l.name.replace(/\//g, "--") === lib, + ); + const slice = library?.components.find((c) => c.model.name === sliceName); + + return slice; +}; diff --git a/packages/slice-machine/src/modules/toaster/utils.tsx b/packages/slice-machine/src/modules/toaster/utils.tsx deleted file mode 100644 index 213da6b35b..0000000000 --- a/packages/slice-machine/src/modules/toaster/utils.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { ReactNode } from "react"; - -import { ToasterType } from "../toaster"; - -export type ToastPayload = - | { done: true; message: ReactNode; error: boolean } - | { done: false }; - -export const handleRemoteResponse = - (addToast: (message: ReactNode, type: ToasterType) => void) => - (payload: ToastPayload) => { - if (payload.done) { - addToast( - payload.message, - (() => { - if (payload.error) { - return ToasterType.ERROR; - } - return ToasterType.SUCCESS; - })(), - ); - } - }; diff --git a/packages/slice-machine/src/modules/useSliceMachineActions.ts b/packages/slice-machine/src/modules/useSliceMachineActions.ts index 31e752f655..155b3e78c9 100644 --- a/packages/slice-machine/src/modules/useSliceMachineActions.ts +++ b/packages/slice-machine/src/modules/useSliceMachineActions.ts @@ -23,34 +23,21 @@ import { renameAvailableCustomType, saveCustomTypeCreator, } from "./availableCustomTypes"; -import { createSlice, deleteSliceCreator, renameSliceCreator } from "./slices"; +import { + createSlice, + deleteSliceCreator, + generateSliceCustomScreenshotCreator, + renameSliceCreator, + updateSliceCreator, +} from "./slices"; import { UserContextStoreType, UserReviewType } from "./userContext/types"; import { GenericToastTypes, openToasterCreator } from "./toaster"; -import type { SliceBuilderState } from "@builders/SliceBuilder"; import { CustomTypes } from "@lib/models/common/CustomType"; -import { - CustomType, - NestableWidget, -} from "@prismicio/types-internal/lib/customtypes"; -import { - addSliceWidgetCreator, - copyVariationSliceCreator, - deleteSliceWidgetMockCreator, - initSliceStoreCreator, - removeSliceWidgetCreator, - reorderSliceWidgetCreator, - replaceSliceWidgetCreator, - updateAndSaveSliceCreator, - updateSliceCreator, - updateSliceWidgetMockCreator, -} from "./selectedSlice/actions"; -import { generateSliceCustomScreenshotCreator } from "./screenshots/actions"; -import { ComponentUI } from "../../lib/models/common/ComponentUI"; +import { CustomType } from "@prismicio/types-internal/lib/customtypes"; +import { ComponentUI, ScreenshotUI } from "../../lib/models/common/ComponentUI"; import { ChangesPushSagaPayload, changesPushCreator } from "./pushChangesSaga"; -import type { ScreenshotGenerationMethod } from "@lib/models/common/Screenshots"; import { saveSliceMockCreator } from "./simulator"; import { SaveSliceMockRequest } from "@src/apiClient"; -import { VariationSM, WidgetsArea } from "@lib/models/common/Slice"; import { CustomTypeFormat } from "@slicemachine/manager"; import { LibraryUI } from "@lib/models/common/LibraryUI"; @@ -169,140 +156,28 @@ const useSliceMachineActions = () => { /** End of sucess actions */ // Slice module - const initSliceStore = (component: ComponentUI) => - dispatch(initSliceStoreCreator(component)); - - const addSliceWidget = ( - variationId: string, - widgetsArea: WidgetsArea, - key: string, - value: NestableWidget, - ) => { - dispatch(addSliceWidgetCreator({ variationId, widgetsArea, key, value })); - }; - - const replaceSliceWidget = ( - variationId: string, - widgetsArea: WidgetsArea, - previousKey: string, - newKey: string, - value: NestableWidget, - ) => { - dispatch( - replaceSliceWidgetCreator({ - variationId, - widgetsArea, - previousKey, - newKey, - value, - }), - ); - }; - - const reorderSliceWidget = ( - variationId: string, - widgetsArea: WidgetsArea, - start: number, - end: number | undefined, - ) => { + const saveSliceSuccess = (component: ComponentUI) => { dispatch( - reorderSliceWidgetCreator({ - variationId, - widgetsArea, - start, - end, - }), - ); - }; - - const removeSliceWidget = ( - variationId: string, - widgetsArea: WidgetsArea, - key: string, - ) => { - dispatch( - removeSliceWidgetCreator({ - variationId, - widgetsArea, - key, - }), - ); - }; - - const deleteSliceWidgetMock = ( - variationId: string, - widgetArea: WidgetsArea, - newKey: string, - ) => { - dispatch( - deleteSliceWidgetMockCreator({ - variationId, - widgetArea, - newKey, - }), - ); - }; - - const updateSliceWidgetMock = ( - variationId: string, - widgetArea: WidgetsArea, - previousKey: string, - newKey: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockValue: any, - ) => { - dispatch( - updateSliceWidgetMockCreator({ - variationId, - widgetArea, - previousKey, - newKey, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - mockValue, + updateSliceCreator.success({ + component, }), ); }; - const generateSliceCustomScreenshot = ( + const saveSliceCustomScreenshotSuccess = ( variationId: string, + screenshot: ScreenshotUI, component: ComponentUI, - file: Blob, - method: ScreenshotGenerationMethod, ) => { dispatch( - generateSliceCustomScreenshotCreator.request({ + generateSliceCustomScreenshotCreator.success({ variationId, + screenshot, component, - file, - method, - }), - ); - }; - - const updateSlice = ( - component: ComponentUI, - setSliceBuilderState: (sliceBuilderState: SliceBuilderState) => void, - ) => { - dispatch( - updateSliceCreator.request({ - component, - setSliceBuilderState, }), ); }; - const updateAndSaveSlice = (component: ComponentUI) => { - dispatch(updateAndSaveSliceCreator({ component })); - }; - - const copyVariationSlice = ( - key: string, - name: string, - copied: VariationSM, - ) => { - dispatch(copyVariationSliceCreator({ key, name, copied })); - }; - const createSliceSuccess = (libraries: readonly LibraryUI[]) => dispatch(createSlice({ libraries })); @@ -377,17 +252,8 @@ const useSliceMachineActions = () => { deleteCustomTypeSuccess, renameAvailableCustomTypeSuccess, saveCustomTypeSuccess, - initSliceStore, - addSliceWidget, - replaceSliceWidget, - reorderSliceWidget, - removeSliceWidget, - updateSliceWidgetMock, - deleteSliceWidgetMock, - generateSliceCustomScreenshot, - updateSlice, - updateAndSaveSlice, - copyVariationSlice, + saveSliceSuccess, + saveSliceCustomScreenshotSuccess, createSliceSuccess, renameSlice, deleteSlice, diff --git a/packages/slice-machine/src/redux/reducer.ts b/packages/slice-machine/src/redux/reducer.ts index c460e54337..3904ee0f64 100644 --- a/packages/slice-machine/src/redux/reducer.ts +++ b/packages/slice-machine/src/redux/reducer.ts @@ -12,7 +12,6 @@ import { simulatorReducer } from "@src/modules/simulator"; import { availableCustomTypesReducer } from "@src/modules/availableCustomTypes"; import { slicesReducer } from "@src/modules/slices"; import { routerReducer } from "connected-next-router"; -import { selectedSliceReducer } from "@src/modules/selectedSlice/reducer"; import { pushChangesReducer } from "@src/modules/pushChangesSaga"; /** Creates the main reducer */ @@ -25,7 +24,6 @@ const createReducer = (): Reducer => simulator: simulatorReducer, availableCustomTypes: availableCustomTypesReducer, slices: slicesReducer, - selectedSlice: selectedSliceReducer, router: routerReducer, pushChanges: pushChangesReducer, }); diff --git a/packages/slice-machine/src/redux/saga.ts b/packages/slice-machine/src/redux/saga.ts index 37e998c88b..d21c98b01f 100644 --- a/packages/slice-machine/src/redux/saga.ts +++ b/packages/slice-machine/src/redux/saga.ts @@ -2,10 +2,8 @@ import { fork } from "redux-saga/effects"; import { watchSimulatorSagas } from "@src/modules/simulator"; import { watchAvailableCustomTypesSagas } from "@src/modules/availableCustomTypes"; -import { selectedSliceSagas } from "@src/modules/selectedSlice/sagas"; import { watchSliceSagas } from "@src/modules/slices"; import { watchToasterSagas } from "@src/modules/toaster"; -import { screenshotsSagas } from "@src/modules/screenshots/sagas"; import { watchChangesPushSagas } from "@src/modules/pushChangesSaga"; import { watchChangelogSagas } from "@src/modules/environment"; @@ -15,8 +13,6 @@ export default function* rootSaga() { yield fork(watchAvailableCustomTypesSagas); yield fork(watchSliceSagas); yield fork(watchToasterSagas); - yield fork(screenshotsSagas); - yield fork(selectedSliceSagas); yield fork(watchChangesPushSagas); yield fork(watchChangelogSagas); } diff --git a/packages/slice-machine/src/redux/type.ts b/packages/slice-machine/src/redux/type.ts index 48064271db..d3f1b1412b 100644 --- a/packages/slice-machine/src/redux/type.ts +++ b/packages/slice-machine/src/redux/type.ts @@ -6,7 +6,6 @@ import { SimulatorStoreType } from "@src/modules/simulator/types"; import { AvailableCustomTypesStoreType } from "@src/modules/availableCustomTypes/types"; import { SlicesStoreType } from "@src/modules/slices/types"; import { RouterState } from "connected-next-router/types"; -import { SelectedSliceStoreType } from "@src/modules/selectedSlice/types"; import { PushChangesStoreType } from "@src/modules/pushChangesSaga"; export type SliceMachineStoreType = { @@ -17,7 +16,6 @@ export type SliceMachineStoreType = { simulator: SimulatorStoreType; availableCustomTypes: AvailableCustomTypesStoreType; slices: SlicesStoreType; - selectedSlice: SelectedSliceStoreType; router: RouterState; pushChanges: PushChangesStoreType; }; diff --git a/packages/slice-machine/test/src/modules/selectedSlice/__fixtures__/mocks.json b/packages/slice-machine/test/src/modules/selectedSlice/__fixtures__/mocks.json deleted file mode 100644 index fdf5e35965..0000000000 --- a/packages/slice-machine/test/src/modules/selectedSlice/__fixtures__/mocks.json +++ /dev/null @@ -1,171 +0,0 @@ -[ - { - "__TYPE__": "SharedSliceContent", - "variation": "default-slice", - "primary": { - "linkLabel": { - "__TYPE__": "FieldContent", - "value": "speed", - "type": "Text" - }, - "title": { - "__TYPE__": "StructuredTextContent", - "value": [ - { - "type": "heading1", - "content": { - "text": "Higher" - } - } - ] - }, - "link": { - "__TYPE__": "LinkContent", - "value": { - "id": "mock_document_id", - "__TYPE__": "DocumentLink" - } - }, - "imageLeft": { - "__TYPE__": "ImageContent", - "url": "https://images.unsplash.com/photo-1551739440-5dd934d3a94a?fit=crop&w=592&h=592", - "origin": { - "id": "main", - "url": "https://images.unsplash.com/photo-1551739440-5dd934d3a94a", - "width": 592, - "height": 592 - }, - "width": 592, - "height": 592, - "edit": { - "zoom": 1, - "crop": { - "x": 0, - "y": 0 - }, - "background": "transparent" - }, - "thumbnails": { - "mobile": { - "url": "https://images.unsplash.com/photo-1515378791036-0648a3ef77b2?fit=crop&w=560&h=280", - "origin": { - "id": "mobile", - "url": "https://images.unsplash.com/photo-1515378791036-0648a3ef77b2", - "width": 560, - "height": 280 - }, - "width": 560, - "height": 280, - "edit": { - "zoom": 1, - "crop": { - "x": 0, - "y": 0 - }, - "background": "transparent" - } - } - } - }, - "imageLeftTitle": { - "__TYPE__": "FieldContent", - "value": "rate", - "type": "Text" - }, - "imageLeftCta": { - "__TYPE__": "FieldContent", - "value": "funny", - "type": "Text" - }, - "leftImageLink": { - "__TYPE__": "LinkContent", - "value": { - "id": "mock_document_id", - "__TYPE__": "DocumentLink" - } - }, - "imageTopRight": { - "__TYPE__": "ImageContent", - "url": "https://images.unsplash.com/photo-1600861194802-a2b11076bc51?fit=crop&w=592&h=280", - "origin": { - "id": "main", - "url": "https://images.unsplash.com/photo-1600861194802-a2b11076bc51", - "width": 592, - "height": 280 - }, - "width": 592, - "height": 280, - "edit": { - "zoom": 1, - "crop": { - "x": 0, - "y": 0 - }, - "background": "transparent" - }, - "thumbnails": {} - }, - "imageTopRightTitle": { - "__TYPE__": "FieldContent", - "value": "soil", - "type": "Text" - }, - "imageTopRightCta": { - "__TYPE__": "FieldContent", - "value": "printed", - "type": "Text" - }, - "topRightImageLink": { - "__TYPE__": "LinkContent", - "value": { - "id": "mock_document_id", - "__TYPE__": "DocumentLink" - } - }, - "imageBottomRight": { - "__TYPE__": "ImageContent", - "url": "https://images.unsplash.com/photo-1607582278043-57198ac8da43?fit=crop&w=592&h=280", - "origin": { - "id": "main", - "url": "https://images.unsplash.com/photo-1607582278043-57198ac8da43", - "width": 592, - "height": 280 - }, - "width": 592, - "height": 280, - "edit": { - "zoom": 1, - "crop": { - "x": 0, - "y": 0 - }, - "background": "transparent" - }, - "thumbnails": {} - }, - "imageBottomRightTitle": { - "__TYPE__": "FieldContent", - "value": "smile", - "type": "Text" - }, - "imageBottomRightCta": { - "__TYPE__": "FieldContent", - "value": "exchange", - "type": "Text" - }, - "bottomRightImageLink": { - "__TYPE__": "LinkContent", - "value": { - "id": "mock_document_id", - "__TYPE__": "DocumentLink" - } - } - }, - "items": [ - { - "__TYPE__": "GroupItemContent", - "value": [] - } - ] - } -] diff --git a/packages/slice-machine/test/src/modules/selectedSlice/__fixtures__/model.json b/packages/slice-machine/test/src/modules/selectedSlice/__fixtures__/model.json deleted file mode 100644 index cb7757cdcc..0000000000 --- a/packages/slice-machine/test/src/modules/selectedSlice/__fixtures__/model.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "id": "dummy_slice", - "type": "SharedSlice", - "name": "DummySlice", - "description": "DummySlice", - "variations": [ - { - "id": "default-slice", - "name": "Default slice", - "docURL": "...", - "version": "sktwi1xtmkfgx8626", - "description": "DummySlice", - "primary": { - "section_title": { - "config": { - "label": "Section Title", - "placeholder": "" - }, - "type": "Text" - } - }, - "items": { - "link": { - "config": { - "label": "Link", - "placeholder": "", - "select": "document", - "allowTargetBlank": false - }, - "type": "Link" - } - } - } - ] -} diff --git a/packages/slice-machine/test/src/modules/selectedSlice/__testutils__/getRefreshStateCreatorPayloadData.ts b/packages/slice-machine/test/src/modules/selectedSlice/__testutils__/getRefreshStateCreatorPayloadData.ts deleted file mode 100644 index db3e3e7759..0000000000 --- a/packages/slice-machine/test/src/modules/selectedSlice/__testutils__/getRefreshStateCreatorPayloadData.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { dummyServerState } from "../../__fixtures__/serverState"; -import { LibraryUI } from "@lib/models/common/LibraryUI"; - -export const getRefreshStateCreatorPayloadData = ( - libraryName: string, - modelId: string, -) => { - const MOCK_UPDATED_LIBRARY: LibraryUI[] = [ - { - path: "../../e2e-projects/next/slices/ecommerce", - isLocal: true, - name: libraryName, - components: [ - { - from: "slices/ecommerce", - href: "slices--ecommerce", - pathToSlice: "./slices/ecommerce", - fileName: "index", - extension: "js", - screenshots: { - "default-slice": { - hash: "f92c69c60df8fd8eb42902bfb6574776", - url: "http://localhost:9999/api/__preview?q=default-slice", - }, - }, - mocks: [], - model: { - id: modelId, - type: "SharedSlice", - name: "CategoryPreviewWithImageBackgrounds", - description: "CategoryPreviewWithImageBackgrounds", - variations: [ - { - id: "default-slice", - name: "Default slice", - docURL: "...", - version: "sktwi1xtmkfgx8626", - description: "MockSlice", - primary: [ - { - key: "Title", - value: { - config: { - label: "Title", - placeholder: "My first Title...", - }, - type: "Text", - }, - }, - ], - items: [], - }, - ], - }, - }, - ], - meta: { - isNodeModule: false, - isDownloaded: false, - isManual: true, - }, - }, - ]; - - return { - env: dummyServerState.env, - libraries: MOCK_UPDATED_LIBRARY, - localCustomTypes: [], - remoteCustomTypes: [], - remoteSlices: [], - }; -}; diff --git a/packages/slice-machine/test/src/modules/selectedSlice/__testutils__/getSelectedSliceDummyData.ts b/packages/slice-machine/test/src/modules/selectedSlice/__testutils__/getSelectedSliceDummyData.ts deleted file mode 100644 index ea9ade50a1..0000000000 --- a/packages/slice-machine/test/src/modules/selectedSlice/__testutils__/getSelectedSliceDummyData.ts +++ /dev/null @@ -1,30 +0,0 @@ -import jsonModel from "../__fixtures__/model.json"; -import jsonMocks from "../__fixtures__/mocks.json"; - -import { ComponentUI } from "@lib/models/common/ComponentUI"; -import { Slices } from "@lib/models/common/Slice"; -import { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; -import { ComponentInfo } from "@lib/models/common/Library"; - -export const getSelectedSliceDummyData = () => { - const dummyModel = Slices.toSM(jsonModel as unknown as SharedSlice); - - const dummyModelVariationID = "default-slice"; - - const dummySliceState: ComponentUI = { - from: "slices/libName", - href: "slices--libName", - pathToSlice: "./slices/libName", - fileName: "index", - extension: "js", - model: dummyModel, - screenshots: {}, - mocks: jsonMocks as ComponentInfo["mocks"], - }; - - return { - dummyModel, - dummyModelVariationID, - dummySliceState, - }; -}; diff --git a/packages/slice-machine/test/src/modules/selectedSlice/reducer.test.ts b/packages/slice-machine/test/src/modules/selectedSlice/reducer.test.ts deleted file mode 100644 index 754294f63e..0000000000 --- a/packages/slice-machine/test/src/modules/selectedSlice/reducer.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { selectedSliceReducer } from "@src/modules/selectedSlice/reducer"; -import { - addSliceWidgetCreator, - copyVariationSliceCreator, - initSliceStoreCreator, - removeSliceWidgetCreator, - replaceSliceWidgetCreator, -} from "@src/modules/selectedSlice/actions"; -import { NestableWidget } from "@prismicio/types-internal/lib/customtypes"; -import { getSelectedSliceDummyData } from "./__testutils__/getSelectedSliceDummyData"; -import { getRefreshStateCreatorPayloadData } from "./__testutils__/getRefreshStateCreatorPayloadData"; -import { refreshStateCreator } from "@src/modules/environment"; -import { WidgetsArea } from "@lib/models/common/Slice"; - -const { dummyModelVariationID, dummySliceState } = getSelectedSliceDummyData(); - -describe("[Selected Slice module]", () => { - describe("[Reducer]", () => { - it("should return the initial state if no matching action", () => { - expect( - // @ts-expect-error the NO.MATCH is not a valid action type - selectedSliceReducer(dummySliceState, { type: "NO.MATCH" }), - ).toEqual(dummySliceState); - expect( - // @ts-expect-error the NO.MATCH is not a valid action type - selectedSliceReducer(null, { type: "NO.MATCH" }), - ).toEqual(null); - }); - - it("should update the selected slice state given SELECTED_SLICE/INIT action", () => { - expect( - selectedSliceReducer(null, initSliceStoreCreator(dummySliceState)), - ).toEqual(dummySliceState); - }); - it("should update the selected slice state given SLICE/ADD_WIDGET action", () => { - const primaryWidgetsInit = - dummySliceState.model.variations[0].primary || []; - - const newWidget: { key: string; value: NestableWidget } = { - key: "new-widget-text", - value: { - type: "Text", - config: { - label: "newWidgetText", - placeholder: "", - }, - }, - }; - - const newState = selectedSliceReducer( - dummySliceState, - addSliceWidgetCreator({ - variationId: dummyModelVariationID, - widgetsArea: WidgetsArea.Primary, - key: newWidget.key, - value: newWidget.value, - }), - ); - const primaryWidgets = newState?.model.variations[0].primary; - expect(primaryWidgets?.length).toEqual(primaryWidgetsInit.length + 1); - expect(primaryWidgets?.at(-1)?.key).toBe(newWidget.key); - expect(primaryWidgets?.at(-1)?.value).toBe(newWidget.value); - }); - it("should update the selected slice state given SLICE/REPLACE_WIDGET action if the tab is found", () => { - const primaryWidgetsInit = - dummySliceState.model.variations[0].primary || []; - - const widgetToReplace = primaryWidgetsInit[0]; - - const updatedWidget: { key: string; value: NestableWidget } = { - key: "new_key", - value: { - type: "Text", - config: { - label: "newWidgetText", - placeholder: "", - }, - }, - }; - - const newState = selectedSliceReducer( - dummySliceState, - replaceSliceWidgetCreator({ - variationId: dummyModelVariationID, - widgetsArea: WidgetsArea.Primary, - previousKey: widgetToReplace.key, - newKey: updatedWidget.key, - value: updatedWidget.value, - }), - ); - - const primaryWidgets = newState?.model.variations[0].primary; - expect(primaryWidgets?.length).toEqual(primaryWidgetsInit.length); - const replacedWidget = primaryWidgets?.find( - (w) => w.key === updatedWidget.key, - ); - expect(replacedWidget).toBeTruthy(); - expect(replacedWidget?.value).toBe(updatedWidget.value); - }); - it("should update the selected slice state given SLICE/REMOVE_WIDGET action", () => { - const primaryWidgetsInit = - dummySliceState.model.variations[0].primary || []; - - const widgetToDelete = primaryWidgetsInit[0]; - - const newState = selectedSliceReducer( - dummySliceState, - removeSliceWidgetCreator({ - variationId: dummyModelVariationID, - widgetsArea: WidgetsArea.Primary, - key: widgetToDelete.key, - }), - ); - - const primaryWidgets = newState?.model.variations[0].primary; - expect(primaryWidgets?.length).toEqual(0); - }); - it("should update the selected slice state given SLICE/COPY_VARIATION action", () => { - const preVariations = dummySliceState?.model.variations; - - const newState = selectedSliceReducer( - dummySliceState, - copyVariationSliceCreator({ - key: "new-variation", - name: "New Variation", - copied: dummySliceState.model.variations[0], - }), - ); - - const variations = newState?.model.variations; - - expect(variations?.length).toEqual(preVariations.length + 1); - expect(variations?.at(-1)?.id).toEqual("new-variation"); - expect(variations?.at(-1)?.name).toEqual("New Variation"); - }); - - it("should update the selected slice screenshots given STATE/REFRESH.RESPONSE action if the component is found", () => { - const action = refreshStateCreator( - getRefreshStateCreatorPayloadData( - dummySliceState.from, - dummySliceState.model.id, - ), - ); - - const newState = selectedSliceReducer(dummySliceState, action); - - expect(newState?.screenshots).toEqual({ - "default-slice": { - hash: "f92c69c60df8fd8eb42902bfb6574776", - url: "http://localhost:9999/api/__preview?q=default-slice", - }, - }); - }); - - it("should do nothing given STATE/REFRESH.RESPONSE action if the component is not found", () => { - const action = refreshStateCreator( - getRefreshStateCreatorPayloadData( - "unknown-livrary", - dummySliceState.model.id, - ), - ); - - const newState = selectedSliceReducer(dummySliceState, action); - - expect(newState?.screenshots).toEqual({}); - expect(newState).toEqual(dummySliceState); - }); - }); -}); diff --git a/packages/slice-machine/test/src/modules/selectedSlice/sagas.test.ts b/packages/slice-machine/test/src/modules/selectedSlice/sagas.test.ts deleted file mode 100644 index 7f2111cf96..0000000000 --- a/packages/slice-machine/test/src/modules/selectedSlice/sagas.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { testSaga } from "redux-saga-test-plan"; -import { updateSliceSaga } from "@src/modules/selectedSlice/sagas"; -import { updateSliceCreator } from "@src/modules/selectedSlice/actions"; -import { readSliceMocks, updateSlice } from "@src/apiClient"; -import { openToasterCreator, ToasterType } from "@src/modules/toaster"; -import { getSelectedSliceDummyData } from "./__testutils__/getSelectedSliceDummyData"; - -const { dummySliceState } = getSelectedSliceDummyData(); - -describe("[Selected Slice sagas]", () => { - describe("[updateSliceSaga]", () => { - it("should call the api and dispatch the success action", () => { - const mockSetSliceBuilderState = vi.fn< - { - error: boolean; - done: boolean; - loading: boolean; - message: { - props: { - message: string; - path: string; - }; - }; - }[] - >(); - const saga = testSaga( - updateSliceSaga, - updateSliceCreator.request({ - component: dummySliceState, - // @ts-expect-error - Issue with `message` type - setSliceBuilderState: mockSetSliceBuilderState, - }), - ); - - saga.next().call(updateSlice, dummySliceState); - - saga.next({ errors: [] }).call(readSliceMocks, { - libraryID: dummySliceState.from, - sliceID: dummySliceState.model.id, - }); - - saga.next({ errors: [], mocks: [] }).put( - updateSliceCreator.success({ - component: { ...dummySliceState, mocks: [] }, - }), - ); - - saga.next().isDone(); - - const mockSetSliceBuilderStateCalls = - mockSetSliceBuilderState.mock.lastCall?.[0]; - expect(mockSetSliceBuilderStateCalls?.error).toBe(false); - expect(mockSetSliceBuilderStateCalls?.done).toBe(true); - expect(mockSetSliceBuilderStateCalls?.loading).toBe(false); - expect(mockSetSliceBuilderStateCalls?.message.props.message).toBe( - "Slice saved successfully at ", - ); - expect(mockSetSliceBuilderStateCalls?.message.props.path).toBe( - "slices/libName/DummySlice/model.json", - ); - }); - it("should open a error toaster on internal error", () => { - const mockSetSliceBuilderState = vi.fn(); - const saga = testSaga( - updateSliceSaga, - updateSliceCreator.request({ - component: dummySliceState, - setSliceBuilderState: mockSetSliceBuilderState, - }), - ).next(); - - saga.throw(new Error()).put( - openToasterCreator({ - content: "Internal Error: Models & mocks not generated", - type: ToasterType.ERROR, - }), - ); - saga.next().isDone(); - }); - }); -}); diff --git a/packages/slice-machine/test/src/modules/simulator.test.ts b/packages/slice-machine/test/src/modules/simulator.test.ts index b3b03c6e9b..2f8c1de72c 100644 --- a/packages/slice-machine/test/src/modules/simulator.test.ts +++ b/packages/slice-machine/test/src/modules/simulator.test.ts @@ -26,7 +26,6 @@ import { updateSliceMock } from "@src/modules/slices"; import { modalOpenCreator } from "@src/modules/modal"; import { ModalKeysEnum } from "@src/modules/modal/types"; import { checkSimulatorSetup, getSimulatorSetupSteps } from "@src/apiClient"; -import { updateSelectedSliceMocks } from "@src/modules/selectedSlice/actions"; import { createTestPlugin } from "test/__testutils__/createTestPlugin"; import { createTestProject } from "test/__testutils__/createTestProject"; import { createSliceMachineManager } from "@slicemachine/manager"; @@ -284,7 +283,6 @@ describe("[Simulator module]", () => { }), ) .put(updateSliceMock(payload.payload)) - .put(updateSelectedSliceMocks({ mocks: payload.payload.mocks })) .put(saveSliceMockCreator.success()) .run(); diff --git a/packages/slice-machine/test/src/modules/toaster/utils.test.ts b/packages/slice-machine/test/src/modules/toaster/utils.test.ts deleted file mode 100644 index fcc69af71c..0000000000 --- a/packages/slice-machine/test/src/modules/toaster/utils.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, afterEach, expect, it, vi } from "vitest"; - -import { handleRemoteResponse, ToastPayload } from "@src/modules/toaster/utils"; -import { ToasterType } from "@src/modules/toaster"; - -describe("[Toaster utils]", () => { - describe("[handleRemoteResponse]", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should call addToast function on an success", () => { - const addToastFakeFunction = vi.fn(); - const toastPayload: ToastPayload = { - done: true, - message: "message", - error: false, - }; - handleRemoteResponse(addToastFakeFunction)(toastPayload); - - expect(addToastFakeFunction).toHaveBeenCalledWith( - "message", - ToasterType.SUCCESS, - ); - }); - - it("shouldn't call addToast function on an unfinished request", () => { - const addToastFakeFunction = vi.fn(); - const toastPayload: ToastPayload = { done: false }; - handleRemoteResponse(addToastFakeFunction)(toastPayload); - - expect(addToastFakeFunction).toHaveBeenCalledTimes(0); - }); - - it("shouldn't call addToast function on a request with errors", () => { - const addToastFakeFunction = vi.fn(); - const toastPayload: ToastPayload = { - done: true, - message: "message", - error: true, - }; - handleRemoteResponse(addToastFakeFunction)(toastPayload); - - expect(addToastFakeFunction).toHaveBeenCalledWith( - "message", - ToasterType.ERROR, - ); - }); - }); -}); diff --git a/playwright/mocks/images/slice-screenshot-imageLeft.png b/playwright/mocks/images/slice-screenshot-imageLeft.png new file mode 100644 index 0000000000..9833529f23 Binary files /dev/null and b/playwright/mocks/images/slice-screenshot-imageLeft.png differ diff --git a/playwright/pages/SliceBuilderPage.ts b/playwright/pages/SliceBuilderPage.ts index 2373cfc3ba..5ffc2f670b 100644 --- a/playwright/pages/SliceBuilderPage.ts +++ b/playwright/pages/SliceBuilderPage.ts @@ -5,17 +5,24 @@ import { RenameVariationDialog } from "./components/RenameVariationDialog"; import { DeleteVariationDialog } from "./components/DeleteVariationDialog"; import { BuilderPage } from "./shared/BuilderPage"; import { SlicesListPage } from "./SlicesListPage"; +import { FieldTypeLabel } from "./components/AddFieldDialog"; +import { UpdateScreenshotDialog } from "./components/UpdateScreenshotDialog"; + +type ZoneType = "static" | "repeatable"; export class SliceBuilderPage extends BuilderPage { readonly slicesListPage: SlicesListPage; readonly addVariationDialog: AddVariationDialog; readonly renameVariationDialog: RenameVariationDialog; readonly deleteVariationDialog: DeleteVariationDialog; + readonly updateScreenshotDialog: UpdateScreenshotDialog; readonly simulateTooltipTitle: Locator; readonly simulateTooltipCloseButton: Locator; readonly variationCards: Locator; readonly addVariationButton: Locator; + readonly noScreenshotMessage: Locator; readonly repeatableZone: Locator; + readonly repeatableZoneAddFieldButton: Locator; readonly repeatableZonePlaceholder: Locator; readonly repeatableZoneListItem: Locator; @@ -29,6 +36,7 @@ export class SliceBuilderPage extends BuilderPage { this.addVariationDialog = new AddVariationDialog(page); this.renameVariationDialog = new RenameVariationDialog(page); this.deleteVariationDialog = new DeleteVariationDialog(page); + this.updateScreenshotDialog = new UpdateScreenshotDialog(page); /** * Static locators @@ -44,8 +52,14 @@ export class SliceBuilderPage extends BuilderPage { this.addVariationButton = page.getByText("Add a new variation", { exact: true, }); + this.noScreenshotMessage = page.getByText("No screenshot available", { + exact: true, + }); // Repeatable zone this.repeatableZone = page.getByTestId("slice-repeatable-zone"); + this.repeatableZoneAddFieldButton = page.getByTestId( + "add-Repeatable-field", + ); this.repeatableZonePlaceholder = page.getByText( "Add a field to your Repeatable Zone", { exact: true }, @@ -67,6 +81,49 @@ export class SliceBuilderPage extends BuilderPage { }); } + getListItem(fieldId: string, zoneType: ZoneType) { + if (zoneType === "static") { + return this.page + .getByTestId("static-zone-content") + .getByTestId(`list-item-${fieldId}`); + } + + return this.page + .getByTestId("slice-repeatable-zone") + .getByTestId(`list-item-${fieldId}`); + } + + getListItemFieldName(fieldId: string, fieldName: string, zoneType: ZoneType) { + return this.getListItem(fieldId, zoneType) + .getByTestId("field-name") + .getByText(fieldName, { exact: true }); + } + + getEditFieldButton(fieldId: string, zoneType: ZoneType) { + return this.getListItem(fieldId, zoneType).getByRole("button", { + name: "Edit field", + exact: true, + }); + } + + getFieldMenuButton(fieldId: string, zoneType: ZoneType) { + return this.getListItem(fieldId, zoneType).getByTestId("field-menu-button"); + } + + getListItemFieldId(fieldId: string, zoneType: ZoneType) { + let fieldSubId; + + if (zoneType === "static") { + fieldSubId = "primary"; + } else { + fieldSubId = "items[i]"; + } + + return this.getListItem(fieldId, zoneType) + .getByTestId("field-id") + .getByText(`slice.${fieldSubId}.${fieldId}`, { exact: true }); + } + /** * Actions */ @@ -81,7 +138,7 @@ export class SliceBuilderPage extends BuilderPage { async openVariationActionMenu( name: string, variation: string, - action: "Rename" | "Delete", + action: "Rename" | "Delete" | "Update screenshot", ) { await this.getVariationCard(name, variation) .getByRole("button", { name: "Slice actions", exact: true }) @@ -96,6 +153,61 @@ export class SliceBuilderPage extends BuilderPage { await this.addVariationButton.click(); } + async addField(args: { + type: FieldTypeLabel; + name: string; + expectedId: string; + zoneType: ZoneType; + }) { + const { type, name, expectedId, zoneType } = args; + + if (zoneType === "static") { + await this.staticZoneAddFieldButton.click(); + } else { + await this.repeatableZoneAddFieldButton.click(); + } + + await expect(this.addFieldDialog.title).toBeVisible(); + await this.addFieldDialog.selectField(type); + await this.newFieldNameInput.fill(name); + await expect(this.newFieldIdInput).toHaveValue(expectedId); + await this.newFieldAddButton.click(); + await expect(this.addFieldDialog.title).not.toBeVisible(); + } + + async deleteField(fieldId: string, zoneType: ZoneType) { + await this.getFieldMenuButton(fieldId, zoneType).click(); + await this.page.getByRole("menuitem", { name: "Delete field" }).click(); + } + + async copyCodeSnippet(fieldId: string, zoneType: ZoneType) { + await this.getListItem(fieldId, zoneType) + .getByRole("button", { + name: "Copy code snippet", + exact: true, + }) + .click(); + + const handle = await this.page.evaluateHandle(() => + navigator.clipboard.readText(), + ); + const clipboardContent = await handle.jsonValue(); + expect(clipboardContent).toContain(fieldId); + + await expect( + this.getListItem(fieldId, zoneType).getByRole("button", { + name: "Code snippet copied", + exact: true, + }), + ).toBeVisible(); + await expect( + this.getListItem(fieldId, zoneType).getByRole("button", { + name: "Copy code snippet", + exact: true, + }), + ).toBeVisible(); + } + /** * Assertions */ diff --git a/playwright/pages/components/AddFieldDialog.ts b/playwright/pages/components/AddFieldDialog.ts index b2eea338fe..cce8f399b7 100644 --- a/playwright/pages/components/AddFieldDialog.ts +++ b/playwright/pages/components/AddFieldDialog.ts @@ -1,8 +1,24 @@ import { expect, Locator, Page } from "@playwright/test"; -import { FieldType } from "../shared/BuilderPage"; import { Dialog } from "./Dialog"; +export type FieldTypeLabel = + | "Rich Text" + | "Image" + | "Link" + | "Link to media" + | "Content Relationship" + | "Select" + | "Boolean" + | "Date" + | "Timestamp" + | "Embed" + | "Number" + | "GeoPoint" + | "Color" + | "Key Text" + | "Group"; + export class AddFieldDialog extends Dialog { constructor(page: Page) { super(page, { @@ -23,7 +39,7 @@ export class AddFieldDialog extends Dialog { /** * Dynamic locators */ - getField(fieldType: FieldType): Locator { + getField(fieldType: FieldTypeLabel): Locator { return this.dialog.getByRole("heading", { name: fieldType, exact: true, @@ -33,7 +49,7 @@ export class AddFieldDialog extends Dialog { /** * Actions */ - async selectField(fieldType: FieldType) { + async selectField(fieldType: FieldTypeLabel) { await this.getField(fieldType).click(); await expect(this.title).not.toBeVisible(); } diff --git a/playwright/pages/components/UpdateScreenshotDialog.ts b/playwright/pages/components/UpdateScreenshotDialog.ts new file mode 100644 index 0000000000..fa6c39fa2a --- /dev/null +++ b/playwright/pages/components/UpdateScreenshotDialog.ts @@ -0,0 +1,47 @@ +import { expect, Locator, Page } from "@playwright/test"; + +import { Dialog } from "./Dialog"; + +export class UpdateScreenshotDialog extends Dialog { + screenshot: Locator; + screenshotPlaceholder: Locator; + selectFileInput: Locator; + + constructor(page: Page) { + super(page, { + title: "Slice screenshots", + }); + + /** + * Static locators + */ + this.screenshot = this.dialog.getByRole("img", { name: "Preview image" }); + this.screenshotPlaceholder = this.dialog.getByText("Paste, drop or ...", { + exact: true, + }); + this.selectFileInput = this.dialog.getByText("Select file", { + exact: true, + }); + } + + /** + * Dynamic locators + */ + // Handle dynamic locators here + + /** + * Actions + */ + async updateScreenshot(fileName: string) { + await expect(this.title).toBeVisible(); + const fileChooserPromise = this.page.waitForEvent("filechooser"); + await this.selectFileInput.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(`mocks/images/${fileName}.png`); + } + + /** + * Assertions + */ + // Handle assertions here +} diff --git a/playwright/pages/shared/BuilderPage.ts b/playwright/pages/shared/BuilderPage.ts index 305b5dad92..def40a5b7d 100644 --- a/playwright/pages/shared/BuilderPage.ts +++ b/playwright/pages/shared/BuilderPage.ts @@ -1,39 +1,19 @@ -import { expect, Locator, Page } from "@playwright/test"; +import { Locator, Page } from "@playwright/test"; import { AddFieldDialog } from "../components/AddFieldDialog"; import { EditFieldDialog } from "../components/EditFieldDialog"; import { SliceMachinePage } from "../SliceMachinePage"; -export type FieldType = - | "Rich Text" - | "Image" - | "Link" - | "Link to media" - | "Content Relationship" - | "Select" - | "Boolean" - | "Date" - | "Timestamp" - | "Embed" - | "Number" - | "GeoPoint" - | "Color" - | "Key Text" - | "Group"; - export class BuilderPage extends SliceMachinePage { readonly addFieldDialog: AddFieldDialog; readonly editFieldDialog: EditFieldDialog; readonly saveButton: Locator; - readonly showCodeSnippetsButton: Locator; - readonly hideCodeSnippetsButton: Locator; readonly autoSaveStatusSaved: Locator; readonly autoSaveStatusSaving: Locator; readonly autoSaveStatusError: Locator; readonly autoSaveRetryButton: Locator; readonly staticZoneContent: Locator; readonly staticZoneAddFieldButton: Locator; - readonly staticZonePlaceholder: Locator; readonly staticZoneListItem: Locator; readonly codeSnippetsFieldSwitch: Locator; readonly newFieldNameInput: Locator; @@ -54,14 +34,6 @@ export class BuilderPage extends SliceMachinePage { */ // header this.saveButton = page.getByRole("button", { name: "Save", exact: true }); - this.showCodeSnippetsButton = page.getByRole("button", { - name: "Show code snippets", - exact: true, - }); - this.hideCodeSnippetsButton = page.getByRole("button", { - name: "Hide code snippets", - exact: true, - }); // Auto save status this.autoSaveStatusSaved = page.getByText("Auto-saved", { exact: true }); this.autoSaveStatusSaving = page.getByText("Saving...", { exact: true }); @@ -75,10 +47,6 @@ export class BuilderPage extends SliceMachinePage { // Static zone this.staticZoneContent = page.getByTestId("static-zone-content"); this.staticZoneAddFieldButton = page.getByTestId("add-Static-field"); - this.staticZonePlaceholder = this.staticZoneContent.getByText( - "Add a field to your Static Zone", - { exact: true }, - ); this.staticZoneListItem = this.staticZoneContent.getByRole("listitem"); // Code snippets this.codeSnippetsFieldSwitch = page.getByTestId("code-snippets-switch"); @@ -94,113 +62,12 @@ export class BuilderPage extends SliceMachinePage { /** * Dynamic locators */ - getListItem(fieldId: string, groupFieldId?: string) { - if (groupFieldId) { - return this.page.getByTestId( - `list-item-group-${groupFieldId}-${fieldId}`, - ); - } - - return this.page.getByTestId(`list-item-${fieldId}`); - } - - getListItemFieldName( - fieldId: string, - fieldName: string, - groupFieldId?: string, - ) { - return this.getListItem(fieldId, groupFieldId) - .getByTestId("field-name") - .getByText(fieldName, { exact: true }); - } - - getListItemFieldId(fieldId: string, groupFieldId?: string) { - if (groupFieldId) { - return this.getListItem(fieldId, groupFieldId) - .getByTestId("field-id") - .getByText(`data.${groupFieldId}.${fieldId}`, { exact: true }); - } - - return this.getListItem(fieldId) - .getByTestId("field-id") - .getByText(`data.${fieldId}`, { exact: true }); - } - - getEditFieldButton(fieldId: string, groupFieldId?: string) { - return this.getListItem(fieldId, groupFieldId).getByRole("button", { - name: "Edit field", - exact: true, - }); - } - - getFieldMenuButton(fieldId: string, groupFieldId?: string) { - return this.getListItem(fieldId, groupFieldId).getByTestId( - "field-menu-button", - ); - } + // Handle dynamic locators here /** * Actions */ - async addStaticField(args: { - type: FieldType; - name: string; - expectedId: string; - groupFieldId?: string; - }) { - const { type, name, expectedId, groupFieldId } = args; - - if (groupFieldId) { - await this.getListItem(groupFieldId) - .getByRole("button", { - name: "Add Field", - exact: true, - }) - .click(); - } else { - await this.staticZoneAddFieldButton.click(); - } - - await expect(this.addFieldDialog.title).toBeVisible(); - await this.addFieldDialog.selectField(type); - await this.newFieldNameInput.fill(name); - await expect(this.newFieldIdInput).toHaveValue(expectedId); - await this.newFieldAddButton.click(); - await expect(this.addFieldDialog.title).not.toBeVisible(); - } - - async deleteField(fieldId: string, groupFieldId?: string) { - await this.getFieldMenuButton(fieldId, groupFieldId).click(); - await this.page.getByRole("menuitem", { name: "Delete field" }).click(); - } - - async copyCodeSnippet(fieldId: string) { - await this.getListItem(fieldId) - .getByRole("button", { - name: "Copy code snippet", - exact: true, - }) - .click(); - - const handle = await this.page.evaluateHandle(() => - navigator.clipboard.readText(), - ); - const clipboardContent = await handle.jsonValue(); - expect(clipboardContent).toContain(fieldId); - - await expect( - this.getListItem(fieldId).getByRole("button", { - name: "Code snippet copied", - exact: true, - }), - ).toBeVisible(); - await expect( - this.getListItem(fieldId).getByRole("button", { - name: "Copy code snippet", - exact: true, - }), - ).toBeVisible(); - } + // Handle actions here /** * Assertions diff --git a/playwright/pages/shared/TypeBuilderPage.ts b/playwright/pages/shared/TypeBuilderPage.ts index 1e701a153b..f177b7acaf 100644 --- a/playwright/pages/shared/TypeBuilderPage.ts +++ b/playwright/pages/shared/TypeBuilderPage.ts @@ -13,6 +13,7 @@ import { DeleteSliceZoneDialog } from "../components/DeleteSliceZoneDialog"; import { CustomTypesTablePage } from "../CustomTypesTablePage"; import { PageTypesTablePage } from "../PageTypesTablePage"; import { BuilderPage } from "./BuilderPage"; +import { FieldTypeLabel } from "../components/AddFieldDialog"; export class TypeBuilderPage extends BuilderPage { readonly createTypeDialog: CreateTypeDialog; @@ -148,6 +149,51 @@ export class TypeBuilderPage extends BuilderPage { }); } + getListItem(fieldId: string, groupFieldId?: string) { + if (groupFieldId) { + return this.page.getByTestId( + `list-item-group-${groupFieldId}-${fieldId}`, + ); + } + + return this.page.getByTestId(`list-item-${fieldId}`); + } + + getListItemFieldName( + fieldId: string, + fieldName: string, + groupFieldId?: string, + ) { + return this.getListItem(fieldId, groupFieldId) + .getByTestId("field-name") + .getByText(fieldName, { exact: true }); + } + + getEditFieldButton(fieldId: string, groupFieldId?: string) { + return this.getListItem(fieldId, groupFieldId).getByRole("button", { + name: "Edit field", + exact: true, + }); + } + + getFieldMenuButton(fieldId: string, groupFieldId?: string) { + return this.getListItem(fieldId, groupFieldId).getByTestId( + "field-menu-button", + ); + } + + getListItemFieldId(fieldId: string, groupFieldId?: string) { + if (groupFieldId) { + return this.getListItem(fieldId, groupFieldId) + .getByTestId("field-id") + .getByText(`data.${groupFieldId}.${fieldId}`, { exact: true }); + } + + return this.getListItem(fieldId) + .getByTestId("field-id") + .getByText(`data.${fieldId}`, { exact: true }); + } + /** * Actions */ @@ -177,6 +223,66 @@ export class TypeBuilderPage extends BuilderPage { .click(); } + async addStaticField(args: { + type: FieldTypeLabel; + name: string; + expectedId: string; + groupFieldId?: string; + }) { + const { type, name, expectedId, groupFieldId } = args; + + if (groupFieldId) { + await this.getListItem(groupFieldId) + .getByRole("button", { + name: "Add Field", + exact: true, + }) + .click(); + } else { + await this.staticZoneAddFieldButton.click(); + } + + await expect(this.addFieldDialog.title).toBeVisible(); + await this.addFieldDialog.selectField(type); + await this.newFieldNameInput.fill(name); + await expect(this.newFieldIdInput).toHaveValue(expectedId); + await this.newFieldAddButton.click(); + await expect(this.addFieldDialog.title).not.toBeVisible(); + } + + async deleteField(fieldId: string, groupFieldId?: string) { + await this.getFieldMenuButton(fieldId, groupFieldId).click(); + await this.page.getByRole("menuitem", { name: "Delete field" }).click(); + } + + async copyCodeSnippet(fieldId: string) { + await this.getListItem(fieldId) + .getByRole("button", { + name: "Copy code snippet", + exact: true, + }) + .click(); + + const handle = await this.page.evaluateHandle(() => + navigator.clipboard.readText(), + ); + const clipboardContent = await handle.jsonValue(); + expect(clipboardContent).toContain(fieldId); + + await expect( + this.getListItem(fieldId).getByRole("button", { + name: "Code snippet copied", + exact: true, + }), + ).toBeVisible(); + await expect( + this.getListItem(fieldId).getByRole("button", { + name: "Copy code snippet", + exact: true, + }), + ).toBeVisible(); + } + /** * Assertions */ diff --git a/playwright/tests/slices/sliceBuilder.spec.ts b/playwright/tests/slices/sliceBuilder.spec.ts deleted file mode 100644 index 66d86aef6e..0000000000 --- a/playwright/tests/slices/sliceBuilder.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { expect } from "@playwright/test"; - -import { test } from "../../fixtures"; -import { generateRandomId } from "../../utils/generateRandomId"; - -test.run()("I can add a new variation", async ({ slice, sliceBuilderPage }) => { - await sliceBuilderPage.goto(slice.name); - await expect(sliceBuilderPage.variationCards).toHaveCount(1); - - await sliceBuilderPage.openAddVariationDialog(); - const variationName = `Variation ${generateRandomId()}`; - await sliceBuilderPage.addVariationDialog.addVariation(variationName); - - await expect(sliceBuilderPage.variationCards).toHaveCount(2); - await expect( - sliceBuilderPage.getVariationCard(slice.name, variationName), - ).toBeVisible(); -}); - -test.run()("I can rename a variation", async ({ slice, sliceBuilderPage }) => { - await sliceBuilderPage.goto(slice.name); - - const defaultVariationName = "Default"; - await sliceBuilderPage.openVariationActionMenu( - slice.name, - defaultVariationName, - "Rename", - ); - const newVariationName = `${defaultVariationName}Renamed`; - await sliceBuilderPage.renameVariationDialog.renameVariation( - newVariationName, - ); - - await expect(sliceBuilderPage.variationCards).toHaveCount(1); - await expect( - sliceBuilderPage.getVariationCard(slice.name, defaultVariationName), - ).not.toBeVisible(); - await expect( - sliceBuilderPage.getVariationCard(slice.name, newVariationName), - ).toBeVisible(); -}); - -test.run()("I can delete a variation", async ({ slice, sliceBuilderPage }) => { - await sliceBuilderPage.goto(slice.name); - await sliceBuilderPage.openAddVariationDialog(); - const variationName = `Variation ${generateRandomId()}`; - await sliceBuilderPage.addVariationDialog.addVariation(variationName); - - await sliceBuilderPage.openVariationActionMenu( - slice.name, - variationName, - "Delete", - ); - await sliceBuilderPage.deleteVariationDialog.deleteVariation(); - - await expect(sliceBuilderPage.variationCards).toHaveCount(1); - await expect( - sliceBuilderPage.getVariationCard(slice.name, variationName), - ).not.toBeVisible(); -}); - -test.run()( - "I can add a static field to the builder", - async ({ slice, sliceBuilderPage }) => { - await sliceBuilderPage.goto(slice.name); - - await expect(sliceBuilderPage.staticZoneListItem).toHaveCount(0); - - await sliceBuilderPage.addStaticField({ - type: "Rich Text", - name: "My Rich Text", - expectedId: "my_rich_text", - }); - - await expect(sliceBuilderPage.staticZoneListItem).toHaveCount(1); - }, -); - -test.run({ onboarded: false })( - "I can close the simulator tooltip and it stays close", - async ({ slice, sliceBuilderPage }) => { - await sliceBuilderPage.goto(slice.name); - - // Simulator tooltip should open automatically - await expect(sliceBuilderPage.simulateTooltipTitle).toBeVisible(); - await sliceBuilderPage.simulateTooltipCloseButton.click(); - await expect(sliceBuilderPage.simulateTooltipTitle).not.toBeVisible(); - - await sliceBuilderPage.page.reload(); - await expect(sliceBuilderPage.getBreadcrumbLabel(slice.name)).toBeVisible(); - - await expect(sliceBuilderPage.simulateTooltipTitle).not.toBeVisible(); - }, -); diff --git a/playwright/tests/slices/sliceBuilderCommon.spec.ts b/playwright/tests/slices/sliceBuilderCommon.spec.ts new file mode 100644 index 0000000000..74213889ac --- /dev/null +++ b/playwright/tests/slices/sliceBuilderCommon.spec.ts @@ -0,0 +1,181 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; +import { generateRandomId } from "../../utils/generateRandomId"; + +test.run()("I can add a new variation", async ({ slice, sliceBuilderPage }) => { + await sliceBuilderPage.goto(slice.name); + await expect(sliceBuilderPage.variationCards).toHaveCount(1); + + await sliceBuilderPage.openAddVariationDialog(); + const variationName = `Variation ${generateRandomId()}`; + await sliceBuilderPage.addVariationDialog.addVariation(variationName); + + await expect(sliceBuilderPage.variationCards).toHaveCount(2); + await expect( + sliceBuilderPage.getVariationCard(slice.name, variationName), + ).toBeVisible(); +}); + +test.run()("I can rename a variation", async ({ slice, sliceBuilderPage }) => { + await sliceBuilderPage.goto(slice.name); + + const defaultVariationName = "Default"; + await sliceBuilderPage.openVariationActionMenu( + slice.name, + defaultVariationName, + "Rename", + ); + const newVariationName = `${defaultVariationName}Renamed`; + await sliceBuilderPage.renameVariationDialog.renameVariation( + newVariationName, + ); + + await expect(sliceBuilderPage.variationCards).toHaveCount(1); + await expect( + sliceBuilderPage.getVariationCard(slice.name, defaultVariationName), + ).not.toBeVisible(); + await expect( + sliceBuilderPage.getVariationCard(slice.name, newVariationName), + ).toBeVisible(); +}); + +test.run()("I can delete a variation", async ({ slice, sliceBuilderPage }) => { + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.openAddVariationDialog(); + const variationName = `Variation ${generateRandomId()}`; + await sliceBuilderPage.addVariationDialog.addVariation(variationName); + + await sliceBuilderPage.openVariationActionMenu( + slice.name, + variationName, + "Delete", + ); + await sliceBuilderPage.deleteVariationDialog.deleteVariation(); + + await expect(sliceBuilderPage.variationCards).toHaveCount(1); + await expect( + sliceBuilderPage.getVariationCard(slice.name, variationName), + ).not.toBeVisible(); +}); + +test.run({ onboarded: false })( + "I can close the simulator tooltip and it stays close", + async ({ slice, sliceBuilderPage }) => { + await sliceBuilderPage.goto(slice.name); + + // Simulator tooltip should open automatically + await expect(sliceBuilderPage.simulateTooltipTitle).toBeVisible(); + await sliceBuilderPage.simulateTooltipCloseButton.click(); + await expect(sliceBuilderPage.simulateTooltipTitle).not.toBeVisible(); + + await sliceBuilderPage.page.reload(); + await expect(sliceBuilderPage.getBreadcrumbLabel(slice.name)).toBeVisible(); + + await expect(sliceBuilderPage.simulateTooltipTitle).not.toBeVisible(); + }, +); + +test.run()( + "I can see my changes auto-saved", + async ({ sliceBuilderPage, slice }) => { + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "static", + }); + + await expect(sliceBuilderPage.autoSaveStatusSaved).toBeVisible(); + await sliceBuilderPage.page.reload(); + await expect(sliceBuilderPage.autoSaveStatusSaved).toBeVisible(); + }, +); + +test.run()( + "I can see my changes being saved", + async ({ sliceBuilderPage, slice, procedures }) => { + procedures.mock("slices.updateSlice", ({ data }) => data, { + delay: 2000, + }); + + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "static", + }); + + await expect(sliceBuilderPage.autoSaveStatusSaving).toBeVisible(); + await expect(sliceBuilderPage.autoSaveStatusSaved).toBeVisible(); + await sliceBuilderPage.page.reload(); + await expect(sliceBuilderPage.autoSaveStatusSaved).toBeVisible(); + }, +); + +test.run()( + "I can see that my changes failed to save and I can retry", + async ({ sliceBuilderPage, slice, procedures }) => { + procedures.mock("slices.updateSlice", () => ({ errors: [{}] }), { + execute: false, + times: 1, + }); + + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "static", + }); + + await expect(sliceBuilderPage.autoSaveStatusError).toBeVisible(); + await sliceBuilderPage.autoSaveRetryButton.click(); + await expect(sliceBuilderPage.autoSaveStatusSaving).toBeVisible(); + await expect(sliceBuilderPage.autoSaveStatusSaved).toBeVisible(); + await sliceBuilderPage.page.reload(); + await expect(sliceBuilderPage.autoSaveStatusSaved).toBeVisible(); + }, +); + +test.run()( + "I cannot see a save happening when I first load the page", + async ({ sliceBuilderPage, slice, procedures }) => { + procedures.mock("slices.updateSlice", ({ data }) => data, { + delay: 2000, + }); + + await sliceBuilderPage.goto(slice.name); + await expect(sliceBuilderPage.autoSaveStatusSaving).not.toBeVisible({ + // As soon as it's visible it's a problem + timeout: 1, + }); + }, +); + +test.run()("I can add a screenshot", async ({ slice, sliceBuilderPage }) => { + await sliceBuilderPage.goto(slice.name); + + await expect(sliceBuilderPage.noScreenshotMessage).toBeVisible(); + await sliceBuilderPage.openVariationActionMenu( + slice.name, + "Default", + "Update screenshot", + ); + + await expect( + sliceBuilderPage.updateScreenshotDialog.screenshotPlaceholder, + ).toBeVisible(); + await sliceBuilderPage.updateScreenshotDialog.updateScreenshot( + "slice-screenshot-imageLeft", + ); + await expect( + sliceBuilderPage.updateScreenshotDialog.screenshotPlaceholder, + ).not.toBeVisible(); + + await sliceBuilderPage.page.reload(); + await expect(sliceBuilderPage.breadcrumb).toBeVisible(); + await expect(sliceBuilderPage.noScreenshotMessage).not.toBeVisible(); +}); diff --git a/playwright/tests/slices/sliceBuilderFields.spec.ts b/playwright/tests/slices/sliceBuilderFields.spec.ts new file mode 100644 index 0000000000..62950d78bb --- /dev/null +++ b/playwright/tests/slices/sliceBuilderFields.spec.ts @@ -0,0 +1,159 @@ +import { expect } from "@playwright/test"; + +import { test } from "../../fixtures"; + +test.run()( + "I can add a rich text field in the static zone", + async ({ sliceBuilderPage, slice }) => { + await sliceBuilderPage.goto(slice.name); + + await expect(sliceBuilderPage.staticZoneListItem).toHaveCount(0); + + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "static", + }); + + await expect(sliceBuilderPage.staticZoneListItem).toHaveCount(1); + }, +); + +test.run()( + "I can edit a rich text field in the static zone", + async ({ sliceBuilderPage, slice }) => { + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "static", + }); + + await sliceBuilderPage.getEditFieldButton("my_rich_text", "static").click(); + await sliceBuilderPage.editFieldDialog.editField({ + name: "My Rich Text", + newName: "My Rich Text Renamed", + newId: "my_rich_text_renamed", + }); + + await expect( + sliceBuilderPage.getListItemFieldId("my_rich_text_renamed", "static"), + ).toBeVisible(); + await expect( + sliceBuilderPage.getListItemFieldName( + "my_rich_text_renamed", + "My Rich Text Renamed", + "static", + ), + ).toBeVisible(); + }, +); + +test.run()( + "I can delete a field in the static zone", + async ({ sliceBuilderPage, slice }) => { + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "static", + }); + + await sliceBuilderPage.deleteField("my_rich_text", "static"); + + await expect( + sliceBuilderPage.getListItemFieldId("my_rich_text", "static"), + ).not.toBeVisible(); + }, +); + +test.run()( + "I can add a rich text field in the repeatable zone", + async ({ sliceBuilderPage, slice }) => { + await sliceBuilderPage.goto(slice.name); + + await expect(sliceBuilderPage.repeatableZoneListItem).toHaveCount(0); + + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "repeatable", + }); + + await expect(sliceBuilderPage.repeatableZoneListItem).toHaveCount(1); + }, +); + +test.run()( + "I can edit a rich text field in the repeatable zone", + async ({ sliceBuilderPage, slice }) => { + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "repeatable", + }); + + await sliceBuilderPage + .getEditFieldButton("my_rich_text", "repeatable") + .click(); + await sliceBuilderPage.editFieldDialog.editField({ + name: "My Rich Text", + newName: "My Rich Text Renamed", + newId: "my_rich_text_renamed", + }); + + await expect( + sliceBuilderPage.getListItemFieldId("my_rich_text_renamed", "repeatable"), + ).toBeVisible(); + await expect( + sliceBuilderPage.getListItemFieldName( + "my_rich_text_renamed", + "My Rich Text Renamed", + "repeatable", + ), + ).toBeVisible(); + }, +); + +test.run()( + "I can delete a field in the repeatable zone", + async ({ sliceBuilderPage, slice }) => { + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "repeatable", + }); + + await sliceBuilderPage.deleteField("my_rich_text", "repeatable"); + + await expect( + sliceBuilderPage.getListItemFieldId("my_rich_text", "repeatable"), + ).not.toBeVisible(); + }, +); + +test.run()( + "I can see and copy the code snippets", + async ({ sliceBuilderPage, slice }) => { + await sliceBuilderPage.goto(slice.name); + await sliceBuilderPage.addField({ + type: "Rich Text", + name: "My Rich Text", + expectedId: "my_rich_text", + zoneType: "repeatable", + }); + + await expect(sliceBuilderPage.codeSnippetsFieldSwitch).not.toBeChecked(); + await sliceBuilderPage.codeSnippetsFieldSwitch.click(); + await expect(sliceBuilderPage.codeSnippetsFieldSwitch).toBeChecked(); + await sliceBuilderPage.copyCodeSnippet("my_rich_text", "repeatable"); + }, +);