Skip to content

Commit

Permalink
Store onboarding state in user metadata (#5280)
Browse files Browse the repository at this point in the history
* feature: Dashboard UI onboarding component

* i18n

* feature: Dashboard UI onboarding component

* i18n

* Base logic for store onboarding state in metadata

* Fix saving metadata

* Wait for user load

* Add debouncing

* Add test to store

* Move ff condition inside onbarding context

* Improve loading state from API

* Update tests

* Add changeset

* CR fixes

* Update mutation to eliminate problem with permissions

---------

Co-authored-by: Wojciech <wojciech.mista@hotmail.com>
  • Loading branch information
poulch and Cloud11PL committed Nov 29, 2024
1 parent 18a4eae commit 3f2ca21
Show file tree
Hide file tree
Showing 16 changed files with 298 additions and 76 deletions.
5 changes: 5 additions & 0 deletions .changeset/dirty-bugs-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"saleor-dashboard": patch
---

Onboarding state is now stored in user metadata, that means that onboarding state is persisted between logins on different machines
4 changes: 1 addition & 3 deletions src/components/DevModePanel/DevModePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// @ts-strict-ignore
import { useDashboardTheme } from "@dashboard/components/GraphiQL/styles";
import { DashboardModal } from "@dashboard/components/Modal";
import { useFlag } from "@dashboard/featureFlags";
import { useOnboarding } from "@dashboard/newHome/homeOnboarding/onboardingContext";
import { createGraphiQLFetcher, FetcherOpts, FetcherParams } from "@graphiql/toolkit";
import { createFetch } from "@saleor/sdk";
Expand All @@ -18,14 +17,13 @@ export const DevModePanel: React.FC = () => {
const intl = useIntl();
const { rootStyle } = useDashboardTheme();
const { markOnboardingStepAsCompleted } = useOnboarding();
const newHomePageFlag = useFlag("new_home_page");
const { isDevModeVisible, variables, devModeContent, setDevModeVisibility } = useDevModeContext();
const baseFetcher = createGraphiQLFetcher({
url: process.env.API_URL,
fetch: authorizedFetch,
});
const fetcher = async (graphQLParams: FetcherParams, opts: FetcherOpts) => {
if (graphQLParams.operationName !== "IntrospectionQuery" && newHomePageFlag.enabled) {
if (graphQLParams.operationName !== "IntrospectionQuery") {
markOnboardingStepAsCompleted("graphql-playground");
}

Expand Down
6 changes: 1 addition & 5 deletions src/custom-apps/views/CustomAppList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useApolloClient } from "@apollo/client";
import AppDeleteDialog from "@dashboard/apps/components/AppDeleteDialog";
import { EXTENSION_LIST_QUERY } from "@dashboard/apps/queries";
import { WindowTitle } from "@dashboard/components/WindowTitle";
import { useFlag } from "@dashboard/featureFlags";
import {
AppSortField,
AppTypeEnum,
Expand Down Expand Up @@ -35,12 +34,9 @@ export const CustomAppList: React.FC<CustomAppListProps> = ({ params }) => {
const intl = useIntl();
const client = useApolloClient();
const { markOnboardingStepAsCompleted } = useOnboarding();
const newHomePageFlag = useFlag("new_home_page");

useEffect(() => {
if (newHomePageFlag) {
markOnboardingStepAsCompleted("view-webhooks");
}
markOnboardingStepAsCompleted("view-webhooks");
}, []);

const [openModal, closeModal] = createDialogActionHandlers<
Expand Down
36 changes: 36 additions & 0 deletions src/graphql/hooks.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10148,6 +10148,42 @@ export function useMenuDetailsLazyQuery(baseOptions?: ApolloReactHooks.LazyQuery
export type MenuDetailsQueryHookResult = ReturnType<typeof useMenuDetailsQuery>;
export type MenuDetailsLazyQueryHookResult = ReturnType<typeof useMenuDetailsLazyQuery>;
export type MenuDetailsQueryResult = Apollo.QueryResult<Types.MenuDetailsQuery, Types.MenuDetailsQueryVariables>;
export const UpdateUserMetadataDocument = gql`
mutation UpdateUserMetadata($id: ID!, $input: [MetadataInput!]!) {
updateMetadata(id: $id, input: $input) {
errors {
...MetadataError
}
}
}
${MetadataErrorFragmentDoc}`;
export type UpdateUserMetadataMutationFn = Apollo.MutationFunction<Types.UpdateUserMetadataMutation, Types.UpdateUserMetadataMutationVariables>;

/**
* __useUpdateUserMetadataMutation__
*
* To run a mutation, you first call `useUpdateUserMetadataMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateUserMetadataMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateUserMetadataMutation, { data, loading, error }] = useUpdateUserMetadataMutation({
* variables: {
* id: // value for 'id'
* input: // value for 'input'
* },
* });
*/
export function useUpdateUserMetadataMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<Types.UpdateUserMetadataMutation, Types.UpdateUserMetadataMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useMutation<Types.UpdateUserMetadataMutation, Types.UpdateUserMetadataMutationVariables>(UpdateUserMetadataDocument, options);
}
export type UpdateUserMetadataMutationHookResult = ReturnType<typeof useUpdateUserMetadataMutation>;
export type UpdateUserMetadataMutationResult = Apollo.MutationResult<Types.UpdateUserMetadataMutation>;
export type UpdateUserMetadataMutationOptions = Apollo.BaseMutationOptions<Types.UpdateUserMetadataMutation, Types.UpdateUserMetadataMutationVariables>;
export const WelcomePageActivitiesDocument = gql`
query WelcomePageActivities($hasPermissionToManageOrders: Boolean!) {
activities: homepageEvents(last: 10) @include(if: $hasPermissionToManageOrders) {
Expand Down
8 changes: 8 additions & 0 deletions src/graphql/types.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10745,6 +10745,14 @@ export type MenuDetailsQueryVariables = Exact<{

export type MenuDetailsQuery = { __typename: 'Query', menu: { __typename: 'Menu', id: string, name: string, items: Array<{ __typename: 'MenuItem', id: string, level: number, name: string, url: string | null, children: Array<{ __typename: 'MenuItem', id: string, level: number, name: string, url: string | null, children: Array<{ __typename: 'MenuItem', id: string, level: number, name: string, url: string | null, children: Array<{ __typename: 'MenuItem', id: string, level: number, name: string, url: string | null, children: Array<{ __typename: 'MenuItem', id: string, level: number, name: string, url: string | null, children: Array<{ __typename: 'MenuItem', id: string, level: number, name: string, url: string | null, children: Array<{ __typename: 'MenuItem', id: string, level: number, name: string, url: string | null, category: { __typename: 'Category', id: string, name: string } | null, collection: { __typename: 'Collection', id: string, name: string } | null, page: { __typename: 'Page', id: string, title: string } | null }> | null, category: { __typename: 'Category', id: string, name: string } | null, collection: { __typename: 'Collection', id: string, name: string } | null, page: { __typename: 'Page', id: string, title: string } | null }> | null, category: { __typename: 'Category', id: string, name: string } | null, collection: { __typename: 'Collection', id: string, name: string } | null, page: { __typename: 'Page', id: string, title: string } | null }> | null, category: { __typename: 'Category', id: string, name: string } | null, collection: { __typename: 'Collection', id: string, name: string } | null, page: { __typename: 'Page', id: string, title: string } | null }> | null, category: { __typename: 'Category', id: string, name: string } | null, collection: { __typename: 'Collection', id: string, name: string } | null, page: { __typename: 'Page', id: string, title: string } | null }> | null, category: { __typename: 'Category', id: string, name: string } | null, collection: { __typename: 'Collection', id: string, name: string } | null, page: { __typename: 'Page', id: string, title: string } | null }> | null, category: { __typename: 'Category', id: string, name: string } | null, collection: { __typename: 'Collection', id: string, name: string } | null, page: { __typename: 'Page', id: string, title: string } | null }> | null } | null };

export type UpdateUserMetadataMutationVariables = Exact<{
id: Scalars['ID'];
input: Array<MetadataInput> | MetadataInput;
}>;


export type UpdateUserMetadataMutation = { __typename: 'Mutation', updateMetadata: { __typename: 'UpdateMetadata', errors: Array<{ __typename: 'MetadataError', code: MetadataErrorCode, field: string | null, message: string | null }> } | null };

export type WelcomePageActivitiesQueryVariables = Exact<{
hasPermissionToManageOrders: Scalars['Boolean'];
}>;
Expand Down
5 changes: 1 addition & 4 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ import NavigationSection from "./navigation";
import { navigationSection } from "./navigation/urls";
import { HomePage } from "./newHome";
import { OnboardingProvider } from "./newHome/homeOnboarding/onboardingContext/OnboardingContext";
import { OnboardingStorage } from "./newHome/homeOnboarding/onboardingContext/OnboardingStorage";
import { NotFound } from "./NotFound";
import OrdersSection from "./orders";
import PageSection from "./pages";
Expand All @@ -89,8 +88,6 @@ if (GTM_ID) {

errorTracker.init(history);

const onboardingStorage = new OnboardingStorage();

/*
Handle legacy theming toggle. Since we use new and old macaw,
we need to handle both theme swticher for a while.
Expand Down Expand Up @@ -129,7 +126,7 @@ const App: React.FC = () => (
<ProductAnalytics>
<SavebarRefProvider>
<FeatureFlagsProviderWithUser>
<OnboardingProvider storageService={onboardingStorage}>
<OnboardingProvider>
<Routes />
</OnboardingProvider>
</FeatureFlagsProviderWithUser>
Expand Down
4 changes: 1 addition & 3 deletions src/newHome/homeOnboarding/hooks/useOnboardingData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,7 @@ export const useOnboardingData = () => {
const steps = getStepsData({
intl,
isStepCompleted: (step: OnboardingStepsIDs) => onboardingState.stepsCompleted.includes(step),
onStepComplete: (step: OnboardingStepsIDs) => {
markOnboardingStepAsCompleted(step);
},
onStepComplete: markOnboardingStepAsCompleted,
});

return {
Expand Down
29 changes: 18 additions & 11 deletions src/newHome/homeOnboarding/onboardingContext/OnboardingContext.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useFlag } from "@dashboard/featureFlags";
import {
handleStateChangeAfterStepCompleted,
handleStateChangeAfterToggle,
} from "@dashboard/newHome/homeOnboarding/onboardingContext/utils";
import React from "react";
import React, { useRef } from "react";

import { useNewUserCheck } from "../hooks/useNewUserCheck";
import {
Expand All @@ -17,47 +18,53 @@ import {
OnboardingStepsIDs,
} from "./types";
import { useExpandedOnboardingId } from "./useExpandedOnboardingId";
import { useOnboardingStorage } from "./useOnboardingStorage";

const OnboardingContext = React.createContext<OnboardingContextType | null>(null);

export const OnboardingProvider = ({ children, storageService }: OnboardingProviderProps) => {
export const OnboardingProvider = ({ children }: OnboardingProviderProps) => {
const [onboardingState, setOnboardingState] = React.useState<OnboardingState>({
onboardingExpanded: true,
stepsCompleted: [],
stepsExpanded: {} as OnboardingState["stepsExpanded"],
});
const [loaded, setLoaded] = React.useState(false);
const loaded = useRef(false);
const { isNewUser, isUserLoading } = useNewUserCheck();
const newHomePageFlag = useFlag("new_home_page");

const storageService = useOnboardingStorage();

React.useEffect(() => {
if (loaded || isUserLoading) return;
if (loaded.current || isUserLoading) return;

const onboardingStateLS = storageService.getOnboardingState();
const onboardingStateFromUserMetadata = storageService.getOnboardingState();

// When first time load there is not data in local storage, so use initial state
if (!onboardingStateLS) {
if (!onboardingStateFromUserMetadata) {
setOnboardingState(getInitialOnboardingState(isNewUser));
} else {
setOnboardingState(onboardingStateLS);
setOnboardingState(onboardingStateFromUserMetadata);
}

setLoaded(true);
loaded.current = true;
}, [isNewUser, isUserLoading, loaded, storageService]);

React.useEffect(() => {
if (loaded) {
if (loaded.current) {
storageService.saveOnboardingState(onboardingState);
}
}, [loaded, onboardingState, storageService]);
}, [onboardingState]);

// For old users, onboarding is always completed, for new one we need to calculate it
const isOnboardingCompleted = isNewUser
? onboardingState.stepsCompleted.length === TOTAL_STEPS_COUNT
: true;

const extendedStepId = useExpandedOnboardingId(onboardingState, loaded);
const extendedStepId = useExpandedOnboardingId(onboardingState, loaded.current);

const markOnboardingStepAsCompleted = (id: OnboardingStepsIDs) => {
if (!newHomePageFlag.enabled || onboardingState.stepsCompleted.includes(id)) return;

setOnboardingState(prevOnboardingState =>
handleStateChangeAfterStepCompleted(prevOnboardingState, id),
);
Expand Down
33 changes: 0 additions & 33 deletions src/newHome/homeOnboarding/onboardingContext/OnboardingStorage.ts

This file was deleted.

3 changes: 1 addition & 2 deletions src/newHome/homeOnboarding/onboardingContext/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type OnboardingState = {

export interface StorageService {
getOnboardingState(): OnboardingState | undefined;
saveOnboardingState(onboardingState: OnboardingState): void;
saveOnboardingState(onboardingState: OnboardingState): Promise<void>;
}

export interface OnboardingContextType {
Expand All @@ -38,5 +38,4 @@ export interface OnboardingContextType {

export interface OnboardingProviderProps {
children: React.ReactNode;
storageService: StorageService;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useUser } from "@dashboard/auth";
import { useUpdateUserMetadataMutation } from "@dashboard/graphql";
import { OnboardingStepsIDs } from "@dashboard/newHome/homeOnboarding/onboardingContext/types";
import { act } from "@testing-library/react";
import { renderHook } from "@testing-library/react-hooks";

import { useOnboardingStorage } from "./useOnboardingStorage";

jest.mock("@dashboard/auth", () => ({
__esModule: true,
useUser: jest.fn(),
}));

jest.mock("@dashboard/graphql");

jest.useFakeTimers();

jest.mock("lodash/debounce", () => jest.fn(fn => fn));

describe("useOnboardingStorage", () => {
describe("getOnboardingState", () => {
it("should return undefined when there is no onboarding in user metadata", () => {
// Arrange
(useUser as jest.Mock).mockImplementation(() => ({
user: { metadata: [{ key1: "value1" }, { key2: "value2" }] },
}));
(useUpdateUserMetadataMutation as jest.Mock).mockReturnValue([jest.fn(), {}]);

const { getOnboardingState } = renderHook(() => useOnboardingStorage()).result.current;

// Act
const result = getOnboardingState();

// Assert
expect(result).toBeUndefined();
});

it("should return onboarding state from user metadata", () => {
// Arrange
(useUser as jest.Mock).mockImplementation(() => ({
user: {
metadata: [
{
key: "onboarding",
value: JSON.stringify({ steps: [], onboardingExpanded: true }),
},
],
},
}));
(useUpdateUserMetadataMutation as jest.Mock).mockReturnValue([jest.fn(), {}]);

const { getOnboardingState } = renderHook(() => useOnboardingStorage()).result.current;

// Act
const result = getOnboardingState();

// Assert
expect(result).toEqual({ steps: [], onboardingExpanded: true });
});
});

describe("saveOnboardingState", () => {
it("should not save onboarding state when there is no user", async () => {
// Arrange
(useUser as jest.Mock).mockImplementation(() => ({ user: null }));

const updateMetadataMock = jest.fn();

(useUpdateUserMetadataMutation as jest.Mock).mockReturnValue([updateMetadataMock, {}]);

const { result } = renderHook(() => useOnboardingStorage());

// Act
const returnValue = await act(async () => {
return await result.current.saveOnboardingState({
stepsCompleted: [],
stepsExpanded: {} as Record<OnboardingStepsIDs, boolean>,
onboardingExpanded: true,
});
});

// Assert
expect(returnValue).toBeUndefined();
expect(updateMetadataMock).not.toHaveBeenCalled();
});

it("should save onboarding state to user metadata and be called only once", async () => {
// Arrange
(useUser as jest.Mock).mockImplementation(() => ({ user: { id: "1", metadata: [] } }));

const updateMetadataMock = jest.fn();

(useUpdateUserMetadataMutation as jest.Mock).mockReturnValue([updateMetadataMock, {}]);

const { result } = renderHook(() => useOnboardingStorage());

// Act
await act(async () => {
await result.current.saveOnboardingState({
stepsCompleted: [],
stepsExpanded: {} as Record<OnboardingStepsIDs, boolean>,
onboardingExpanded: true,
});
});

jest.runAllTimers();

// Assert
expect(updateMetadataMock).toHaveBeenCalledTimes(1);
expect(updateMetadataMock).toHaveBeenCalledWith({
variables: {
id: "1",
input: [
{
key: "onboarding",
value: JSON.stringify({
stepsCompleted: [],
stepsExpanded: {} as Record<OnboardingStepsIDs, boolean>,
onboardingExpanded: true,
}),
},
],
},
});
});
});
});
Loading

0 comments on commit 3f2ca21

Please sign in to comment.