From 46e0c16ae081e4c8c7e9b82290c7a3215fcc2a5c Mon Sep 17 00:00:00 2001 From: Xavier Rutayisire Date: Mon, 16 Oct 2023 12:21:20 +0200 Subject: [PATCH] feat(review): Satisfaction modal for advanced repository (#1167) --- .../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 ( - - {ratingSelectable.map((rating, index) => ( - - ))} - - ); +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 = (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 ( skipReview()} + onRequestClose={() => skipReview(reviewType)} closeTimeoutMS={500} contentLabel={"Review Modal"} portalClassName={"ReviewModal"} @@ -178,9 +120,9 @@ const ReviewModal: React.FunctionComponent = () => { }} > - Share Feedback + Share feedback - skipReview()} /> + skipReview(reviewType)} /> { }} > - Overall, how satisfied are you with your Slice Machine - experience? + Overall, how satisfied or dissatisfied are you with your Slice + Machine experience so far? { Very satisfied - + { ); }; - -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 = (props) => { + const { field, form } = props; + + return ( + + {ratingSelectable.map((rating, index) => ( + + ))} + + ); +}; 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 ; + } + + const isOnboardingDone = + sliceCount >= 1 && + customTypes.length >= 1 && + hasSliceWithinCustomType && + hasPushedAnHourAgo; + + if ( + !userReview.onboarding && + !userReview.advancedRepository && + isOnboardingDone + ) { + return ; + } + + 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);