From 99454d4217059670118f7b212247ecf60d08151c Mon Sep 17 00:00:00 2001 From: Xavier Rutayisire Date: Wed, 11 Oct 2023 18:46:46 +0200 Subject: [PATCH] feat(review): Satisfaction modal for advanced repository --- packages/manager/src/index.ts | 1 + .../PrismicRepositoryManager.ts | 67 ++++++++++++++++++- .../src/managers/prismicRepository/types.ts | 2 + .../manager/src/managers/telemetry/types.ts | 6 +- .../components/ReviewModal/index.tsx | 56 +++++++++++----- .../src/hooks/useDocumentsCount.ts | 22 ++++++ .../src/modules/useSliceMachineActions.ts | 16 ++++- .../src/modules/userContext/index.ts | 26 +++++-- .../src/modules/userContext/types.ts | 15 ++++- .../test/src/modules/userContext.test.ts | 36 +++++++--- 10 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 packages/slice-machine/src/hooks/useDocumentsCount.ts diff --git a/packages/manager/src/index.ts b/packages/manager/src/index.ts index fbaa6e37cb..bfee321480 100644 --- a/packages/manager/src/index.ts +++ b/packages/manager/src/index.ts @@ -6,6 +6,7 @@ export type { PrismicRepository, FrameworkWroomTelemetryID, StarterId, + DocumentStatus, } from "./managers/prismicRepository/types"; export type { SliceMachineManager } from "./managers/SliceMachineManager"; diff --git a/packages/manager/src/managers/prismicRepository/PrismicRepositoryManager.ts b/packages/manager/src/managers/prismicRepository/PrismicRepositoryManager.ts index a5ab911759..84d8d2f35f 100644 --- a/packages/manager/src/managers/prismicRepository/PrismicRepositoryManager.ts +++ b/packages/manager/src/managers/prismicRepository/PrismicRepositoryManager.ts @@ -26,9 +26,10 @@ import { TransactionalMergeReturnType, FrameworkWroomTelemetryID, StarterId, + DocumentStatus, } from "./types"; import { assertPluginsInitialized } from "../../lib/assertPluginsInitialized"; -import { UnauthenticatedError } from "../../errors"; +import { UnauthenticatedError, UnexpectedDataError } from "../../errors"; const DEFAULT_REPOSITORY_SETTINGS = { plan: "personal", @@ -36,6 +37,14 @@ const DEFAULT_REPOSITORY_SETTINGS = { role: "developer", }; +const PrismicDocumentsCount = t.exact( + t.type({ + count: t.number, + }), +); + +type PrismicDocumentsCount = t.TypeOf; + type PrismicRepositoryManagerCheckExistsArgs = { domain: string; }; @@ -57,6 +66,14 @@ type PrismicRepositoryManagerPushDocumentsArgs = { documents: Record; // TODO: Type unknown if possible(?) }; +type PrismicRepositoryManagerGetDocumentsCountArgs = { + statuses: DocumentStatus[]; +}; + +type PrismicRepositoryManagerGetDocumentsCountReturnType = { + count: number; +}; + export class PrismicRepositoryManager extends BaseManager { // TODO: Add methods for repository-specific actions. E.g. creating a // new repository. @@ -258,6 +275,54 @@ export class PrismicRepositoryManager extends BaseManager { } } + async getDocumentsCount( + args: PrismicRepositoryManagerGetDocumentsCountArgs, + ): Promise { + const { statuses } = args; + const statusParams = statuses.map((status) => `status=${status}`).join("&"); + const url = new URL( + `./core/documents/count?${statusParams}`, + API_ENDPOINTS.PrismicWroom, + ); + + const repositoryName = await this.project.getRepositoryName(); + // Update hostname to include repository domain + url.hostname = `${repositoryName}.${url.hostname}`; + + const res = await this._fetch({ + url, + method: "GET", + userAgent: PrismicRepositoryUserAgent.LegacyZero, // Custom User Agent is required, + }); + + if (res.ok) { + const json = await res.json(); + const { value, error } = decode(PrismicDocumentsCount, json); + + if (error || !value) { + throw new UnexpectedDataError( + `Received invalid data while getting documents count for repository "${repositoryName}."`, + ); + } + + return value; + } + + let reason: string | null = null; + try { + reason = await res.text(); + } catch { + // Noop + } + + throw new Error( + `Failed to fetch documents count for repository "${repositoryName}.", ${res.status} ${res.statusText}`, + { + cause: reason, + }, + ); + } + async pushChanges( args: TransactionalMergeArgs, ): Promise { diff --git a/packages/manager/src/managers/prismicRepository/types.ts b/packages/manager/src/managers/prismicRepository/types.ts index bc36becddf..6ef1d109e9 100644 --- a/packages/manager/src/managers/prismicRepository/types.ts +++ b/packages/manager/src/managers/prismicRepository/types.ts @@ -155,3 +155,5 @@ export type StarterId = | "nuxt_multi_page" | "nuxt_blog" | "nuxt_multi_lang"; + +export type DocumentStatus = "published" | "draft"; 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/ReviewModal/index.tsx b/packages/slice-machine/components/ReviewModal/index.tsx index 4a37d77118..23d09cbc5e 100644 --- a/packages/slice-machine/components/ReviewModal/index.tsx +++ b/packages/slice-machine/components/ReviewModal/index.tsx @@ -16,16 +16,15 @@ 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 { getLastSyncChange, getUserReview } 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 { useDocumentsCount } from "@src/hooks/useDocumentsCount"; +import { UserReview } from "@src/modules/userContext/types"; Modal.setAppElement("#__next"); @@ -63,18 +62,19 @@ const ReviewModal: React.FunctionComponent = () => { const { isReviewLoading, isLoginModalOpen, - hasSendAReview, + userReview, customTypes, libraries, lastSyncChange, } = useSelector((store: SliceMachineStoreType) => ({ isReviewLoading: isLoading(store, LoadingKeysEnum.REVIEW), isLoginModalOpen: isModalOpen(store, ModalKeysEnum.LOGIN), - hasSendAReview: userHasSendAReview(store), + userReview: getUserReview(store), customTypes: selectAllCustomTypes(store), libraries: getLibraries(store), lastSyncChange: getLastSyncChange(store), })); + const documentsCount = useDocumentsCount(["published"]); const { skipReview, sendAReview, startLoadingReview, stopLoadingReview } = useSliceMachineActions(); @@ -102,16 +102,38 @@ const ReviewModal: React.FunctionComponent = () => { lastSyncChange && Date.now() - lastSyncChange >= 3600000 ); - const userHasCreatedEnoughContent = + const isOnboardingDone = sliceCount >= 1 && customTypes.length >= 1 && hasSliceWithinCustomType && hasPushedAnHourAgo; + const isAdvancedRepository = + documentsCount !== undefined && documentsCount >= 20; + + const shouldDisplayOnboardingReview = + isOnboardingDone && !userReview.onboarding; + + const shouldDisplayAdvancedRepositoryReview = + userReview.onboarding && + isAdvancedRepository && + !userReview.advancedRepository; + + const reviewType: keyof UserReview = shouldDisplayAdvancedRepositoryReview + ? "advancedRepository" + : "onboarding"; 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 +145,11 @@ const ReviewModal: React.FunctionComponent = () => { return ( skipReview()} + onRequestClose={() => skipReview(reviewType)} closeTimeoutMS={500} contentLabel={"Review Modal"} portalClassName={"ReviewModal"} @@ -178,9 +202,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? { { dispatch(stopLoadingActionCreator({ loadingKey: LoadingKeysEnum.LOGIN })); // UserContext module - const skipReview = () => dispatch(skipReviewCreator()); - const sendAReview = () => dispatch(sendAReviewCreator()); + const skipReview = (reviewType: keyof UserReview) => + dispatch( + skipReviewCreator({ + reviewType, + }) + ); + const sendAReview = (reviewType: keyof UserReview) => + 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..5533aa64fe 100644 --- a/packages/slice-machine/src/modules/userContext/index.ts +++ b/packages/slice-machine/src/modules/userContext/index.ts @@ -4,6 +4,7 @@ import { ActionType, createAction, getType } from "typesafe-actions"; import { AuthStatus, UserContextStoreType, + UserReview, } from "@src/modules/userContext/types"; import { refreshStateCreator } from "../environment"; import ErrorWithStatus from "@lib/models/common/ErrorWithStatus"; @@ -12,7 +13,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 +29,13 @@ const initialState: UserContextStoreType = { }; // Actions Creators -export const sendAReviewCreator = createAction("USER_CONTEXT/SEND_REVIEW")(); +export const sendAReviewCreator = createAction("USER_CONTEXT/SEND_REVIEW")<{ + reviewType: keyof UserReview; +}>(); -export const skipReviewCreator = createAction("USER_CONTEXT/SKIP_REVIEW")(); +export const skipReviewCreator = createAction("USER_CONTEXT/SKIP_REVIEW")<{ + reviewType: keyof UserReview; +}>(); export const updatesViewedCreator = createAction("USER_CONTEXT/VIEWED_UPDATES")< UserContextStoreType["updatesViewed"] @@ -57,8 +65,11 @@ type userContextActions = ActionType< >; // Selectors -export const userHasSendAReview = (state: SliceMachineStoreType): boolean => - state.userContext.hasSendAReview; +export const getUserReview = (state: SliceMachineStoreType): UserReview => + state.userContext.userReview ?? { + onboarding: state.userContext.hasSendAReview ?? false, + advancedRepository: false, + }; export const getUpdatesViewed = ( state: SliceMachineStoreType @@ -90,7 +101,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..832c816a78 100644 --- a/packages/slice-machine/src/modules/userContext/types.ts +++ b/packages/slice-machine/src/modules/userContext/types.ts @@ -5,8 +5,19 @@ export enum AuthStatus { UNKNOWN = "unknown", } +export type UserReview = { + onboarding: boolean; + advancedRepository: boolean; +}; + +// Allow to handle old property that users can have +// in their local storage +type LegacyUserContextStoreType = { + hasSendAReview?: boolean; +}; + export type UserContextStoreType = { - hasSendAReview: boolean; + userReview: UserReview; updatesViewed: { latest: string | null; latestNonBreaking: string | null; @@ -16,4 +27,4 @@ export type UserContextStoreType = { hasSeenChangesToolTip: boolean; authStatus: AuthStatus; lastSyncChange: number | null; -}; +} & LegacyUserContextStoreType; diff --git a/packages/slice-machine/test/src/modules/userContext.test.ts b/packages/slice-machine/test/src/modules/userContext.test.ts index d69c77d088..c63b39e3f4 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 onboaring 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);