diff --git a/packages/slice-machine/components/ChangesItems/ChangesItems.tsx b/packages/slice-machine/components/ChangesItems/ChangesItems.tsx index 484303c44a..4c0898998f 100644 --- a/packages/slice-machine/components/ChangesItems/ChangesItems.tsx +++ b/packages/slice-machine/components/ChangesItems/ChangesItems.tsx @@ -6,19 +6,23 @@ import { ChangesSectionHeader } from "@components/ChangesSectionHeader"; import { CustomTypeTable } from "@components/CustomTypeTable/changesPage"; import Grid from "@components/Grid"; import { ComponentUI } from "@lib/models/common/ComponentUI"; -import { ModelStatusInformation } from "@src/hooks/useModelStatus"; import ScreenshotChangesModal from "@components/ScreenshotChangesModal"; import { countMissingScreenshots } from "@src/domain/slice"; import { useScreenshotChangesModal } from "@src/hooks/useScreenshotChangesModal"; import { ModelStatus } from "@lib/models/common/ModelStatus"; import { LocalOrRemoteCustomType } from "@lib/models/common/ModelData"; import { SharedSliceCard } from "@src/features/slices/sliceCards/SharedSliceCard"; +import { AuthStatus } from "@src/modules/userContext/types"; +import { ModelsStatuses } from "@src/features/sync/getUnSyncChanges"; import { DevCollaborationExperiment } from "./DevCollaborationExperiment"; -interface ChangesItemsProps extends ModelStatusInformation { +interface ChangesItemsProps { unSyncedCustomTypes: LocalOrRemoteCustomType[]; unSyncedSlices: ComponentUI[]; + modelsStatuses: ModelsStatuses; + authStatus: AuthStatus; + isOnline: boolean; } export const ChangesItems: React.FC = ({ diff --git a/packages/slice-machine/components/CustomTypeTable/changesPage.tsx b/packages/slice-machine/components/CustomTypeTable/changesPage.tsx index 6af2fc36d7..a56cd7c754 100644 --- a/packages/slice-machine/components/CustomTypeTable/changesPage.tsx +++ b/packages/slice-machine/components/CustomTypeTable/changesPage.tsx @@ -1,7 +1,6 @@ import Link from "next/link"; import React from "react"; import { Box, Text } from "theme-ui"; -import { ModelStatusInformation } from "@src/hooks/useModelStatus"; import { CustomTypeSM } from "@lib/models/common/CustomType"; import { ModelStatus } from "@lib/models/common/ModelStatus"; import { @@ -10,18 +9,26 @@ import { } from "@lib/models/common/ModelData"; import { StatusBadge } from "@src/features/changes/StatusBadge"; import { CUSTOM_TYPES_CONFIG } from "@src/features/customTypes/customTypesConfig"; +import { AuthStatus } from "@src/modules/userContext/types"; +import { ModelsStatuses } from "@src/features/sync/getUnSyncChanges"; -interface CustomTypeTableProps extends ModelStatusInformation { +interface CustomTypeTableProps { customTypes: LocalOrRemoteCustomType[]; + modelsStatuses: ModelsStatuses; + authStatus: AuthStatus; + isOnline: boolean; } const firstColumnWidth = "40%"; const secondColumnWidth = "40%"; const thirdColumnWidth = "20%"; -const CustomTypeChangeRow: React.FC< - { ct: CustomTypeSM; status: ModelStatus } & ModelStatusInformation -> = ({ ct, status, authStatus, isOnline }) => { +const CustomTypeChangeRow: React.FC<{ + ct: CustomTypeSM; + status: ModelStatus; + authStatus: AuthStatus; + isOnline: boolean; +}> = ({ ct, status, authStatus, isOnline }) => { return ( <> @@ -80,7 +87,6 @@ export const CustomTypeTable: React.FC = ({ status={modelsStatuses.customTypes[customType.local.id]} authStatus={authStatus} isOnline={isOnline} - modelsStatuses={modelsStatuses} key={customType.local.id} /> @@ -97,7 +103,6 @@ export const CustomTypeTable: React.FC = ({ status={modelsStatuses.customTypes[customType.remote.id]} authStatus={authStatus} isOnline={isOnline} - modelsStatuses={modelsStatuses} key={customType.remote.id} /> diff --git a/packages/slice-machine/components/DeleteSliceModal/index.tsx b/packages/slice-machine/components/DeleteSliceModal/index.tsx index e3cfe0e590..1635701858 100644 --- a/packages/slice-machine/components/DeleteSliceModal/index.tsx +++ b/packages/slice-machine/components/DeleteSliceModal/index.tsx @@ -7,6 +7,7 @@ import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import Card from "@components/Card"; import { Button } from "@components/Button"; import { deleteSlice } from "@src/features/slices/actions/deleteSlice"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; type DeleteSliceModalProps = { isOpen: boolean; @@ -21,6 +22,7 @@ export const DeleteSliceModal: React.FunctionComponent< > = ({ sliceId, sliceName, libName, isOpen, onClose }) => { const [isDeleting, setIsDeleting] = useState(false); const { deleteSliceSuccess } = useSliceMachineActions(); + const { syncChanges } = useAutoSync(); const { theme } = useThemeUI(); const onDelete = async () => { @@ -32,6 +34,7 @@ export const DeleteSliceModal: React.FunctionComponent< libraryID: libName, onSuccess: () => { deleteSliceSuccess(sliceId, libName); + syncChanges(); }, }); diff --git a/packages/slice-machine/components/Forms/CreateCustomTypeModal/CreateCustomTypeModal.tsx b/packages/slice-machine/components/Forms/CreateCustomTypeModal/CreateCustomTypeModal.tsx index 64a3be4d18..7a5277df16 100644 --- a/packages/slice-machine/components/Forms/CreateCustomTypeModal/CreateCustomTypeModal.tsx +++ b/packages/slice-machine/components/Forms/CreateCustomTypeModal/CreateCustomTypeModal.tsx @@ -21,6 +21,7 @@ import { } from "@src/features/customTypes/actions/createCustomType"; import { CUSTOM_TYPES_CONFIG } from "@src/features/customTypes/customTypesConfig"; import { getFormat } from "@src/domain/customType"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; import { InputBox } from "../components/InputBox"; import { SelectRepeatable } from "../components/SelectRepeatable"; @@ -58,6 +59,7 @@ export const CreateCustomTypeModal: React.FC = ({ ); const customTypesMessages = CUSTOM_TYPES_MESSAGES[format]; const [isIdFieldPristine, setIsIdFieldPristine] = useState(true); + const { syncChanges } = useAutoSync(); const router = useRouter(); const onSubmit = async ({ id, label, repeatable }: FormValues) => { @@ -86,6 +88,8 @@ export const CreateCustomTypeModal: React.FC = ({ } : undefined, }); + + syncChanges(); }, }); diff --git a/packages/slice-machine/components/Forms/CreateSliceModal/CreateSliceModal.tsx b/packages/slice-machine/components/Forms/CreateSliceModal/CreateSliceModal.tsx index 88ab46e96e..34778097d1 100644 --- a/packages/slice-machine/components/Forms/CreateSliceModal/CreateSliceModal.tsx +++ b/packages/slice-machine/components/Forms/CreateSliceModal/CreateSliceModal.tsx @@ -9,6 +9,7 @@ import ModalFormCard from "@components/ModalFormCard"; import { createSlice } from "@src/features/slices/actions/createSlice"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import { getState } from "@src/apiClient"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; import { validateSliceModalValues } from "../formsValidator"; import { InputBox } from "../components/InputBox"; @@ -30,6 +31,7 @@ export const CreateSliceModal: FC = ({ }) => { const { createSliceSuccess } = useSliceMachineActions(); const [isCreatingSlice, setIsCreatingSlice] = useState(false); + const { syncChanges } = useAutoSync(); const onSubmit = async (values: FormValues) => { const sliceName = values.sliceName; @@ -45,8 +47,8 @@ export const CreateSliceModal: FC = ({ const serverState = await getState(); // Update Redux store createSliceSuccess(serverState.libraries); - onSuccess(newSlice, libraryName); + syncChanges(); }, }); }; diff --git a/packages/slice-machine/components/Forms/RenameCustomTypeModal/RenameCustomTypeModal.tsx b/packages/slice-machine/components/Forms/RenameCustomTypeModal/RenameCustomTypeModal.tsx index d59ec2322c..0782ce2c39 100644 --- a/packages/slice-machine/components/Forms/RenameCustomTypeModal/RenameCustomTypeModal.tsx +++ b/packages/slice-machine/components/Forms/RenameCustomTypeModal/RenameCustomTypeModal.tsx @@ -13,6 +13,7 @@ import { CustomTypeFormat } from "@slicemachine/manager"; import { CUSTOM_TYPES_MESSAGES } from "@src/features/customTypes/customTypesMessages"; import { renameCustomType } from "@src/features/customTypes/actions/renameCustomType"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; interface RenameCustomTypeModalProps { isChangesLocal: boolean; @@ -32,6 +33,7 @@ export const RenameCustomTypeModal: React.FC = ({ const customTypeName = customType?.label ?? ""; const customTypeId = customType?.id ?? ""; const { renameCustomTypeSuccess } = useSliceMachineActions(); + const { syncChanges } = useAutoSync(); const [isRenaming, setIsRenaming] = useState(false); @@ -46,7 +48,10 @@ export const RenameCustomTypeModal: React.FC = ({ await renameCustomType({ model: customType, newLabel: values.customTypeName, - onSuccess: renameCustomTypeSuccess, + onSuccess: (renamedCustomType) => { + renameCustomTypeSuccess(renamedCustomType); + syncChanges(); + }, }); } setIsRenaming(false); diff --git a/packages/slice-machine/components/Forms/RenameSliceModal/RenameSliceModal.tsx b/packages/slice-machine/components/Forms/RenameSliceModal/RenameSliceModal.tsx index 3843cf9baa..03d123fb37 100644 --- a/packages/slice-machine/components/Forms/RenameSliceModal/RenameSliceModal.tsx +++ b/packages/slice-machine/components/Forms/RenameSliceModal/RenameSliceModal.tsx @@ -6,6 +6,7 @@ import { SliceMachineStoreType } from "@src/redux/type"; import { getLibraries, getRemoteSlices } from "@src/modules/slices"; import { ComponentUI } from "@lib/models/common/ComponentUI"; import { renameSlice } from "@src/features/slices/actions/renameSlice"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; import { InputBox } from "../components/InputBox"; import ModalFormCard from "../../ModalFormCard"; @@ -24,6 +25,7 @@ export const RenameSliceModal: React.FC = ({ onClose, }) => { const { renameSliceSuccess } = useSliceMachineActions(); + const { syncChanges } = useAutoSync(); const { localLibs, remoteLibs } = useSelector( (store: SliceMachineStoreType) => ({ localLibs: getLibraries(store), @@ -39,6 +41,7 @@ export const RenameSliceModal: React.FC = ({ newSliceName: values.sliceName, onSuccess: (renamedSlice) => { renameSliceSuccess(renamedSlice.from, renamedSlice.model); + syncChanges(); }, }); diff --git a/packages/slice-machine/components/LoginModal/index.tsx b/packages/slice-machine/components/LoginModal/index.tsx index 088f5a213b..8f8c994c50 100644 --- a/packages/slice-machine/components/LoginModal/index.tsx +++ b/packages/slice-machine/components/LoginModal/index.tsx @@ -11,7 +11,7 @@ import { Text, } from "theme-ui"; import SliceMachineModal from "@components/SliceMachineModal"; -import { checkAuthStatus, startAuth } from "@src/apiClient"; +import { checkAuthStatus, getState, startAuth } from "@src/apiClient"; import { buildEndpoints } from "@lib/prismic/endpoints"; import { startPolling } from "@lib/utils/poll"; import { CheckAuthStatusResponse } from "@models/common/Auth"; @@ -25,6 +25,12 @@ import { getEnvironment } from "@src/modules/environment"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import preferWroomBase from "@lib/utils/preferWroomBase"; import { ToasterType } from "@src/modules/toaster"; +import { getUnSyncedChanges } from "@src/features/sync/getUnSyncChanges"; +import { normalizeFrontendCustomTypes } from "@lib/models/common/normalizers/customType"; +import { normalizeFrontendSlices } from "@lib/models/common/normalizers/slices"; +import { AuthStatus } from "@src/modules/userContext/types"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; +import { getActiveEnvironment } from "@src/features/environments/actions/getActiveEnvironment"; interface ValidAuthStatus extends CheckAuthStatusResponse { status: "ok"; @@ -42,7 +48,7 @@ const LoginModal: React.FunctionComponent = () => { env: getEnvironment(store), }), ); - + const { syncChanges } = useAutoSync(); const { closeModals, startLoadingLogin, stopLoadingLogin, openToaster } = useSliceMachineActions(); @@ -72,6 +78,38 @@ const LoginModal: React.FunctionComponent = () => { openToaster("Logged in", ToasterType.SUCCESS); stopLoadingLogin(); closeModals(); + + const serverState = await getState(); + const slices = normalizeFrontendSlices( + serverState.libraries, + serverState.remoteSlices, + ); + const customTypes = Object.values( + normalizeFrontendCustomTypes( + serverState.customTypes, + serverState.remoteCustomTypes, + ), + ); + const { changedCustomTypes, changedSlices } = getUnSyncedChanges({ + authStatus: AuthStatus.AUTHORIZED, + customTypes, + isOnline: true, + libraries: serverState.libraries, + slices, + }); + const { activeEnvironment } = await getActiveEnvironment(); + + if ( + activeEnvironment?.kind === "dev" && + (changedCustomTypes.length > 0 || changedSlices.length > 0) + ) { + syncChanges({ + environment: activeEnvironment, + loggedIn: true, + changedCustomTypes, + changedSlices, + }); + } } catch (e) { stopLoadingLogin(); openToaster("Login failed", ToasterType.ERROR); diff --git a/packages/slice-machine/components/Navigation/ChangesItem.tsx b/packages/slice-machine/components/Navigation/ChangesItem.tsx new file mode 100644 index 0000000000..8a8b5928a3 --- /dev/null +++ b/packages/slice-machine/components/Navigation/ChangesItem.tsx @@ -0,0 +1,117 @@ +import { type FC } from "react"; +import { useRouter } from "next/router"; +import { Box, Button, Text } from "@prismicio/editor-ui"; + +import { + HoverCard, + HoverCardCloseButton, + HoverCardDescription, + HoverCardMedia, + HoverCardTitle, +} from "@src/components/HoverCard"; +import useSliceMachineActions from "@src/modules/useSliceMachineActions"; +import { useNetwork } from "@src/hooks/useNetwork"; +import { useAuthStatus } from "@src/hooks/useAuthStatus"; +import { useUnSyncChanges } from "@src/features/sync/useUnSyncChanges"; +import { AuthStatus } from "@src/modules/userContext/types"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; +import { AutoSyncStatusIndicator } from "@src/features/sync/components/AutoSyncStatusIndicator"; + +export const ChangesItem: FC = () => { + const { setSeenChangesToolTip } = useSliceMachineActions(); + const open = useOpenChangesHoverCard(); + const router = useRouter(); + const { autoSyncStatus } = useAutoSync(); + + const onClose = () => { + setSeenChangesToolTip(); + }; + + return ( + + {autoSyncStatus === "failed" || + autoSyncStatus === "synced" || + autoSyncStatus === "syncing" ? ( + + ) : ( + // TODO(DT-1942): This should be a Button with a link component for + // accessibility + + )} + + } + > + Push your changes + + + When you click Save, your changes are saved locally. Then, you can push + your models to Prismic from the Changes page. + + Got it + + ); +}; + +export const ChangesCount: FC = () => { + const isOnline = useNetwork(); + const authStatus = useAuthStatus(); + const { unSyncedSlices, unSyncedCustomTypes } = useUnSyncChanges(); + const numberOfChanges = unSyncedSlices.length + unSyncedCustomTypes.length; + + if ( + !isOnline || + authStatus === AuthStatus.UNAUTHORIZED || + authStatus === AuthStatus.FORBIDDEN + ) { + return null; + } + + if (numberOfChanges === 0) { + return null; + } + + const formattedNumberOfChanges = numberOfChanges > 9 ? "+9" : numberOfChanges; + + return ( + + + {formattedNumberOfChanges} + + + ); +}; + +// TODO(DT-1925): Reactivate this feature +const useOpenChangesHoverCard = () => { + // const { hasSeenChangesToolTip, hasSeenSimulatorToolTip } = useSelector( + // (store: SliceMachineStoreType) => ({ + // hasSeenChangesToolTip: userHasSeenChangesToolTip(store), + // hasSeenSimulatorToolTip: userHasSeenSimulatorToolTip(store), + // }), + // ); + + // return ( + // !hasSeenChangesToolTip && + // hasSeenSimulatorToolTip + // ); + + return false; +}; diff --git a/packages/slice-machine/components/Navigation/ChangesListItem.tsx b/packages/slice-machine/components/Navigation/ChangesListItem.tsx deleted file mode 100644 index 6c00f9ff8e..0000000000 --- a/packages/slice-machine/components/Navigation/ChangesListItem.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { type FC } from "react"; -import Link from "next/link"; -import { useRouter } from "next/router"; - -import { SideNavLink, SideNavListItem } from "@src/components/SideNav"; -import { RadarIcon } from "@src/icons/RadarIcon"; -import { - HoverCard, - HoverCardCloseButton, - HoverCardDescription, - HoverCardMedia, - HoverCardTitle, -} from "@src/components/HoverCard"; -import useSliceMachineActions from "@src/modules/useSliceMachineActions"; - -import { ChangesRightElement } from "./ChangesRightElement"; - -export const ChangesListItem: FC = () => { - const { setSeenChangesToolTip } = useSliceMachineActions(); - const open = useOpenChangesHoverCard(); - const router = useRouter(); - - const onClose = () => { - setSeenChangesToolTip(); - }; - - return ( - - } - /> - - } - > - Push your changes - - - When you click Save, your changes are saved locally. Then, you can push - your models to Prismic from the Changes page. - - Got it - - ); -}; - -// TODO(DT-1925): Reactivate this feature -const useOpenChangesHoverCard = () => { - // const { hasSeenChangesToolTip, hasSeenSimulatorToolTip } = useSelector( - // (store: SliceMachineStoreType) => ({ - // hasSeenChangesToolTip: userHasSeenChangesToolTip(store), - // hasSeenSimulatorToolTip: userHasSeenSimulatorToolTip(store), - // }), - // ); - - // return ( - // !hasSeenChangesToolTip && - // hasSeenSimulatorToolTip - // ); - - return false; -}; diff --git a/packages/slice-machine/components/Navigation/ChangesRightElement.tsx b/packages/slice-machine/components/Navigation/ChangesRightElement.tsx deleted file mode 100644 index b2f7979216..0000000000 --- a/packages/slice-machine/components/Navigation/ChangesRightElement.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { FC } from "react"; - -import { useUnSyncChanges } from "@src/hooks/useUnSyncChanges"; -import { RightElement } from "@src/components/SideNav/SideNav"; -import { AuthStatus } from "@src/modules/userContext/types"; -import { useNetwork } from "@src/hooks/useNetwork"; - -export const ChangesRightElement: FC = () => { - const isOnline = useNetwork(); - const { unSyncedSlices, unSyncedCustomTypes, authStatus } = - useUnSyncChanges(); - const numberOfChanges = unSyncedSlices.length + unSyncedCustomTypes.length; - - if ( - !isOnline || - authStatus === AuthStatus.UNAUTHORIZED || - authStatus === AuthStatus.FORBIDDEN - ) { - return Logged out; - } - - if (numberOfChanges === 0) { - return null; - } - - const formattedNumberOfChanges = numberOfChanges > 9 ? "+9" : numberOfChanges; - - return ( - - {formattedNumberOfChanges} - - ); -}; diff --git a/packages/slice-machine/components/Navigation/Environment.tsx b/packages/slice-machine/components/Navigation/Environment.tsx index 41513d2a34..9e6c440a92 100644 --- a/packages/slice-machine/components/Navigation/Environment.tsx +++ b/packages/slice-machine/components/Navigation/Environment.tsx @@ -1,25 +1,40 @@ +import { useState } from "react"; import { Environment as EnvironmentType, isUnauthenticatedError, isUnauthorizedError, } from "@slicemachine/manager/client"; -import { telemetry } from "@src/apiClient"; +import { getState, telemetry } from "@src/apiClient"; import { useEnvironments } from "@src/features/environments/useEnvironments"; import { setEnvironment } from "@src/features/environments/actions/setEnvironment"; import { useActiveEnvironment } from "@src/features/environments/useActiveEnvironment"; -import { getLegacySliceMachineState } from "@src/features/legacyState/actions/getLegacySliceMachineState"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import { SideNavEnvironmentSelector } from "@src/components/SideNav"; import { useNetwork } from "@src/hooks/useNetwork"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; +import { normalizeFrontendSlices } from "@lib/models/common/normalizers/slices"; +import { normalizeFrontendCustomTypes } from "@lib/models/common/normalizers/customType"; +import { getUnSyncedChanges } from "@src/features/sync/getUnSyncChanges"; +import { useAuthStatus } from "@src/hooks/useAuthStatus"; export function Environment() { const { environments, error: useEnvironmentsError } = useEnvironments(); const { activeEnvironment } = useActiveEnvironment(); const { refreshState, openLoginModal } = useSliceMachineActions(); + const { syncChanges } = useAutoSync(); const isOnline = useNetwork(); + const authStatus = useAuthStatus(); + const [isSwitchingEnv, setIsSwitchingEnv] = useState(false); + const { autoSyncStatus } = useAutoSync(); async function onSelect(environment: EnvironmentType) { + if (activeEnvironment?.name === environment.name) { + return; + } + + setIsSwitchingEnv(true); + void telemetry.track({ event: "environment:switch", domain: environment.domain, @@ -27,9 +42,39 @@ export function Environment() { await setEnvironment(environment); - const legacySliceMachineState = await getLegacySliceMachineState(); + const serverState = await getState(); + refreshState(serverState); + + const slices = normalizeFrontendSlices( + serverState.libraries, + serverState.remoteSlices, + ); + const customTypes = Object.values( + normalizeFrontendCustomTypes( + serverState.customTypes, + serverState.remoteCustomTypes, + ), + ); + const { changedCustomTypes, changedSlices } = getUnSyncedChanges({ + authStatus, + customTypes, + isOnline, + libraries: serverState.libraries, + slices, + }); + + if ( + environment.kind === "dev" && + (changedCustomTypes.length > 0 || changedSlices.length > 0) + ) { + syncChanges({ + environment, + changedCustomTypes, + changedSlices, + }); + } - refreshState(legacySliceMachineState); + setIsSwitchingEnv(false); } if (!isOnline) { @@ -42,6 +87,8 @@ export function Environment() { environments={environments} activeEnvironment={activeEnvironment} onSelect={onSelect} + disabled={isSwitchingEnv || autoSyncStatus === "syncing"} + loading={isSwitchingEnv} /> ); } diff --git a/packages/slice-machine/components/Navigation/index.css.ts b/packages/slice-machine/components/Navigation/index.css.ts index 4fe98ae314..138f8bc8a6 100644 --- a/packages/slice-machine/components/Navigation/index.css.ts +++ b/packages/slice-machine/components/Navigation/index.css.ts @@ -1,6 +1,5 @@ import { sprinkles } from "@prismicio/editor-ui"; export const environmentDivider = sprinkles({ - marginTop: 16, - marginBottom: 32, + marginBlock: 16, }); diff --git a/packages/slice-machine/components/Navigation/index.test.tsx b/packages/slice-machine/components/Navigation/index.test.tsx index b4ac31021e..4930b81082 100644 --- a/packages/slice-machine/components/Navigation/index.test.tsx +++ b/packages/slice-machine/components/Navigation/index.test.tsx @@ -131,10 +131,8 @@ describe("Side Navigation", () => { test.skip.each([ ["Page types", "/"], ["Custom types", "/custom-types"], - ["Changes", "/changes"], - - ["Changelog", "/changelog"], ["Slices", "/slices"], + ["Changelog", "/changelog"], // TODO: once we have a plan fo how to display individual libraries change this // ["Slices/a", "/slices#slices/a"], // ["Slices/b", "/slices#slices/b"] @@ -156,95 +154,17 @@ describe("Side Navigation", () => { }, ); - test("should display the number of changes on the 'Changes' item", async () => { + test("should display the number of changes on the 'Review changes' item", async () => { renderSideNavigation({ canUpdate: true, hasChanges: true }); - expect(await screen.findByText("Changes")).toBeVisible(); + expect(await screen.findByText("Review changes")).toBeVisible(); const changesItem = screen - .getByText("Changes") - .closest("a") as HTMLAnchorElement; + .getByText("Review changes") + .closest("button") as HTMLButtonElement; expect(within(changesItem).getByText("1")).toBeVisible(); }); - test("should not display the number of changes or 'Disconnect' on the 'Changes' item", async () => { - renderSideNavigation({ canUpdate: true, hasChanges: false }); - - expect(await screen.findByText("Changes")).toBeVisible(); - const changesItem = screen - .getByText("Changes") - .closest("a") as HTMLAnchorElement; - - expect(within(changesItem).queryByText("1")).not.toBeInTheDocument(); - expect( - within(changesItem).queryByText("Logged out"), - ).not.toBeInTheDocument(); - }); - - test("should display the information that user is disconnected on the 'Changes' item when unauthorized", async () => { - renderSideNavigation({ - canUpdate: true, - hasChanges: true, - authStatus: AuthStatus.UNAUTHORIZED, - }); - - expect(await screen.findByText("Changes")).toBeVisible(); - const changesItem = screen - .getByText("Changes") - .closest("a") as HTMLAnchorElement; - - expect(within(changesItem).getByText("Logged out")).toBeVisible(); - }); - - test("should display the information that user is disconnected on the 'Changes' item when forbidden", async () => { - renderSideNavigation({ - canUpdate: true, - hasChanges: true, - authStatus: AuthStatus.FORBIDDEN, - }); - - expect(await screen.findByText("Changes")).toBeVisible(); - const changesItem = screen - .getByText("Changes") - .closest("a") as HTMLAnchorElement; - - expect(within(changesItem).getByText("Logged out")).toBeVisible(); - }); - - test("should display the information that user is disconnected on the 'Changes' item when offline", async () => { - vi.spyOn(navigator, "onLine", "get").mockReturnValueOnce(false); - - renderSideNavigation({ - canUpdate: true, - hasChanges: true, - }); - - expect(await screen.findByText("Changes")).toBeVisible(); - const changesItem = screen - .getByText("Changes") - .closest("a") as HTMLAnchorElement; - - expect(within(changesItem).getByText("Logged out")).toBeVisible(); - }); - - test("should not display the information that user is disconnected when user is online", async () => { - vi.spyOn(navigator, "onLine", "get").mockReturnValueOnce(true); - - renderSideNavigation({ - canUpdate: true, - hasChanges: true, - }); - - expect(await screen.findByText("Changes")).toBeVisible(); - const changesItem = screen - .getByText("Changes") - .closest("a") as HTMLAnchorElement; - - expect( - within(changesItem).queryByText("Logged out"), - ).not.toBeInTheDocument(); - }); - test("Video Item with next", async (ctx) => { const adapter = createTestPlugin({ meta: { diff --git a/packages/slice-machine/components/Navigation/index.tsx b/packages/slice-machine/components/Navigation/index.tsx index 01c1a32b4b..041d3cf78d 100644 --- a/packages/slice-machine/components/Navigation/index.tsx +++ b/packages/slice-machine/components/Navigation/index.tsx @@ -10,7 +10,6 @@ import { LightningIcon } from "@src/icons/Lightning"; import { MathPlusIcon } from "@src/icons/MathPlusIcon"; import { CUSTOM_TYPES_CONFIG } from "@src/features/customTypes/customTypesConfig"; import { - SideNavSeparator, SideNavLink, SideNavListItem, SideNavList, @@ -28,7 +27,7 @@ import { getChangelog } from "@src/modules/environment"; import { CUSTOM_TYPES_MESSAGES } from "@src/features/customTypes/customTypesMessages"; import { useRepositoryInformation } from "@src/hooks/useRepositoryInformation"; -import { ChangesListItem } from "./ChangesListItem"; +import { ChangesItem } from "./ChangesItem"; import { Environment } from "./Environment"; import * as styles from "./index.css"; @@ -73,6 +72,8 @@ const Navigation: FC = () => { href={repositoryUrl} /> + + { /> - - - - - - { - const { slice, autoSaveStatus } = useSliceState(); + const { slice, actionQueueStatus } = useSliceState(); const isSimulatorAvailableForFramework = useSelector( selectIsSimulatorAvailableForFramework, @@ -33,10 +33,10 @@ export const SliceBuilder: FC = () => { - + diff --git a/packages/slice-machine/lib/models/common/ModelStatus/index.ts b/packages/slice-machine/lib/models/common/ModelStatus/index.ts index 42c21aeb9c..b1eab4cd2d 100644 --- a/packages/slice-machine/lib/models/common/ModelStatus/index.ts +++ b/packages/slice-machine/lib/models/common/ModelStatus/index.ts @@ -117,3 +117,28 @@ export function computeModelStatus( : compareCustomTypeLocalToRemote(model); return { status, model }; } + +export function computeStatuses( + models: LocalOrRemoteCustomType[], + userHasAccessToModels: boolean, +): { [sliceId: string]: ModelStatus }; +export function computeStatuses( + models: LocalOrRemoteSlice[], + userHasAccessToModels: boolean, +): { [sliceId: string]: ModelStatus }; +export function computeStatuses( + models: LocalOrRemoteModel[], + userHasAccessToModels: boolean, +) { + return models.reduce<{ [id: string]: ModelStatus }>( + (acc, model) => { + const { status } = computeModelStatus(model, userHasAccessToModels); + + return { + ...acc, + [hasLocal(model) ? model.local.id : model.remote.id]: status, + }; + }, + {} as { [sliceId: string]: ModelStatus }, + ); +} diff --git a/packages/slice-machine/pages/_app.tsx b/packages/slice-machine/pages/_app.tsx index e4294c586a..c265da3016 100644 --- a/packages/slice-machine/pages/_app.tsx +++ b/packages/slice-machine/pages/_app.tsx @@ -34,9 +34,9 @@ import type { Persistor } from "redux-persist/es/types"; import { PersistGate } from "redux-persist/integration/react"; import { ThemeProvider as ThemeUIThemeProvider, useThemeUI } from "theme-ui"; -import { AppLayout, AppLayoutContent } from "@components/AppLayout"; import { InAppGuideProvider } from "@src/features/inAppGuide/InAppGuideContext"; import { InAppGuideDialog } from "@src/features/inAppGuide/InAppGuideDialog"; +import { AutoSyncProvider } from "@src/features/sync/AutoSyncProvider"; import SliceMachineApp from "../components/App"; import LoadingPage from "../components/LoadingPage"; @@ -140,39 +140,41 @@ function App({ ); }} renderError={() => ( - - - - - - - + + + )} > - - - }> - - - - - - { - console.error( - `An error occurred while rendering the in-app guide`, - error, - ); - }} - > - - - - - + }> + + + + + + + + { + console.error( + `An error occurred while rendering the in-app guide`, + error, + ); + }} + > + + + + + + + diff --git a/packages/slice-machine/pages/changes.tsx b/packages/slice-machine/pages/changes.tsx index b9798a2324..2892134ef8 100644 --- a/packages/slice-machine/pages/changes.tsx +++ b/packages/slice-machine/pages/changes.tsx @@ -1,8 +1,10 @@ import { Button } from "@prismicio/editor-ui"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { toast } from "react-toastify"; import Head from "next/head"; import { BaseStyles } from "theme-ui"; +import { useRouter } from "next/router"; + import { AppLayout, AppLayoutActions, @@ -12,60 +14,52 @@ import { } from "@components/AppLayout"; import { ChangesItems } from "@components/ChangesItems"; import { AuthErrorPage, OfflinePage } from "@components/ChangesEmptyState"; - import { NoChangesBlankSlate } from "@src/features/changes/BlankSlates"; - import { AuthStatus } from "@src/modules/userContext/types"; -import { unSyncStatuses, useUnSyncChanges } from "@src/hooks/useUnSyncChanges"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; import { SoftDeleteDocumentsDrawer, HardDeleteDocumentsDrawer, } from "@components/DeleteDocumentsDrawer"; -import { hasLocal } from "@lib/models/common/ModelData"; -import { - ChangedCustomType, - ChangedSlice, -} from "@lib/models/common/ModelStatus"; import { PushChangesLimit } from "@slicemachine/manager"; -import { pushChanges } from "@src/features/changes/actions/pushChanges"; import { getState } from "@src/apiClient"; +import { pushChanges } from "@src/features/sync/actions/pushChanges"; +import { useUnSyncChanges } from "@src/features/sync/useUnSyncChanges"; +import { useNetwork } from "@src/hooks/useNetwork"; +import { useAuthStatus } from "@src/hooks/useAuthStatus"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; const Changes: React.FunctionComponent = () => { const { unSyncedSlices, unSyncedCustomTypes, + changedCustomTypes, + changedSlices, modelsStatuses, - authStatus, - isOnline, } = useUnSyncChanges(); - - const { changedSlices, changedCustomTypes } = useMemo(() => { - const changedSlices = unSyncedSlices - .map((s) => ({ - slice: s, - status: modelsStatuses.slices[s.model.id], - })) - .filter((s): s is ChangedSlice => unSyncStatuses.includes(s.status)); // TODO can we sync unSyncStatuses and ChangedSlice? - const changedCustomTypes = unSyncedCustomTypes - .map((model) => (hasLocal(model) ? model.local : model.remote)) - .map((ct) => ({ - customType: ct, - status: modelsStatuses.customTypes[ct.id], - })) - .filter((c): c is ChangedCustomType => unSyncStatuses.includes(c.status)); - - return { changedSlices, changedCustomTypes }; - }, [unSyncedSlices, unSyncedCustomTypes, modelsStatuses]); - + const isOnline = useNetwork(); + const authStatus = useAuthStatus(); const { pushChangesSuccess, refreshState } = useSliceMachineActions(); const [isSyncing, setIsSyncing] = useState(false); const [openModalData, setOpenModalData] = useState< PushChangesLimit | undefined >(undefined); + const { autoSyncStatus } = useAutoSync(); + const router = useRouter(); const numberOfChanges = unSyncedSlices.length + unSyncedCustomTypes.length; + // Changes page should not be accessible when the autoSyncStatus is syncing, synced or failed + useEffect(() => { + if ( + autoSyncStatus === "synced" || + autoSyncStatus === "failed" || + autoSyncStatus === "syncing" + ) { + void router.push("/"); + } + }, [autoSyncStatus, router]); + const onPush = async (confirmDeleteDocuments: boolean) => { try { setIsSyncing(true); @@ -73,23 +67,15 @@ const Changes: React.FunctionComponent = () => { const limit = await pushChanges({ confirmDeleteDocuments, - changedSlices, changedCustomTypes, + changedSlices, }); if (limit !== undefined) { setOpenModalData(limit); } else { - // TODO(DT-1737): Remove the use of global getState const serverState = await getState(); - refreshState({ - env: serverState.env, - remoteCustomTypes: serverState.remoteCustomTypes, - customTypes: serverState.customTypes, - libraries: serverState.libraries, - remoteSlices: serverState.remoteSlices, - clientError: serverState.clientError, - }); + refreshState(serverState); // Update last sync value in local storage pushChangesSuccess(); diff --git a/packages/slice-machine/src/components/SideNav/SideNav.css.ts b/packages/slice-machine/src/components/SideNav/SideNav.css.ts index d5b869d709..beb4f43df2 100644 --- a/packages/slice-machine/src/components/SideNav/SideNav.css.ts +++ b/packages/slice-machine/src/components/SideNav/SideNav.css.ts @@ -54,7 +54,7 @@ export const repository = style([ sprinkles({ display: "flex", justifyContent: "space-between", - marginBottom: 32, + marginBottom: 16, }), ]); diff --git a/packages/slice-machine/src/components/SideNav/SideNav.tsx b/packages/slice-machine/src/components/SideNav/SideNav.tsx index ae3d877342..628bbd1217 100644 --- a/packages/slice-machine/src/components/SideNav/SideNav.tsx +++ b/packages/slice-machine/src/components/SideNav/SideNav.tsx @@ -173,10 +173,14 @@ export const UpdateInfo: FC = ({ Some updates of Slice Machine are available.

- {createElement( - component, - { ...{ className: styles.updateInfoLink, onClick }, href }, - "Learn more", - )} + { + // TODO(DT-1942): This should be a Button with a link component for + // accessibility + createElement( + component, + { ...{ className: styles.updateInfoLink, onClick }, href }, + "Learn more", + ) + } ); diff --git a/packages/slice-machine/src/components/SideNav/SideNavEnvironmentSelector.tsx b/packages/slice-machine/src/components/SideNav/SideNavEnvironmentSelector.tsx index 5bec3b979f..8fe7430e90 100644 --- a/packages/slice-machine/src/components/SideNav/SideNavEnvironmentSelector.tsx +++ b/packages/slice-machine/src/components/SideNav/SideNavEnvironmentSelector.tsx @@ -8,6 +8,7 @@ import { Icon, IconButton, InvisibleButton, + ProgressCircle, Text, } from "@prismicio/editor-ui"; import { Environment } from "@slicemachine/manager/client"; @@ -21,22 +22,26 @@ import * as styles from "./SideNavEnvironmentSelector.css"; import { LoginIcon } from "@src/icons/LoginIcon"; type SideNavEnvironmentSelectorProps = { - variant?: "default" | "offline" | "unauthorized" | "unauthenticated"; - environments?: Environment[]; activeEnvironment?: Environment; - onSelect?: (environment: Environment) => void | Promise; + disabled?: boolean; + environments?: Environment[]; + loading?: boolean; + variant?: "default" | "offline" | "unauthorized" | "unauthenticated"; onLogInClick?: () => void; + onSelect?: (environment: Environment) => void | Promise; }; export const SideNavEnvironmentSelector: FC = ( props, ) => { const { - variant = "default", - environments = [], activeEnvironment, - onSelect, + disabled = false, + environments = [], + loading = false, + variant = "default", onLogInClick, + onSelect, } = props; const isProductionEnvironmentActive = activeEnvironment?.kind === "prod"; @@ -60,7 +65,7 @@ export const SideNavEnvironmentSelector: FC = ( overflow="hidden" alignItems="flex-start" > - {variant === "default" || variant === "unauthenticated" ? ( + {variant === "default" ? ( Environment @@ -70,6 +75,12 @@ export const SideNavEnvironmentSelector: FC = ( ) : undefined} + {variant === "offline" ? ( + + Offline + + ) : undefined} + {variant === "default" ? ( = ( {environments.length > 1 ? ( ) : undefined} @@ -107,11 +120,14 @@ type EnvironmentDropdownMenuProps = Pick< SideNavEnvironmentSelectorProps, "activeEnvironment" | "onSelect" > & { + disabled: boolean; environments: Environment[]; + loading: boolean; }; const EnvironmentDropdownMenu: FC = (props) => { - const { environments, activeEnvironment, onSelect } = props; + const { activeEnvironment, disabled, environments, loading, onSelect } = + props; const nonPersonalEnvironments = environments.filter( (environment) => environment.kind !== "dev", @@ -122,8 +138,12 @@ const EnvironmentDropdownMenu: FC = (props) => { return ( - - + + : "unfoldMore"} + hiddenLabel="Select environment" + disabled={disabled} + /> {personalEnvironment ? ( diff --git a/packages/slice-machine/src/features/autoSave/AutoSaveStatusIndicator.tsx b/packages/slice-machine/src/features/autoSave/AutoSaveStatusIndicator.tsx index 33efe1f06d..6da7026f1c 100644 --- a/packages/slice-machine/src/features/autoSave/AutoSaveStatusIndicator.tsx +++ b/packages/slice-machine/src/features/autoSave/AutoSaveStatusIndicator.tsx @@ -1,10 +1,10 @@ import { FC } from "react"; import { Box, Icon, ProgressCircle, Text } from "@prismicio/editor-ui"; -import { AutoSaveStatus } from "./useAutoSave"; +import { ActionQueueStatus } from "@src/hooks/useActionQueue"; type AutoSaveStatusIndicatorProps = { - status: AutoSaveStatus; + status: ActionQueueStatus; }; export const AutoSaveStatusIndicator: FC = ( @@ -14,7 +14,7 @@ export const AutoSaveStatusIndicator: FC = ( let autoSaveStatusInfo; switch (status) { - case "saving": + case "pending": autoSaveStatusInfo = { icon: , text: "Saving...", @@ -26,7 +26,7 @@ export const AutoSaveStatusIndicator: FC = ( text: "Failed to save", }; break; - case "saved": + case "done": autoSaveStatusInfo = { icon: , text: "Auto-saved", diff --git a/packages/slice-machine/src/features/autoSave/useAutoSave.tsx b/packages/slice-machine/src/features/autoSave/useAutoSave.tsx deleted file mode 100644 index bb0d94b659..0000000000 --- a/packages/slice-machine/src/features/autoSave/useAutoSave.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { - useCallback, - useState, - useEffect, - SetStateAction, - Dispatch, - useMemo, -} from "react"; -import { uniqueId } from "lodash"; -import { toast } from "react-toastify"; -import { Box, Button, Text } from "@prismicio/editor-ui"; - -export type AutoSaveStatus = "saving" | "saved" | "failed"; - -type UseAutoSaveArgs = { - autoSaveStatusDelay?: number; - errorMessage?: string; - retryDelay?: number; - retryMessage?: string; -}; - -type UseAutoSaveReturnType = { - autoSaveStatus: AutoSaveStatus; - setNextSave: (nextSave: NextSave) => void; -}; - -type AutoSaveStack = { - pendingSave: NextSave | undefined; - nextSave: NextSave | undefined; -}; - -type NextSave = () => Promise; - -export const useAutoSave = ( - args: UseAutoSaveArgs = {}, -): UseAutoSaveReturnType => { - const { - autoSaveStatusDelay = 300, - errorMessage = "An error happened while saving", - retryDelay = 1000, - retryMessage = "Retry", - } = args; - - const [autoSaveStack, setAutoSaveStack] = useState({ - pendingSave: undefined, - nextSave: undefined, - }); - const [autoSaveStatusActual, setAutoSaveStatusActual] = - useState("saved"); - const [autoSaveStatusDelayed, setAutoSaveStatusDelayed] = - useState(autoSaveStatusActual); - - const setNextSave = useCallback((nextSave: NextSave) => { - setAutoSaveStack((prevState) => ({ - ...prevState, - nextSave, - })); - }, []); - - const executeSave = useCallback( - async (nextSave?: NextSave) => { - if (nextSave) { - setAutoSaveStatusActual("saving"); - - try { - await nextSave(); - - setAutoSaveStatusActual("saved"); - } catch (error) { - setAutoSaveStatusActual("failed"); - console.error(errorMessage, error); - - toastError({ - errorMessage, - retryDelay, - retryMessage, - setAutoSaveStatusActual, - setAutoSaveStack, - }); - } - } - }, - [errorMessage, retryDelay, retryMessage], - ); - - useEffect(() => { - if (autoSaveStatusActual === "saved" && autoSaveStack.nextSave) { - void executeSave(autoSaveStack.nextSave); - - setAutoSaveStack({ - pendingSave: autoSaveStack.nextSave, - nextSave: undefined, - }); - } - }, [autoSaveStatusActual, autoSaveStack, executeSave]); - - useEffect(() => { - if (autoSaveStatusActual === "saving") { - setAutoSaveStatusDelayed("saving"); - } else { - const delayedTimeout = setTimeout(() => { - setAutoSaveStatusDelayed(autoSaveStatusActual); - }, autoSaveStatusDelay); - - return () => { - clearTimeout(delayedTimeout); - }; - } - - return; - }, [autoSaveStatusActual, autoSaveStatusDelay]); - - return useMemo( - () => ({ - autoSaveStatus: autoSaveStatusDelayed, - setNextSave, - }), - [autoSaveStatusDelayed, setNextSave], - ); -}; - -type ToastErrorArgs = { - errorMessage: string; - retryDelay: number; - retryMessage: string; - setAutoSaveStatusActual: Dispatch>; - setAutoSaveStack: Dispatch>; -}; - -function toastError(args: ToastErrorArgs) { - const { - errorMessage, - retryDelay, - retryMessage, - setAutoSaveStatusActual, - setAutoSaveStack, - } = args; - const toastId = uniqueId(); - - toast.error( - () => ( - - - {errorMessage} - - - - ), - { - autoClose: false, - closeOnClick: false, - draggable: false, - toastId, - }, - ); -} diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypeProvider.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypeProvider.tsx index 75ff582ae0..49023d3e37 100644 --- a/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypeProvider.tsx +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypeProvider.tsx @@ -10,18 +10,16 @@ import { CustomType } from "@prismicio/types-internal/lib/customtypes"; import { useStableCallback } from "@prismicio/editor-support/React"; import useSliceMachineActions from "@src/modules/useSliceMachineActions"; -import { - AutoSaveStatus, - useAutoSave, -} from "@src/features/autoSave/useAutoSave"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; import { getFormat } from "@src/domain/customType"; import { updateCustomType } from "@src/apiClient"; +import { ActionQueueStatus, useActionQueue } from "@src/hooks/useActionQueue"; import { CUSTOM_TYPES_MESSAGES } from "../customTypesMessages"; type CustomTypeContext = { customType: CustomType; - autoSaveStatus: AutoSaveStatus; + actionQueueStatus: ActionQueueStatus; setCustomType: (customType: CustomType) => void; }; @@ -40,16 +38,17 @@ export function CustomTypeProvider(props: CustomTypeProviderProps) { const [customType, setCustomTypeState] = useState(initialCustomType); const format = getFormat(customType); const customTypeMessages = CUSTOM_TYPES_MESSAGES[format]; - const { autoSaveStatus, setNextSave } = useAutoSave({ + const { actionQueueStatus, setNextAction } = useActionQueue({ errorMessage: customTypeMessages.autoSaveFailed, }); const { saveCustomTypeSuccess } = useSliceMachineActions(); const stableSaveCustomTypeSuccess = useStableCallback(saveCustomTypeSuccess); + const { syncChanges } = useAutoSync(); const setCustomType = useCallback( (customType: CustomType) => { setCustomTypeState(customType); - setNextSave(async () => { + setNextAction(async () => { const { errors } = await updateCustomType(customType); if (errors.length > 0) { @@ -58,18 +57,20 @@ export function CustomTypeProvider(props: CustomTypeProviderProps) { // Update available custom types store with new custom type stableSaveCustomTypeSuccess(customType); + + syncChanges(); }); }, - [setNextSave, stableSaveCustomTypeSuccess], + [setNextAction, stableSaveCustomTypeSuccess, syncChanges], ); const contextValue: CustomTypeContext = useMemo( () => ({ - autoSaveStatus, + actionQueueStatus, customType, setCustomType, }), - [autoSaveStatus, customType, setCustomType], + [actionQueueStatus, customType, setCustomType], ); return ( diff --git a/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypesBuilderPage.tsx b/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypesBuilderPage.tsx index 5a763bfb35..3d1196433e 100644 --- a/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypesBuilderPage.tsx +++ b/packages/slice-machine/src/features/customTypes/customTypesBuilder/CustomTypesBuilderPage.tsx @@ -78,7 +78,7 @@ const CustomTypesBuilderPageWithProvider: React.FC< - {({ autoSaveStatus, customType, setCustomType }) => { + {({ actionQueueStatus, customType, setCustomType }) => { const format = getFormat(customType); const config = CUSTOM_TYPES_CONFIG[customTypeFromStore.format]; const messages = CUSTOM_TYPES_MESSAGES[customTypeFromStore.format]; @@ -92,7 +92,7 @@ const CustomTypesBuilderPageWithProvider: React.FC< page={customType.label ?? customType.id} /> - + {customType.format === "page" ? ( ) : undefined} diff --git a/packages/slice-machine/src/features/environments/actions/setEnvironment.ts b/packages/slice-machine/src/features/environments/actions/setEnvironment.ts index c162a2806a..a1f21cf4b4 100644 --- a/packages/slice-machine/src/features/environments/actions/setEnvironment.ts +++ b/packages/slice-machine/src/features/environments/actions/setEnvironment.ts @@ -1,8 +1,8 @@ import { revalidateData } from "@prismicio/editor-support/Suspense"; -import { Environment } from "@slicemachine/manager/client"; -import { getLegacySliceMachineState } from "@src/features/legacyState/actions/getLegacySliceMachineState"; +import { Environment } from "@slicemachine/manager/client"; import { managerClient } from "@src/managerClient"; +import { getState } from "@src/apiClient"; import { getActiveEnvironment } from "./getActiveEnvironment"; @@ -14,5 +14,5 @@ export async function setEnvironment( }); revalidateData(getActiveEnvironment, []); - revalidateData(getLegacySliceMachineState, []); + revalidateData(getState, []); } diff --git a/packages/slice-machine/src/features/legacyState/actions/getLegacySliceMachineState.ts b/packages/slice-machine/src/features/legacyState/actions/getLegacySliceMachineState.ts deleted file mode 100644 index 602e8cb98a..0000000000 --- a/packages/slice-machine/src/features/legacyState/actions/getLegacySliceMachineState.ts +++ /dev/null @@ -1,53 +0,0 @@ -import ServerState from "@models/server/ServerState"; -import { Slices } from "@lib/models/common/Slice"; -import { CustomTypes } from "@lib/models/common/CustomType"; -import { managerClient } from "@src/managerClient"; - -export async function getLegacySliceMachineState() { - const rawState = await managerClient.getState(); - - // `rawState` from the client contains non-SM-specific models. We need to - // transform the data to something SM recognizes. - const state: ServerState = { - ...rawState, - libraries: rawState.libraries.map((library) => { - return { - ...library, - components: library.components.map((component) => { - return { - ...component, - model: Slices.toSM(component.model), - - // Replace screnshot Blobs with URLs. - screenshots: Object.fromEntries( - Object.entries(component.screenshots).map( - ([variationID, screenshot]) => { - return [ - variationID, - { - ...screenshot, - url: URL.createObjectURL(screenshot.data), - }, - ]; - }, - ), - ), - }; - }), - }; - }), - customTypes: rawState.customTypes.map((customTypeModel) => { - return CustomTypes.toSM(customTypeModel); - }), - remoteCustomTypes: rawState.remoteCustomTypes.map( - (remoteCustomTypeModel) => { - return CustomTypes.toSM(remoteCustomTypeModel); - }, - ), - remoteSlices: rawState.remoteSlices.map((remoteSliceModel) => { - return Slices.toSM(remoteSliceModel); - }), - }; - - return state; -} diff --git a/packages/slice-machine/src/features/legacyState/useLegacySliceMachineState.ts b/packages/slice-machine/src/features/legacyState/useLegacySliceMachineState.ts deleted file mode 100644 index 2e0dd9e14e..0000000000 --- a/packages/slice-machine/src/features/legacyState/useLegacySliceMachineState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useRequest } from "@prismicio/editor-support/Suspense"; - -import { getLegacySliceMachineState } from "./actions/getLegacySliceMachineState"; - -export function useLegacySliceMachineState() { - return useRequest(getLegacySliceMachineState, []); -} diff --git a/packages/slice-machine/src/features/slices/sliceBuilder/SliceBuilderProvider.tsx b/packages/slice-machine/src/features/slices/sliceBuilder/SliceBuilderProvider.tsx index 4f2cb143c7..77617850cc 100644 --- a/packages/slice-machine/src/features/slices/sliceBuilder/SliceBuilderProvider.tsx +++ b/packages/slice-machine/src/features/slices/sliceBuilder/SliceBuilderProvider.tsx @@ -9,18 +9,16 @@ import { import { useStableCallback } 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"; +import { useAutoSync } from "@src/features/sync/AutoSyncProvider"; +import { ActionQueueStatus, useActionQueue } from "@src/hooks/useActionQueue"; type SliceContext = { slice: ComponentUI; - autoSaveStatus: AutoSaveStatus; + actionQueueStatus: ActionQueueStatus; setSlice: (slice: ComponentUI) => void; variation: VariationSM; }; @@ -37,12 +35,13 @@ export function SliceBuilderProvider(props: SliceBuilderProviderProps) { const router = useRouter(); const [slice, setSliceState] = useState(initialSlice); - const { autoSaveStatus, setNextSave } = useAutoSave({ + const { actionQueueStatus, setNextAction } = useActionQueue({ errorMessage: "Failed to save slice. Check your browser's console for more information.", }); const { saveSliceSuccess } = useSliceMachineActions(); const stableSaveSliceSuccess = useStableCallback(saveSliceSuccess); + const { syncChanges } = useAutoSync(); const variation = useMemo(() => { const variationName = router.query.variation; @@ -60,7 +59,7 @@ export function SliceBuilderProvider(props: SliceBuilderProviderProps) { const setSlice = useCallback( (slice: ComponentUI) => { setSliceState(slice); - setNextSave(async () => { + setNextAction(async () => { const { errors: updateSliceErrors } = await updateSlice(slice); if (updateSliceErrors.length > 0) { @@ -76,20 +75,23 @@ export function SliceBuilderProvider(props: SliceBuilderProviderProps) { throw readSliceMockErrors; } + // Update slices store with new slice stableSaveSliceSuccess({ ...slice, mocks }); + + syncChanges(); }); }, - [setNextSave, stableSaveSliceSuccess], + [setNextAction, stableSaveSliceSuccess, syncChanges], ); const contextValue: SliceContext = useMemo( () => ({ - autoSaveStatus, + actionQueueStatus, slice, setSlice, variation, }), - [autoSaveStatus, slice, setSlice, variation], + [actionQueueStatus, slice, setSlice, variation], ); return ( diff --git a/packages/slice-machine/src/features/sync/AutoSyncProvider.tsx b/packages/slice-machine/src/features/sync/AutoSyncProvider.tsx new file mode 100644 index 0000000000..54bf77c123 --- /dev/null +++ b/packages/slice-machine/src/features/sync/AutoSyncProvider.tsx @@ -0,0 +1,247 @@ +import { + FC, + PropsWithChildren, + createContext, + useCallback, + useContext, + useEffect, + useMemo, +} from "react"; +import { + useOnChange, + useStableCallback, +} from "@prismicio/editor-support/React"; + +import { AuthStatus } from "@src/modules/userContext/types"; +import useSliceMachineActions from "@src/modules/useSliceMachineActions"; +import { getState } from "@src/apiClient"; +import { + Environment, + isUnauthenticatedError, +} from "@slicemachine/manager/client"; +import { useNetwork } from "@src/hooks/useNetwork"; +import { useAuthStatus } from "@src/hooks/useAuthStatus"; +import { + ChangedCustomType, + ChangedSlice, +} from "@lib/models/common/ModelStatus"; + +import { useActiveEnvironment } from "../environments/useActiveEnvironment"; +import { ActionQueueStatus, useActionQueue } from "../../hooks/useActionQueue"; +import { pushChanges } from "./actions/pushChanges"; +import { useUnSyncChanges } from "./useUnSyncChanges"; +import { fetchUnSyncChanges } from "./fetchUnSyncChanges"; + +export type AutoSyncStatus = + | "not-active" + | "offline" + | "not-logged-in" + | "syncing" + | "synced" + | "failed"; + +type AutoSyncContext = { + autoSyncStatus: AutoSyncStatus; + syncChanges: (args?: SyncChangesArgs) => void; +}; + +type SyncChangesArgs = { + environment?: Environment; + loggedIn?: boolean; + changedCustomTypes?: ChangedCustomType[]; + changedSlices?: ChangedSlice[]; +}; + +const AutoSyncContextValue = createContext( + undefined, +); + +export const AutoSyncProvider: FC = (props) => { + const { children } = props; + const { unSyncedCustomTypes, unSyncedSlices } = useUnSyncChanges(); + const isOnline = useNetwork(); + const authStatus = useAuthStatus(); + const { refreshState, pushChangesSuccess } = useSliceMachineActions(); + const stableRefreshState = useStableCallback(refreshState); + const stablePushChangesSuccess = useStableCallback(pushChangesSuccess); + const { activeEnvironment } = useActiveEnvironment(); + const { setNextAction, actionQueueStatus } = useActionQueue({ + actionQueueStatusDelay: 0, + errorMessage: + "Failed to sync changes. Check your browser's console for more information.", + }); + + const syncChanges = useCallback( + (args: SyncChangesArgs = {}) => { + const { + // We default to the active environment if not provided. + // This is useful when we want to sync changes right after an environment switch. + environment = activeEnvironment, + + // We default to a full user logged in with internet access if not provider. + // This is useful when we want to sync changes right after the user logs in. + loggedIn = isOnline && authStatus === AuthStatus.AUTHORIZED, + } = args; + + console.log("1. Just calling syncChanges"); + + if (!loggedIn || environment?.kind !== "dev") { + return; + } + + setNextAction(async () => { + console.log("2. Inside setNextSave"); + + // We first get the remote models and local models to ensure we need + // to sync changes. + const { changedCustomTypes, changedSlices } = await fetchUnSyncChanges({ + isOnline, + authStatus, + }); + + if (changedCustomTypes.length === 0 && changedSlices.length === 0) { + return; + } + + console.log("3. Final call to pushChanges"); + + try { + await pushChanges({ + changedCustomTypes, + changedSlices, + // We force the deletion of documents as the auto-sync is only for + // the dev environment. + confirmDeleteDocuments: true, + }); + + // Now that the changes have been pushed, we need to update redux with + // the new remote models. + const serverState = await getState(); + stableRefreshState(serverState); + + // Update last sync value in local storage + stablePushChangesSuccess(); + } catch (error) { + if (isUnauthenticatedError(error)) { + // If the user is not authenticated, we don't want to let the user + // retry the sync. We just stop the sync and let the user + // know that they need to login again. + // This can easily happen if the the token expires when the user is + // offline. + return; + } + + // If the sync failed, we want to display the error message with + // the retry button. + throw error; + } + }); + }, + [ + stableRefreshState, + stablePushChangesSuccess, + setNextAction, + isOnline, + authStatus, + activeEnvironment, + ], + ); + + // We want to sync changes when the user loads the page and there are unsynced changes + useEffect( + () => { + if (unSyncedCustomTypes.length > 0 || unSyncedSlices.length > 0) { + console.log("Use Mount useEffect"); + + syncChanges(); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + // We want to sync changes when the user comes back online and there are unsynced changes + useOnChange(isOnline, () => { + if ( + isOnline && + (unSyncedCustomTypes.length > 0 || unSyncedSlices.length > 0) + ) { + console.log("isBackOnline useEffect"); + + syncChanges(); + } + }); + + const autoSyncStatus = useMemo( + () => + getAutoSyncStatus({ + activeEnvironment, + isOnline, + authStatus, + actionQueueStatus, + }), + [actionQueueStatus, authStatus, isOnline, activeEnvironment], + ); + + const contextValue = useMemo( + () => ({ + syncChanges, + autoSyncStatus, + }), + [syncChanges, autoSyncStatus], + ); + + return ( + + {children} + + ); +}; + +export function useAutoSync(): AutoSyncContext { + const context = useContext(AutoSyncContextValue); + + // Prevent introducing a lot of implementation details from redux in tests + if (process.env.NODE_ENV === "test") { + return { syncChanges: () => void 0, autoSyncStatus: "not-active" }; + } + + if (context === undefined) { + throw new Error("useAutoSync must be used within a AutoSyncProvider"); + } + + return context; +} + +type GetAutoSyncStatusArgs = { + activeEnvironment?: Environment; + isOnline: boolean; + authStatus: AuthStatus; + actionQueueStatus: ActionQueueStatus; +}; + +function getAutoSyncStatus(args: GetAutoSyncStatusArgs): AutoSyncStatus { + const { activeEnvironment, isOnline, authStatus, actionQueueStatus } = args; + + if (activeEnvironment === undefined || activeEnvironment.kind !== "dev") { + return "not-active"; + } + + if (!isOnline) { + return "offline"; + } + + if (authStatus !== AuthStatus.AUTHORIZED) { + return "not-logged-in"; + } + + if (actionQueueStatus === "failed") { + return "failed"; + } + + if (actionQueueStatus === "pending") { + return "syncing"; + } + + return "synced"; +} diff --git a/packages/slice-machine/src/features/changes/actions/pushChanges.ts b/packages/slice-machine/src/features/sync/actions/pushChanges.ts similarity index 100% rename from packages/slice-machine/src/features/changes/actions/pushChanges.ts rename to packages/slice-machine/src/features/sync/actions/pushChanges.ts diff --git a/packages/slice-machine/src/features/changes/actions/trackPushChangesSuccess.ts b/packages/slice-machine/src/features/sync/actions/trackPushChangesSuccess.ts similarity index 100% rename from packages/slice-machine/src/features/changes/actions/trackPushChangesSuccess.ts rename to packages/slice-machine/src/features/sync/actions/trackPushChangesSuccess.ts diff --git a/packages/slice-machine/src/features/sync/components/AutoSyncStatusIndicator.module.css b/packages/slice-machine/src/features/sync/components/AutoSyncStatusIndicator.module.css new file mode 100644 index 0000000000..bfe8d99dd6 --- /dev/null +++ b/packages/slice-machine/src/features/sync/components/AutoSyncStatusIndicator.module.css @@ -0,0 +1,17 @@ +.root { + align-items: center; + border-radius: 6px; + border: solid 1px transparent; + border-image: linear-gradient( + to right, + color-mix(in srgb, var(--grey6), transparent 100%), + var(--grey6), + color-mix(in srgb, var(--grey6), transparent 100%) + ) + 1 stretch; + display: flex; + gap: 4px; + height: 32px; + justify-content: center; + width: 100%; +} diff --git a/packages/slice-machine/src/features/sync/components/AutoSyncStatusIndicator.tsx b/packages/slice-machine/src/features/sync/components/AutoSyncStatusIndicator.tsx new file mode 100644 index 0000000000..064a4dab09 --- /dev/null +++ b/packages/slice-machine/src/features/sync/components/AutoSyncStatusIndicator.tsx @@ -0,0 +1,59 @@ +import { FC } from "react"; +import { Text, Tooltip } from "@prismicio/editor-ui"; + +import { Syncing } from "@src/icons/Syncing"; +import { Synced } from "@src/icons/Synced"; +import { SyncFailed } from "@src/icons/SyncFailed"; + +import styles from "./AutoSyncStatusIndicator.module.css"; + +type AutoSyncStatusIndicatorProps = { + autoSyncStatus: "syncing" | "synced" | "failed"; +}; + +export const AutoSyncStatusIndicator: FC = ( + props, +) => { + const { autoSyncStatus } = props; + + let autoSaveStatusInfo; + + console.log("autoSyncStatus:", autoSyncStatus); + + switch (autoSyncStatus) { + case "syncing": + autoSaveStatusInfo = { + icon: , + text: "Syncing...", + tooltipText: + "Slice Machine is attempting to sync your changes with your Personal Sandbox.", + }; + break; + case "synced": + autoSaveStatusInfo = { + icon: , + text: "In sync", + tooltipText: "All your changes are synced with your Personal Sandbox.", + }; + break; + case "failed": + autoSaveStatusInfo = { + icon: , + text: "Sync failed", + tooltipText: + "An error occurred while syncing your changes with your Personal Sandbox.", + }; + break; + } + + return autoSaveStatusInfo.tooltipText !== undefined ? ( + +
+ {autoSaveStatusInfo.icon} + {autoSaveStatusInfo.text} +
+
+ ) : ( + autoSaveStatusInfo.icon + ); +}; diff --git a/packages/slice-machine/src/features/sync/fetchUnSyncChanges.ts b/packages/slice-machine/src/features/sync/fetchUnSyncChanges.ts new file mode 100644 index 0000000000..6a35a1913d --- /dev/null +++ b/packages/slice-machine/src/features/sync/fetchUnSyncChanges.ts @@ -0,0 +1,40 @@ +import { normalizeFrontendCustomTypes } from "@lib/models/common/normalizers/customType"; +import { normalizeFrontendSlices } from "@lib/models/common/normalizers/slices"; +import { getState } from "@src/apiClient"; +import { AuthStatus } from "@src/modules/userContext/types"; + +import { UnSyncedChanges, getUnSyncedChanges } from "./getUnSyncChanges"; + +type FetchUnSyncChangesArgs = { + isOnline: boolean; + authStatus: AuthStatus; +}; + +export async function fetchUnSyncChanges( + args: FetchUnSyncChangesArgs, +): Promise { + const { isOnline, authStatus } = args; + + const serverState = await getState(); + + const slices = normalizeFrontendSlices( + serverState.libraries, + serverState.remoteSlices, + ); + const customTypes = Object.values( + normalizeFrontendCustomTypes( + serverState.customTypes, + serverState.remoteCustomTypes, + ), + ); + + const unSyncedChanges = getUnSyncedChanges({ + customTypes, + libraries: serverState.libraries, + slices, + isOnline, + authStatus, + }); + + return unSyncedChanges; +} diff --git a/packages/slice-machine/src/features/sync/getUnSyncChanges.ts b/packages/slice-machine/src/features/sync/getUnSyncChanges.ts new file mode 100644 index 0000000000..29b563ba1f --- /dev/null +++ b/packages/slice-machine/src/features/sync/getUnSyncChanges.ts @@ -0,0 +1,144 @@ +import { ComponentUI } from "@lib/models/common/ComponentUI"; +import { LibraryUI } from "@lib/models/common/LibraryUI"; +import { + LocalOrRemoteCustomType, + LocalOrRemoteSlice, + RemoteOnlySlice, + getModelId, + hasLocal, + isRemoteOnly, +} from "@lib/models/common/ModelData"; +import { + ChangedCustomType, + ChangedSlice, + ModelStatus, + computeStatuses, +} from "@lib/models/common/ModelStatus"; +import { AuthStatus } from "@src/modules/userContext/types"; + +type GetUnSyncedChangesArgs = { + customTypes: LocalOrRemoteCustomType[]; + libraries: Readonly; + slices: LocalOrRemoteSlice[]; + isOnline: boolean; + authStatus: AuthStatus; +}; + +const unSyncStatuses = [ + ModelStatus.New, + ModelStatus.Modified, + ModelStatus.Deleted, +]; + +export type UnSyncedChanges = { + changedCustomTypes: ChangedCustomType[]; + unSyncedCustomTypes: LocalOrRemoteCustomType[]; + changedSlices: ChangedSlice[]; + unSyncedSlices: ComponentUI[]; + modelsStatuses: ModelsStatuses; +}; + +export function getUnSyncedChanges( + args: GetUnSyncedChangesArgs, +): UnSyncedChanges { + const { customTypes, libraries, slices, isOnline, authStatus } = args; + + const modelsStatuses = getModelStatus({ + slices, + customTypes, + isOnline, + authStatus, + }); + + const localComponents: ComponentUI[] = libraries.flatMap( + (lib) => lib.components, + ); + const deletedComponents: ComponentUI[] = slices + .filter(isRemoteOnly) + .map(wrapDeletedSlice); + const components: ComponentUI[] = localComponents + .concat(deletedComponents) + .sort((s1, s2) => (s1.model.name > s2.model.name ? 1 : -1)); + + const unSyncedSlices = components.filter( + (component) => + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + modelsStatuses.slices[component.model.id] && + unSyncStatuses.includes(modelsStatuses.slices[component.model.id]), + ); + const unSyncedCustomTypes = customTypes.filter( + (customType) => + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + modelsStatuses.customTypes[getModelId(customType)] && + unSyncStatuses.includes( + modelsStatuses.customTypes[getModelId(customType)], + ), + ); + + const changedSlices = unSyncedSlices + .map((s) => ({ + slice: s, + status: modelsStatuses.slices[s.model.id], + })) + .filter((s): s is ChangedSlice => unSyncStatuses.includes(s.status)); + const changedCustomTypes = unSyncedCustomTypes + .map((model) => (hasLocal(model) ? model.local : model.remote)) + .map((ct) => ({ + customType: ct, + status: modelsStatuses.customTypes[ct.id], + })) + .filter((c): c is ChangedCustomType => unSyncStatuses.includes(c.status)); + + return { + changedCustomTypes, + changedSlices, + unSyncedCustomTypes, + unSyncedSlices, + modelsStatuses, + }; +} + +// ComponentUI are manipulated on all the relevant pages +// But the data is not available for remote only slices +// which have been deleted locally +// Should revisit this with the sync improvements +function wrapDeletedSlice(s: RemoteOnlySlice): ComponentUI { + return { + model: s.remote, + screenshots: {}, + from: "", + href: "", + pathToSlice: "", + fileName: "", + extension: "", + }; +} + +type GetModelStatusArgs = { + slices: LocalOrRemoteSlice[]; + customTypes: LocalOrRemoteCustomType[]; + isOnline: boolean; + authStatus: AuthStatus; +}; + +// Slices and Custom Types needs to be separated as Ids are not unique amongst each others. +export type ModelsStatuses = { + slices: { [sliceId: string]: ModelStatus }; + customTypes: { [ctId: string]: ModelStatus }; +}; + +export const getModelStatus = (args: GetModelStatusArgs): ModelsStatuses => { + const { slices, customTypes, isOnline, authStatus } = args; + + const userHasAccessToModels = + isOnline && + authStatus != AuthStatus.FORBIDDEN && + authStatus != AuthStatus.UNAUTHORIZED; + + const modelsStatuses = { + slices: computeStatuses(slices, userHasAccessToModels), + customTypes: computeStatuses(customTypes, userHasAccessToModels), + }; + + return modelsStatuses; +}; diff --git a/packages/slice-machine/src/features/sync/useUnSyncChanges.ts b/packages/slice-machine/src/features/sync/useUnSyncChanges.ts new file mode 100644 index 0000000000..5aab0b0d6b --- /dev/null +++ b/packages/slice-machine/src/features/sync/useUnSyncChanges.ts @@ -0,0 +1,32 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { selectAllCustomTypes } from "@src/modules/availableCustomTypes"; +import { getFrontendSlices, getLibraries } from "@src/modules/slices"; +import { SliceMachineStoreType } from "@src/redux/type"; +import { useAuthStatus } from "@src/hooks/useAuthStatus"; + +import { useNetwork } from "../../hooks/useNetwork"; +import { UnSyncedChanges, getUnSyncedChanges } from "./getUnSyncChanges"; + +export const useUnSyncChanges = (): UnSyncedChanges => { + const { customTypes, slices, libraries } = useSelector( + (store: SliceMachineStoreType) => ({ + customTypes: selectAllCustomTypes(store), + slices: getFrontendSlices(store), + libraries: getLibraries(store), + }), + ); + const isOnline = useNetwork(); + const authStatus = useAuthStatus(); + + const unSyncedChange = getUnSyncedChanges({ + authStatus, + customTypes, + isOnline, + libraries, + slices, + }); + + return useMemo(() => unSyncedChange, [unSyncedChange]); +}; diff --git a/packages/slice-machine/src/hooks/useActionQueue.tsx b/packages/slice-machine/src/hooks/useActionQueue.tsx new file mode 100644 index 0000000000..e8104e8f45 --- /dev/null +++ b/packages/slice-machine/src/hooks/useActionQueue.tsx @@ -0,0 +1,177 @@ +import { + useCallback, + useState, + useEffect, + SetStateAction, + Dispatch, + useMemo, +} from "react"; +import { uniqueId } from "lodash"; +import { toast } from "react-toastify"; +import { Box, Button, Text } from "@prismicio/editor-ui"; + +export type ActionQueueStatus = "pending" | "done" | "failed"; + +type UseActionQueueArgs = { + actionQueueStatusDelay?: number; + errorMessage: string; + retryDelay?: number; + retryMessage?: string; +}; + +type UseActionQueueReturnType = { + actionQueueStatus: ActionQueueStatus; + setNextAction: (nextAction: NextAction) => void; +}; + +type ActionQueueStack = { + pendingAction: NextAction | undefined; + nextAction: NextAction | undefined; +}; + +type NextAction = () => Promise; + +export const useActionQueue = ( + args: UseActionQueueArgs, +): UseActionQueueReturnType => { + const { + actionQueueStatusDelay = 300, + errorMessage, + retryDelay = 1000, + retryMessage = "Retry", + } = args; + + const [actionQueueStack, setActionQueueStack] = useState({ + pendingAction: undefined, + nextAction: undefined, + }); + const [actionQueueStatusActual, setActionQueueStatusActual] = + useState("done"); + const [actionQueueStatusDelayed, setActionQueueStatusDelayed] = + useState(actionQueueStatusActual); + + const setNextAction = useCallback((nextAction: NextAction) => { + setActionQueueStack((prevState) => ({ + ...prevState, + nextAction, + })); + }, []); + + const executeAction = useCallback( + async (nextAction?: NextAction) => { + if (nextAction) { + setActionQueueStatusActual("pending"); + + try { + await nextAction(); + + setActionQueueStatusActual("done"); + } catch (error) { + setActionQueueStatusActual("failed"); + console.error(errorMessage, error); + + toastError({ + errorMessage, + retryDelay, + retryMessage, + setActionQueueStatusActual, + setActionQueueStack, + }); + } + } + }, + [errorMessage, retryDelay, retryMessage], + ); + + useEffect(() => { + if (actionQueueStatusActual === "done" && actionQueueStack.nextAction) { + void executeAction(actionQueueStack.nextAction); + + setActionQueueStack({ + pendingAction: actionQueueStack.nextAction, + nextAction: undefined, + }); + } + }, [actionQueueStatusActual, actionQueueStack, executeAction]); + + useEffect(() => { + if (actionQueueStatusActual === "pending") { + setActionQueueStatusDelayed("pending"); + } else { + const delayedTimeout = setTimeout(() => { + setActionQueueStatusDelayed(actionQueueStatusActual); + }, actionQueueStatusDelay); + + return () => { + clearTimeout(delayedTimeout); + }; + } + + return; + }, [actionQueueStatusActual, actionQueueStatusDelay]); + + return useMemo( + () => ({ + actionQueueStatus: actionQueueStatusDelayed, + setNextAction, + }), + [actionQueueStatusDelayed, setNextAction], + ); +}; + +type ToastErrorArgs = { + errorMessage: string; + retryDelay: number; + retryMessage: string; + setActionQueueStatusActual: Dispatch>; + setActionQueueStack: Dispatch>; +}; + +function toastError(args: ToastErrorArgs) { + const { + errorMessage, + retryDelay, + retryMessage, + setActionQueueStatusActual, + setActionQueueStack, + } = args; + const toastId = uniqueId(); + + toast.error( + () => ( + + + {errorMessage} + + + + ), + { + autoClose: false, + closeOnClick: false, + draggable: false, + toastId, + }, + ); +} diff --git a/packages/slice-machine/src/hooks/useAuthStatus.ts b/packages/slice-machine/src/hooks/useAuthStatus.ts new file mode 100644 index 0000000000..0420468488 --- /dev/null +++ b/packages/slice-machine/src/hooks/useAuthStatus.ts @@ -0,0 +1,12 @@ +import { useSelector } from "react-redux"; + +import { getAuthStatus } from "@src/modules/environment"; +import { SliceMachineStoreType } from "@src/redux/type"; + +export function useAuthStatus() { + const { authStatus } = useSelector((store: SliceMachineStoreType) => ({ + authStatus: getAuthStatus(store), + })); + + return authStatus; +} diff --git a/packages/slice-machine/src/hooks/useModelStatus.ts b/packages/slice-machine/src/hooks/useModelStatus.ts deleted file mode 100644 index 6037417d4f..0000000000 --- a/packages/slice-machine/src/hooks/useModelStatus.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - computeModelStatus, - ModelStatus, -} from "@lib/models/common/ModelStatus"; -import { getAuthStatus } from "@src/modules/environment"; -import { AuthStatus } from "@src/modules/userContext/types"; -import { SliceMachineStoreType } from "@src/redux/type"; -import { useSelector } from "react-redux"; -import { useNetwork } from "./useNetwork"; -import { - hasLocal, - LocalOrRemoteCustomType, - LocalOrRemoteModel, - LocalOrRemoteSlice, -} from "@lib/models/common/ModelData"; - -// Slices and Custom Types needs to be separated as Ids are not unique amongst each others. -export interface ModelStatusInformation { - modelsStatuses: { - slices: { [sliceId: string]: ModelStatus }; - customTypes: { [ctId: string]: ModelStatus }; - }; - authStatus: AuthStatus; - isOnline: boolean; -} - -function computeStatuses( - models: LocalOrRemoteCustomType[], - userHasAccessToModels: boolean, -): { [sliceId: string]: ModelStatus }; -function computeStatuses( - models: LocalOrRemoteSlice[], - userHasAccessToModels: boolean, -): { [sliceId: string]: ModelStatus }; -function computeStatuses( - models: LocalOrRemoteModel[], - userHasAccessToModels: boolean, -) { - return models.reduce<{ [id: string]: ModelStatus }>( - (acc, model) => { - const { status } = computeModelStatus(model, userHasAccessToModels); - - return { - ...acc, - [hasLocal(model) ? model.local.id : model.remote.id]: status, - }; - }, - {} as { [sliceId: string]: ModelStatus }, - ); -} - -export const useModelStatus = ({ - slices = [], - customTypes = [], -}: { - slices?: LocalOrRemoteSlice[]; - customTypes?: LocalOrRemoteCustomType[]; -}): ModelStatusInformation => { - const isOnline = useNetwork(); - const { authStatus } = useSelector((store: SliceMachineStoreType) => ({ - authStatus: getAuthStatus(store), - })); - const userHasAccessToModels = - isOnline && - authStatus != AuthStatus.FORBIDDEN && - authStatus != AuthStatus.UNAUTHORIZED; - - const modelsStatuses = { - slices: computeStatuses(slices, userHasAccessToModels), - customTypes: computeStatuses(customTypes, userHasAccessToModels), - }; - - return { - modelsStatuses, - authStatus, - isOnline, - }; -}; diff --git a/packages/slice-machine/src/hooks/useUnSyncChanges.ts b/packages/slice-machine/src/hooks/useUnSyncChanges.ts deleted file mode 100644 index 7b30ffce66..0000000000 --- a/packages/slice-machine/src/hooks/useUnSyncChanges.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ComponentUI } from "@lib/models/common/ComponentUI"; -import { ModelStatus } from "@lib/models/common/ModelStatus"; -import { selectAllCustomTypes } from "@src/modules/availableCustomTypes"; -import { getFrontendSlices, getLibraries } from "@src/modules/slices"; -import { SliceMachineStoreType } from "@src/redux/type"; -import { useSelector } from "react-redux"; -import { ModelStatusInformation, useModelStatus } from "./useModelStatus"; -import { - LocalOrRemoteCustomType, - RemoteOnlySlice, - getModelId, - isRemoteOnly, -} from "@lib/models/common/ModelData"; - -export const unSyncStatuses = [ - ModelStatus.New, - ModelStatus.Modified, - ModelStatus.Deleted, -]; - -export interface UnSyncChanges extends ModelStatusInformation { - unSyncedSlices: ComponentUI[]; - unSyncedCustomTypes: LocalOrRemoteCustomType[]; -} - -// ComponentUI are manipulated on all the relevant pages -// But the data is not available for remote only slices -// which have been deleted locally -// Should revisit this with the sync improvements -const wrapDeletedSlice = (s: RemoteOnlySlice): ComponentUI => ({ - model: s.remote, - screenshots: {}, - from: "", - href: "", - pathToSlice: "", - fileName: "", - extension: "", -}); - -export const useUnSyncChanges = (): UnSyncChanges => { - const { customTypes, slices, libraries } = useSelector( - (store: SliceMachineStoreType) => ({ - customTypes: selectAllCustomTypes(store), - slices: getFrontendSlices(store), - libraries: getLibraries(store), - }), - ); - - const { modelsStatuses, authStatus, isOnline } = useModelStatus({ - slices, - customTypes, - }); - - const localComponents: ComponentUI[] = libraries.flatMap( - (lib) => lib.components, - ); - - const deletedComponents: ComponentUI[] = slices - .filter(isRemoteOnly) - .map(wrapDeletedSlice); - - const components: ComponentUI[] = localComponents - .concat(deletedComponents) - .sort((s1, s2) => (s1.model.name > s2.model.name ? 1 : -1)); - - const unSyncedSlices = components.filter( - (component) => - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - modelsStatuses.slices[component.model.id] && - unSyncStatuses.includes(modelsStatuses.slices[component.model.id]), - ); - const unSyncedCustomTypes = customTypes.filter( - (customType) => - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - modelsStatuses.customTypes[getModelId(customType)] && - unSyncStatuses.includes( - modelsStatuses.customTypes[getModelId(customType)], - ), - ); - - return { - unSyncedSlices, - unSyncedCustomTypes, - modelsStatuses, - authStatus, - isOnline, - }; -}; diff --git a/packages/slice-machine/src/icons/SyncFailed.tsx b/packages/slice-machine/src/icons/SyncFailed.tsx new file mode 100644 index 0000000000..8a42368d66 --- /dev/null +++ b/packages/slice-machine/src/icons/SyncFailed.tsx @@ -0,0 +1,30 @@ +import type { FC, SVGProps } from "react"; + +export const SyncFailed: FC> = (props) => ( + + + + + + + + + + + +); diff --git a/packages/slice-machine/src/icons/Synced.tsx b/packages/slice-machine/src/icons/Synced.tsx new file mode 100644 index 0000000000..f0fef42e2b --- /dev/null +++ b/packages/slice-machine/src/icons/Synced.tsx @@ -0,0 +1,19 @@ +import type { FC, SVGProps } from "react"; + +export const Synced: FC> = (props) => ( + + + +); diff --git a/packages/slice-machine/src/icons/Syncing.tsx b/packages/slice-machine/src/icons/Syncing.tsx new file mode 100644 index 0000000000..172a61a61e --- /dev/null +++ b/packages/slice-machine/src/icons/Syncing.tsx @@ -0,0 +1,23 @@ +import type { FC, SVGProps } from "react"; + +export const Syncing: FC> = (props) => ( + + + + +); diff --git a/packages/slice-machine/test/__testutils__/index.tsx b/packages/slice-machine/test/__testutils__/index.tsx index 89a9cfba21..ee86ce03da 100644 --- a/packages/slice-machine/test/__testutils__/index.tsx +++ b/packages/slice-machine/test/__testutils__/index.tsx @@ -1,6 +1,7 @@ import { render as rtlRender, RenderOptions } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Store, AnyAction } from "redux"; +import { ErrorBoundary } from "@prismicio/editor-ui"; import { Provider } from "react-redux"; import type { SliceMachineStoreType } from "../../src/redux/type"; @@ -41,11 +42,13 @@ function render( children: any; }) { return ( - - - {children} - - + + + + {children} + + + ); } return { diff --git a/packages/slice-machine/test/components/LoginModal.test.tsx b/packages/slice-machine/test/components/LoginModal.test.tsx deleted file mode 100644 index 93994203e8..0000000000 --- a/packages/slice-machine/test/components/LoginModal.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// @vitest-environment jsdom - -import { describe, test, expect, vi } from "vitest"; -import React from "react"; -import { render } from "@testing-library/react"; -import LoginModal from "@components/LoginModal"; -import { FrontEndEnvironment } from "@lib/models/common/Environment"; -import { useSelector } from "react-redux"; - -const mockDispatch = vi.fn(); -vi.mock("react-beautiful-dnd", () => { - return {}; -}); -vi.mock("react-redux", () => ({ - useSelector: vi.fn(), - useDispatch: () => mockDispatch, -})); - -const useSelectorMock = vi.mocked(useSelector); - -const App = () => ; - -describe("LoginModal", () => { - window.open = vi.fn(); - - const div = document.createElement("div"); - div.id = "__next"; - document.body.appendChild(div); - - test("when given a prismic url in env it should open to prismic.io/dashboard", () => { - useSelectorMock.mockImplementation(() => ({ - env: { - manifest: { - apiEndpoint: "https://foo.prismic.io/api/v2", - }, - } as FrontEndEnvironment, - isOpen: true, - isLoginLoading: true, - })); - const result = render(); - - expect(result.getByText("Click here").closest("a")).toHaveAttribute( - "href", - // Since we're not using the `start-slicemachine` server proxy, port defaults to Next/React Testing Library's default port - "https://prismic.io/dashboard/cli/login?source=slice-machine&port=3000&path=/api/auth", - ); - }); - - test("when given wroom.io url it should open to wroom.io/dashboard", () => { - useSelectorMock.mockImplementation(() => ({ - env: { - manifest: { - apiEndpoint: "https://foo.wroom.io/api/v2", - }, - } as FrontEndEnvironment, - isOpen: true, - isLoginLoading: true, - })); - const result = render(); - - expect(result.getByText("Click here").closest("a")).toHaveAttribute( - "href", - // Since we're not using the `start-slicemachine` server proxy, port defaults to Next/React Testing Library's default port - "https://wroom.io/dashboard/cli/login?source=slice-machine&port=3000&path=/api/auth", - ); - }); -}); diff --git a/packages/slice-machine/test/src/hooks/useModelStatus.test.ts b/packages/slice-machine/test/src/hooks/useModelStatus.test.ts deleted file mode 100644 index 158a71e77d..0000000000 --- a/packages/slice-machine/test/src/hooks/useModelStatus.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, test, beforeEach, expect, vi } from "vitest"; -import { useModelStatus } from "../../../src/hooks/useModelStatus"; -import * as networkHook from "../../../src/hooks/useNetwork"; -import { AuthStatus } from "@src/modules/userContext/types"; - -import { Slices } from "@lib/models/common/Slice"; -import SliceMock from "../../__fixtures__/sliceModel"; - -import { CustomTypes } from "@lib/models/common/CustomType"; -import { customTypeMock } from "../../__fixtures__/customType"; -import { ModelStatus } from "@lib/models/common/ModelStatus"; - -const mockSelector = vi.fn(); -vi.mock("react-redux", async () => { - const actual: typeof import("react-redux") = - await vi.importActual("react-redux"); - - return { - ...actual, - useSelector: () => mockSelector(), - }; -}); - -const BaseSliceMock = { - ...SliceMock, - variations: SliceMock.variations.map((variation) => { - // @ts-expect-error we know imageUrl is optional - delete variation["imageUrl"]; - return variation; - }), -}; -const sliceModel = Slices.toSM(BaseSliceMock); -const customTypeModel = CustomTypes.toSM(customTypeMock); - -describe("[useModelStatus hook]", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test("it should return the model status correctly", () => { - vi.spyOn(networkHook, "useNetwork").mockImplementation(() => true); // isOnline - mockSelector.mockReturnValue({ authStatus: AuthStatus.AUTHORIZED }); - - const result = useModelStatus({ - slices: [{ local: sliceModel, remote: sliceModel, localScreenshots: {} }], - customTypes: [{ local: customTypeModel }], - }); - - expect(result).toEqual({ - modelsStatuses: { - slices: { [sliceModel.id]: ModelStatus.Synced }, - customTypes: { [customTypeModel.id]: ModelStatus.New }, - }, - authStatus: AuthStatus.AUTHORIZED, - isOnline: true, - }); - }); - - test("it should return Unknown status is the user doesn't have internet", () => { - vi.spyOn(networkHook, "useNetwork").mockImplementation(() => false); // isOnline - mockSelector.mockReturnValue({ authStatus: AuthStatus.AUTHORIZED }); - - const result = useModelStatus({ - slices: [{ local: sliceModel, remote: sliceModel, localScreenshots: {} }], - customTypes: [{ local: customTypeModel }], - }); - - expect(result).toEqual({ - modelsStatuses: { - slices: { [sliceModel.id]: ModelStatus.Unknown }, - customTypes: { [customTypeModel.id]: ModelStatus.Unknown }, - }, - authStatus: AuthStatus.AUTHORIZED, - isOnline: false, - }); - }); - - test("it should return Unknown status is the user is not connected to Prismic", () => { - vi.spyOn(networkHook, "useNetwork").mockImplementation(() => true); // isOnline - mockSelector.mockReturnValue({ authStatus: AuthStatus.UNAUTHORIZED }); - - const result = useModelStatus({ - slices: [{ local: sliceModel, remote: sliceModel, localScreenshots: {} }], - customTypes: [{ local: customTypeModel }], - }); - - expect(result).toEqual({ - modelsStatuses: { - slices: { [sliceModel.id]: ModelStatus.Unknown }, - customTypes: { [customTypeModel.id]: ModelStatus.Unknown }, - }, - authStatus: AuthStatus.UNAUTHORIZED, - isOnline: true, - }); - }); - - test("it should return Unknown status is the user doesn't have access to the repository", () => { - vi.spyOn(networkHook, "useNetwork").mockImplementation(() => true); // isOnline - mockSelector.mockReturnValue({ authStatus: AuthStatus.FORBIDDEN }); - - const result = useModelStatus({ - slices: [{ local: sliceModel, remote: sliceModel, localScreenshots: {} }], - customTypes: [{ local: customTypeModel }], - }); - - expect(result).toEqual({ - modelsStatuses: { - slices: { [sliceModel.id]: ModelStatus.Unknown }, - customTypes: { [customTypeModel.id]: ModelStatus.Unknown }, - }, - authStatus: AuthStatus.FORBIDDEN, - isOnline: true, - }); - }); -}); diff --git a/playwright/pages/components/Menu.ts b/playwright/pages/components/Menu.ts index b13052bbfa..e11bf3caca 100644 --- a/playwright/pages/components/Menu.ts +++ b/playwright/pages/components/Menu.ts @@ -41,7 +41,9 @@ export class Menu { name: "Slices", exact: true, }); - this.changesLink = this.menu.getByRole("link", { name: "Changes" }); + this.changesLink = this.menu.getByRole("button", { + name: "Review changes", + }); this.tutorialLink = this.menu.getByRole("link", { name: "Tutorials", exact: true,