From a34ac50f79df6fca522906a44ffab953de1f8a39 Mon Sep 17 00:00:00 2001
From: Xavier Rutayisire <xavier.rutayisire@gmail.com>
Date: Thu, 12 Oct 2023 19:29:02 +0200
Subject: [PATCH] feat(review): Satisfaction modal for advanced repository

---
 .../manager/src/managers/telemetry/types.ts   |   6 +-
 .../slice-machine/components/App/index.tsx    |   2 +-
 .../ReviewModal/{index.tsx => ReviewForm.tsx} | 132 +++++-------------
 .../ReviewModal/ReviewFormSelect.tsx          |  35 +++++
 .../components/ReviewModal/ReviewModal.tsx    |  70 ++++++++++
 .../components/ReviewModal/index.ts           |   1 +
 .../src/modules/useSliceMachineActions.ts     |  16 ++-
 .../src/modules/userContext/index.ts          |  27 +++-
 .../src/modules/userContext/types.ts          |  15 +-
 .../test/src/modules/userContext.test.ts      |  36 +++--
 10 files changed, 222 insertions(+), 118 deletions(-)
 rename packages/slice-machine/components/ReviewModal/{index.tsx => ReviewForm.tsx} (56%)
 create mode 100644 packages/slice-machine/components/ReviewModal/ReviewFormSelect.tsx
 create mode 100644 packages/slice-machine/components/ReviewModal/ReviewModal.tsx
 create mode 100644 packages/slice-machine/components/ReviewModal/index.ts

diff --git a/packages/manager/src/managers/telemetry/types.ts b/packages/manager/src/managers/telemetry/types.ts
index a5aa65198a..193321ed4d 100644
--- a/packages/manager/src/managers/telemetry/types.ts
+++ b/packages/manager/src/managers/telemetry/types.ts
@@ -94,7 +94,11 @@ type CommandInitEndSegmentEvent = SegmentEvent<
 
 type ReviewSegmentEvent = SegmentEvent<
 	typeof SegmentEventType.review,
-	{ rating: number; comment: string }
+	{
+		rating: number;
+		comment: string;
+		type: "onboarding" | "advanced repository";
+	}
 >;
 
 type SliceSimulatorSetupSegmentEvent = SegmentEvent<
diff --git a/packages/slice-machine/components/App/index.tsx b/packages/slice-machine/components/App/index.tsx
index 63a9eca08f..dee2cfaf82 100644
--- a/packages/slice-machine/components/App/index.tsx
+++ b/packages/slice-machine/components/App/index.tsx
@@ -4,7 +4,7 @@ import { BaseStyles } from "theme-ui";
 
 import { AppLayout, AppLayoutContent } from "@components/AppLayout";
 import LoginModal from "@components/LoginModal";
-import ReviewModal from "@components/ReviewModal";
+import { ReviewModal } from "@components/ReviewModal";
 import { MissingLibraries } from "@components/MissingLibraries";
 import useServerState from "@src/hooks/useServerState";
 import { SliceMachineStoreType } from "@src/redux/type";
diff --git a/packages/slice-machine/components/ReviewModal/index.tsx b/packages/slice-machine/components/ReviewModal/ReviewForm.tsx
similarity index 56%
rename from packages/slice-machine/components/ReviewModal/index.tsx
rename to packages/slice-machine/components/ReviewModal/ReviewForm.tsx
index 4a37d77118..08b3e9aef8 100644
--- a/packages/slice-machine/components/ReviewModal/index.tsx
+++ b/packages/slice-machine/components/ReviewModal/ReviewForm.tsx
@@ -1,6 +1,5 @@
-import Modal from "react-modal";
-import SliceMachineModal from "@components/SliceMachineModal";
-import { Field, FieldProps, Form, Formik } from "formik";
+import { FC } from "react";
+import { Field, Form, Formik } from "formik";
 import {
   Box,
   Button,
@@ -11,107 +10,50 @@ import {
   Text,
   Textarea,
 } from "theme-ui";
+import Modal from "react-modal";
 import { useSelector } from "react-redux";
+
+import SliceMachineModal from "@components/SliceMachineModal";
 import { SliceMachineStoreType } from "@src/redux/type";
 import { isModalOpen } from "@src/modules/modal";
 import { isLoading } from "@src/modules/loading";
 import { LoadingKeysEnum } from "@src/modules/loading/types";
-import {
-  getLastSyncChange,
-  userHasSendAReview,
-} from "@src/modules/userContext";
 import useSliceMachineActions from "@src/modules/useSliceMachineActions";
 import { ModalKeysEnum } from "@src/modules/modal/types";
 import { telemetry } from "@src/apiClient";
-import { selectAllCustomTypes } from "@src/modules/availableCustomTypes";
-import { getLibraries } from "@src/modules/slices";
-import { hasLocal } from "@lib/models/common/ModelData";
+import { UserReviewType } from "@src/modules/userContext/types";
 
-Modal.setAppElement("#__next");
+import { ReviewFormSelect } from "./ReviewFormSelect";
 
-const ratingSelectable = [1, 2, 3, 4, 5];
+Modal.setAppElement("#__next");
 
-const SelectReviewComponent = ({ field, form }: FieldProps) => {
-  return (
-    <Box sx={{ mb: 3, display: "flex", justifyContent: "space-between" }}>
-      {ratingSelectable.map((rating, index) => (
-        <Button
-          variant="secondary"
-          type="button"
-          key={index}
-          onClick={() => void form.setFieldValue("rating", rating)}
-          className={field.value === rating ? "selected" : ""}
-          sx={{
-            "&:not(:last-of-type)": {
-              mr: 1,
-            },
-            "&.selected": {
-              backgroundColor: "code.gray",
-              color: "white",
-            },
-          }}
-          data-cy={`review-form-score-${rating}`}
-        >
-          {rating}
-        </Button>
-      ))}
-    </Box>
-  );
+type ReviewFormProps = {
+  reviewType: UserReviewType;
 };
 
-const ReviewModal: React.FunctionComponent = () => {
-  const {
-    isReviewLoading,
-    isLoginModalOpen,
-    hasSendAReview,
-    customTypes,
-    libraries,
-    lastSyncChange,
-  } = useSelector((store: SliceMachineStoreType) => ({
-    isReviewLoading: isLoading(store, LoadingKeysEnum.REVIEW),
-    isLoginModalOpen: isModalOpen(store, ModalKeysEnum.LOGIN),
-    hasSendAReview: userHasSendAReview(store),
-    customTypes: selectAllCustomTypes(store),
-    libraries: getLibraries(store),
-    lastSyncChange: getLastSyncChange(store),
-  }));
-
+export const ReviewForm: FC<ReviewFormProps> = (props) => {
+  const { reviewType } = props;
+  const { isReviewLoading, isLoginModalOpen } = useSelector(
+    (store: SliceMachineStoreType) => ({
+      isReviewLoading: isLoading(store, LoadingKeysEnum.REVIEW),
+      isLoginModalOpen: isModalOpen(store, ModalKeysEnum.LOGIN),
+    })
+  );
   const { skipReview, sendAReview, startLoadingReview, stopLoadingReview } =
     useSliceMachineActions();
 
-  const sliceCount =
-    // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
-    libraries && libraries.length
-      ? libraries.reduce((count, lib) => {
-          // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
-          if (!lib) return count;
-          return count + lib.components.length;
-        }, 0)
-      : 0;
-
-  const hasSliceWithinCustomType = customTypes.some(
-    (customType) =>
-      hasLocal(customType) &&
-      customType.local.tabs.some(
-        (tab) => tab.sliceZone && tab.sliceZone?.value.length > 0
-      )
-  );
-
-  const hasPushedAnHourAgo = Boolean(
-    // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
-    lastSyncChange && Date.now() - lastSyncChange >= 3600000
-  );
-
-  const userHasCreatedEnoughContent =
-    sliceCount >= 1 &&
-    customTypes.length >= 1 &&
-    hasSliceWithinCustomType &&
-    hasPushedAnHourAgo;
-
   const onSendAReview = (rating: number, comment: string): void => {
     startLoadingReview();
-    void telemetry.track({ event: "review", rating, comment });
-    sendAReview();
+    void telemetry.track({
+      event: "review",
+      rating,
+      comment,
+      type:
+        reviewType === "advancedRepository"
+          ? "advanced repository"
+          : "onboarding",
+    });
+    sendAReview(reviewType);
     stopLoadingReview();
   };
 
@@ -123,9 +65,9 @@ const ReviewModal: React.FunctionComponent = () => {
 
   return (
     <SliceMachineModal
-      isOpen={userHasCreatedEnoughContent && !hasSendAReview}
+      isOpen
       shouldCloseOnOverlayClick={false}
-      onRequestClose={() => skipReview()}
+      onRequestClose={() => skipReview(reviewType)}
       closeTimeoutMS={500}
       contentLabel={"Review Modal"}
       portalClassName={"ReviewModal"}
@@ -178,9 +120,9 @@ const ReviewModal: React.FunctionComponent = () => {
                 }}
               >
                 <Heading sx={{ fontSize: "20px", mr: 4 }}>
-                  Share Feedback
+                  Share feedback
                 </Heading>
-                <Close type="button" onClick={() => skipReview()} />
+                <Close type="button" onClick={() => skipReview(reviewType)} />
               </Flex>
               <Flex
                 sx={{
@@ -190,8 +132,8 @@ const ReviewModal: React.FunctionComponent = () => {
                 }}
               >
                 <Text variant={"xs"} as={"p"} sx={{ maxWidth: 302, mb: 3 }}>
-                  Overall, how satisfied are you with your Slice Machine
-                  experience?
+                  Overall, how satisfied or dissatisfied are you with your Slice
+                  Machine experience so far?
                 </Text>
                 <Box
                   mb={2}
@@ -208,11 +150,11 @@ const ReviewModal: React.FunctionComponent = () => {
                     Very satisfied
                   </Text>
                 </Box>
-                <Field name={"rating"} component={SelectReviewComponent} />
+                <Field name={"rating"} component={ReviewFormSelect} />
                 <Field
                   name={"comment"}
                   type="text"
-                  placeholder="Share your thoughts. What can we improve?"
+                  placeholder="Tell us more..."
                   as={Textarea}
                   autoComplete="off"
                   sx={{ height: 80, mb: 3 }}
@@ -233,5 +175,3 @@ const ReviewModal: React.FunctionComponent = () => {
     </SliceMachineModal>
   );
 };
-
-export default ReviewModal;
diff --git a/packages/slice-machine/components/ReviewModal/ReviewFormSelect.tsx b/packages/slice-machine/components/ReviewModal/ReviewFormSelect.tsx
new file mode 100644
index 0000000000..b0d5b9acba
--- /dev/null
+++ b/packages/slice-machine/components/ReviewModal/ReviewFormSelect.tsx
@@ -0,0 +1,35 @@
+import { FC } from "react";
+import { FieldProps } from "formik";
+import { Box, Button } from "theme-ui";
+
+const ratingSelectable = [1, 2, 3, 4, 5];
+
+export const ReviewFormSelect: FC<FieldProps> = (props) => {
+  const { field, form } = props;
+
+  return (
+    <Box sx={{ mb: 3, display: "flex", justifyContent: "space-between" }}>
+      {ratingSelectable.map((rating, index) => (
+        <Button
+          variant="secondary"
+          type="button"
+          key={index}
+          onClick={() => void form.setFieldValue("rating", rating)}
+          className={field.value === rating ? "selected" : ""}
+          sx={{
+            "&:not(:last-of-type)": {
+              mr: 1,
+            },
+            "&.selected": {
+              backgroundColor: "code.gray",
+              color: "white",
+            },
+          }}
+          data-cy={`review-form-score-${rating}`}
+        >
+          {rating}
+        </Button>
+      ))}
+    </Box>
+  );
+};
diff --git a/packages/slice-machine/components/ReviewModal/ReviewModal.tsx b/packages/slice-machine/components/ReviewModal/ReviewModal.tsx
new file mode 100644
index 0000000000..d6581ecf55
--- /dev/null
+++ b/packages/slice-machine/components/ReviewModal/ReviewModal.tsx
@@ -0,0 +1,70 @@
+import { FC } from "react";
+import { useSelector } from "react-redux";
+
+import { SliceMachineStoreType } from "@src/redux/type";
+import { getLastSyncChange, getUserReview } from "@src/modules/userContext";
+import { selectAllCustomTypes } from "@src/modules/availableCustomTypes";
+import { getLibraries } from "@src/modules/slices";
+import { hasLocal } from "@lib/models/common/ModelData";
+
+import { ReviewForm } from "./ReviewForm";
+
+export const ReviewModal: FC = () => {
+  const { userReview, customTypes, libraries, lastSyncChange } = useSelector(
+    (store: SliceMachineStoreType) => ({
+      userReview: getUserReview(store),
+      customTypes: selectAllCustomTypes(store),
+      libraries: getLibraries(store),
+      lastSyncChange: getLastSyncChange(store),
+    })
+  );
+
+  const sliceCount =
+    // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
+    libraries && libraries.length
+      ? libraries.reduce((count, lib) => {
+          // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
+          if (!lib) return count;
+          return count + lib.components.length;
+        }, 0)
+      : 0;
+
+  const hasSliceWithinCustomType = customTypes.some(
+    (customType) =>
+      hasLocal(customType) &&
+      customType.local.tabs.some(
+        (tab) => tab.sliceZone && tab.sliceZone?.value.length > 0
+      )
+  );
+
+  const hasPushedAnHourAgo = Boolean(
+    // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
+    lastSyncChange && Date.now() - lastSyncChange >= 3600000
+  );
+
+  const isAdvancedRepository =
+    sliceCount >= 6 &&
+    customTypes.length >= 6 &&
+    hasSliceWithinCustomType &&
+    hasPushedAnHourAgo;
+
+  if (!userReview.advancedRepository && isAdvancedRepository) {
+    return <ReviewForm reviewType="advancedRepository" />;
+  }
+
+  const isOnboardingDone =
+    sliceCount >= 1 &&
+    customTypes.length >= 1 &&
+    hasSliceWithinCustomType &&
+    hasPushedAnHourAgo;
+
+  if (
+    !userReview.onboarding &&
+    !userReview.advancedRepository &&
+    isOnboardingDone
+  ) {
+    return <ReviewForm reviewType="onboarding" />;
+  }
+
+  return null;
+};
diff --git a/packages/slice-machine/components/ReviewModal/index.ts b/packages/slice-machine/components/ReviewModal/index.ts
new file mode 100644
index 0000000000..2349b69178
--- /dev/null
+++ b/packages/slice-machine/components/ReviewModal/index.ts
@@ -0,0 +1 @@
+export { ReviewModal } from "./ReviewModal";
diff --git a/packages/slice-machine/src/modules/useSliceMachineActions.ts b/packages/slice-machine/src/modules/useSliceMachineActions.ts
index c61d5d4982..c271994095 100644
--- a/packages/slice-machine/src/modules/useSliceMachineActions.ts
+++ b/packages/slice-machine/src/modules/useSliceMachineActions.ts
@@ -23,7 +23,7 @@ import {
   renameAvailableCustomType,
 } from "./availableCustomTypes";
 import { createSlice, deleteSliceCreator, renameSliceCreator } from "./slices";
-import { UserContextStoreType } from "./userContext/types";
+import { UserContextStoreType, UserReviewType } from "./userContext/types";
 import { GenericToastTypes, openToasterCreator } from "./toaster";
 import {
   initCustomTypeStoreCreator,
@@ -134,8 +134,18 @@ const useSliceMachineActions = () => {
     dispatch(stopLoadingActionCreator({ loadingKey: LoadingKeysEnum.LOGIN }));
 
   // UserContext module
-  const skipReview = () => dispatch(skipReviewCreator());
-  const sendAReview = () => dispatch(sendAReviewCreator());
+  const skipReview = (reviewType: UserReviewType) =>
+    dispatch(
+      skipReviewCreator({
+        reviewType,
+      })
+    );
+  const sendAReview = (reviewType: UserReviewType) =>
+    dispatch(
+      sendAReviewCreator({
+        reviewType,
+      })
+    );
   const setUpdatesViewed = (versions: UserContextStoreType["updatesViewed"]) =>
     dispatch(updatesViewedCreator(versions));
   const setSeenSimulatorToolTip = () =>
diff --git a/packages/slice-machine/src/modules/userContext/index.ts b/packages/slice-machine/src/modules/userContext/index.ts
index 9d616f53d5..45191cf2ae 100644
--- a/packages/slice-machine/src/modules/userContext/index.ts
+++ b/packages/slice-machine/src/modules/userContext/index.ts
@@ -4,6 +4,8 @@ import { ActionType, createAction, getType } from "typesafe-actions";
 import {
   AuthStatus,
   UserContextStoreType,
+  UserReviewState,
+  UserReviewType,
 } from "@src/modules/userContext/types";
 import { refreshStateCreator } from "../environment";
 import ErrorWithStatus from "@lib/models/common/ErrorWithStatus";
@@ -12,7 +14,10 @@ import { changesPushCreator } from "../pushChangesSaga";
 // NOTE: Be careful every key written in this store is persisted in the localstorage
 
 const initialState: UserContextStoreType = {
-  hasSendAReview: false,
+  userReview: {
+    onboarding: false,
+    advancedRepository: false,
+  },
   updatesViewed: {
     latest: null,
     latestNonBreaking: null,
@@ -25,9 +30,13 @@ const initialState: UserContextStoreType = {
 };
 
 // Actions Creators
-export const sendAReviewCreator = createAction("USER_CONTEXT/SEND_REVIEW")();
+export const sendAReviewCreator = createAction("USER_CONTEXT/SEND_REVIEW")<{
+  reviewType: UserReviewType;
+}>();
 
-export const skipReviewCreator = createAction("USER_CONTEXT/SKIP_REVIEW")();
+export const skipReviewCreator = createAction("USER_CONTEXT/SKIP_REVIEW")<{
+  reviewType: UserReviewType;
+}>();
 
 export const updatesViewedCreator = createAction("USER_CONTEXT/VIEWED_UPDATES")<
   UserContextStoreType["updatesViewed"]
@@ -57,8 +66,11 @@ type userContextActions = ActionType<
 >;
 
 // Selectors
-export const userHasSendAReview = (state: SliceMachineStoreType): boolean =>
-  state.userContext.hasSendAReview;
+export const getUserReview = (state: SliceMachineStoreType): UserReviewState =>
+  state.userContext.userReview ?? {
+    onboarding: state.userContext.hasSendAReview ?? false,
+    advancedRepository: false,
+  };
 
 export const getUpdatesViewed = (
   state: SliceMachineStoreType
@@ -90,7 +102,10 @@ export const userContextReducer: Reducer<
     case getType(skipReviewCreator):
       return {
         ...state,
-        hasSendAReview: true,
+        userReview: {
+          ...state.userReview,
+          [action.payload.reviewType]: true,
+        },
       };
     case getType(updatesViewedCreator): {
       return {
diff --git a/packages/slice-machine/src/modules/userContext/types.ts b/packages/slice-machine/src/modules/userContext/types.ts
index b9ef1109f8..83af8ec241 100644
--- a/packages/slice-machine/src/modules/userContext/types.ts
+++ b/packages/slice-machine/src/modules/userContext/types.ts
@@ -6,7 +6,7 @@ export enum AuthStatus {
 }
 
 export type UserContextStoreType = {
-  hasSendAReview: boolean;
+  userReview: UserReviewState;
   updatesViewed: {
     latest: string | null;
     latestNonBreaking: string | null;
@@ -16,4 +16,17 @@ export type UserContextStoreType = {
   hasSeenChangesToolTip: boolean;
   authStatus: AuthStatus;
   lastSyncChange: number | null;
+} & LegacyUserContextStoreType;
+
+export type UserReviewState = {
+  onboarding: boolean;
+  advancedRepository: boolean;
+};
+
+export type UserReviewType = keyof UserReviewState;
+
+// Allow to handle old property that users can have
+// in their local storage
+type LegacyUserContextStoreType = {
+  hasSendAReview?: boolean;
 };
diff --git a/packages/slice-machine/test/src/modules/userContext.test.ts b/packages/slice-machine/test/src/modules/userContext.test.ts
index d69c77d088..d1b7fe41f3 100644
--- a/packages/slice-machine/test/src/modules/userContext.test.ts
+++ b/packages/slice-machine/test/src/modules/userContext.test.ts
@@ -20,41 +20,57 @@ describe("[UserContext module]", () => {
       expect(userContextReducer({}, { type: "NO.MATCH" })).toEqual({});
     });
 
-    it("should update hasSendAReview to true when given USER_CONTEXT/SEND_REVIEW action", () => {
+    it("should update user review onboarding to true when given USER_CONTEXT/SEND_REVIEW action", () => {
       // @ts-expect-error TS(2739) FIXME: Type '{ hasSendAReview: false;... Remove this comment to see the full error message
       const initialState: UserContextStoreType = {
-        hasSendAReview: false,
+        userReview: {
+          onboarding: false,
+          advancedRepository: false,
+        },
         updatesViewed: {
           latest: null,
           latestNonBreaking: null,
         },
       };
 
-      const action = sendAReviewCreator();
+      const action = sendAReviewCreator({
+        reviewType: "onboarding",
+      });
 
-      const expectedState = {
+      const expectedState: UserContextStoreType = {
         ...initialState,
-        hasSendAReview: true,
+        userReview: {
+          onboarding: true,
+          advancedRepository: false,
+        },
       };
 
       expect(userContextReducer(initialState, action)).toEqual(expectedState);
     });
 
-    it("should update hasSendAReview to true when given USER_CONTEXT/SKIP_REVIEW action", () => {
+    it("should update user review onboarding to true when given USER_CONTEXT/SKIP_REVIEW action", () => {
       // @ts-expect-error TS(2739) FIXME: Type '{ hasSendAReview: false;... Remove this comment to see the full error message
       const initialState: UserContextStoreType = {
-        hasSendAReview: false,
+        userReview: {
+          onboarding: false,
+          advancedRepository: false,
+        },
         updatesViewed: {
           latest: null,
           latestNonBreaking: null,
         },
       };
 
-      const action = skipReviewCreator();
+      const action = skipReviewCreator({
+        reviewType: "onboarding",
+      });
 
-      const expectedState = {
+      const expectedState: UserContextStoreType = {
         ...initialState,
-        hasSendAReview: true,
+        userReview: {
+          onboarding: true,
+          advancedRepository: false,
+        },
       };
 
       expect(userContextReducer(initialState, action)).toEqual(expectedState);