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,