From e8e623a2bff20e3e1a7a3a62c19ddd74799e9f26 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 23 Feb 2023 16:49:45 +0800 Subject: [PATCH] feat(console): submit cloud questionnaire (#3172) --- .../cloud/hooks/use-user-onboarding-data.ts | 35 ++++++++ .../console/src/cloud/pages/About/index.tsx | 39 +++++++-- .../console/src/cloud/pages/Cloud/index.tsx | 48 ++++++++--- .../console/src/cloud/pages/Welcome/index.tsx | 18 +++- packages/console/src/cloud/types.ts | 26 ++++-- .../console/src/hooks/use-me-custom-data.ts | 59 +++++++++++++ .../console/src/hooks/use-user-preferences.ts | 83 ++++++------------- 7 files changed, 219 insertions(+), 89 deletions(-) create mode 100644 packages/console/src/cloud/hooks/use-user-onboarding-data.ts create mode 100644 packages/console/src/hooks/use-me-custom-data.ts diff --git a/packages/console/src/cloud/hooks/use-user-onboarding-data.ts b/packages/console/src/cloud/hooks/use-user-onboarding-data.ts new file mode 100644 index 000000000..ffab94a14 --- /dev/null +++ b/packages/console/src/cloud/hooks/use-user-onboarding-data.ts @@ -0,0 +1,35 @@ +import { useCallback, useMemo } from 'react'; +import { z } from 'zod'; + +import useMeCustomData from '@/hooks/use-me-custom-data'; + +import type { UserOnboardingData } from '../types'; +import { userOnboardingDataGuard } from '../types'; + +const userOnboardingDataKey = 'onboarding'; + +const useUserOnboardingData = () => { + const { data, error, isLoading, isLoaded, update: updateMeCustomData } = useMeCustomData(); + + const userOnboardingData = useMemo(() => { + const parsed = z.object({ [userOnboardingDataKey]: userOnboardingDataGuard }).safeParse(data); + + return parsed.success ? parsed.data[userOnboardingDataKey] : {}; + }, [data]); + + const update = useCallback( + async (data: Partial) => { + await updateMeCustomData({ + [userOnboardingDataKey]: { + ...userOnboardingData, + ...data, + }, + }); + }, + [updateMeCustomData, userOnboardingData] + ); + + return { data: userOnboardingData, error, isLoading, isLoaded, update }; +}; + +export default useUserOnboardingData; diff --git a/packages/console/src/cloud/pages/About/index.tsx b/packages/console/src/cloud/pages/About/index.tsx index a4786e55f..322f27515 100644 --- a/packages/console/src/cloud/pages/About/index.tsx +++ b/packages/console/src/cloud/pages/About/index.tsx @@ -1,8 +1,11 @@ +import { conditional } from '@silverhand/essentials'; +import { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Case from '@/assets/images/case.svg'; +import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data'; import * as pageLayout from '@/cloud/scss/layout.module.scss'; import Button from '@/components/Button'; import FormField from '@/components/FormField'; @@ -20,12 +23,22 @@ import { titleOptions, companySizeOptions, reasonOptions } from './options'; const About = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const navigate = useNavigate(); - const { control, register, handleSubmit } = useForm({ + + const { + data: { questionnaire }, + update, + } = useUserOnboardingData(); + + const { control, register, handleSubmit, reset } = useForm({ mode: 'onChange', }); + useEffect(() => { + reset(questionnaire); + }, [questionnaire, reset]); + const onSubmit = handleSubmit(async (formData) => { - console.log(formData); + await update({ questionnaire: formData }); }); const onNext = async () => { @@ -49,14 +62,15 @@ const About = () => { ( { + onChange(value.length === 0 ? undefined : value); + }} /> )} /> @@ -77,10 +91,12 @@ const About = () => { render={({ field: { onChange, value, name } }) => ( { + onChange(conditional(value && value)); + }} /> )} /> @@ -92,9 +108,14 @@ const About = () => { ( - + { + onChange(value.length === 0 ? undefined : value); + }} + /> )} /> diff --git a/packages/console/src/cloud/pages/Cloud/index.tsx b/packages/console/src/cloud/pages/Cloud/index.tsx index 93d7c3203..9fce9b1a0 100644 --- a/packages/console/src/cloud/pages/Cloud/index.tsx +++ b/packages/console/src/cloud/pages/Cloud/index.tsx @@ -1,6 +1,9 @@ +import { conditional } from '@silverhand/essentials'; import { Navigate, Route, Routes } from 'react-router-dom'; +import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data'; import { CloudPage } from '@/cloud/types'; +import { getCloudPagePathname } from '@/cloud/utils'; import NotFound from '@/pages/NotFound'; import About from '../About'; @@ -8,16 +11,39 @@ import Congrats from '../Congrats'; import Welcome from '../Welcome'; import * as styles from './index.module.scss'; -const Cloud = () => ( -
- - } /> - } /> - } /> - } /> - } /> - -
-); +const welcomePathname = getCloudPagePathname(CloudPage.Welcome); + +const Cloud = () => { + const { + data: { questionnaire }, + isLoaded, + } = useUserOnboardingData(); + + if (!isLoaded) { + return null; + } + + return ( +
+ + } /> + } /> + ) ?? + } + /> + ) ?? + } + /> + } /> + +
+ ); +}; export default Cloud; diff --git a/packages/console/src/cloud/pages/Welcome/index.tsx b/packages/console/src/cloud/pages/Welcome/index.tsx index 667b14b8a..7f1a61e0f 100644 --- a/packages/console/src/cloud/pages/Welcome/index.tsx +++ b/packages/console/src/cloud/pages/Welcome/index.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames'; +import { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -6,6 +7,7 @@ import { useNavigate } from 'react-router-dom'; import Congrats from '@/assets/images/congrats.svg'; import ActionBar from '@/cloud/components/ActionBar'; import { CardSelector } from '@/cloud/components/CardSelector'; +import useUserOnboardingData from '@/cloud/hooks/use-user-onboarding-data'; import * as pageLayout from '@/cloud/scss/layout.module.scss'; import Button from '@/components/Button'; import FormField from '@/components/FormField'; @@ -20,15 +22,25 @@ import { deploymentTypeOptions, projectOptions } from './options'; const Welcome = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const navigate = useNavigate(); + + const { + data: { questionnaire }, + update, + } = useUserOnboardingData(); + const { control, handleSubmit, formState: { isSubmitting, isValid }, - } = useForm({ mode: 'onChange' }); + reset, + } = useForm({ defaultValues: questionnaire, mode: 'onChange' }); + + useEffect(() => { + reset(questionnaire); + }, [questionnaire, reset]); const onSubmit = handleSubmit(async (formData) => { - // TODO @xiaoyijun send data to the backend - console.log(formData); + await update({ questionnaire: formData }); }); const onNext = async () => { diff --git a/packages/console/src/cloud/types.ts b/packages/console/src/cloud/types.ts index 50522fde0..5eb33d24a 100644 --- a/packages/console/src/cloud/types.ts +++ b/packages/console/src/cloud/types.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + export enum CloudPage { Welcome = 'welcome', AboutUser = 'about-user', @@ -41,11 +43,19 @@ export enum Reason { Others = 'others', } -export type Questionnaire = { - project: Project; - deploymentType: DeploymentType; - titles: string[]; - companyName: string; - companySize: string; - reasons: string[]; -}; +export const questionnaireGuard = z.object({ + project: z.nativeEnum(Project), + deploymentType: z.nativeEnum(DeploymentType), + titles: z.array(z.nativeEnum(Title)).optional(), + companyName: z.string().optional(), + companySize: z.nativeEnum(CompanySize).optional(), + reasons: z.array(z.nativeEnum(Reason)).optional(), +}); + +export type Questionnaire = z.infer; + +export const userOnboardingDataGuard = z.object({ + questionnaire: questionnaireGuard.optional(), +}); + +export type UserOnboardingData = z.infer; diff --git a/packages/console/src/hooks/use-me-custom-data.ts b/packages/console/src/hooks/use-me-custom-data.ts new file mode 100644 index 000000000..c87f576c6 --- /dev/null +++ b/packages/console/src/hooks/use-me-custom-data.ts @@ -0,0 +1,59 @@ +import { useLogto } from '@logto/react'; +import { t } from 'i18next'; +import { useCallback } from 'react'; +import { toast } from 'react-hot-toast'; +import type { BareFetcher } from 'swr'; +import useSWR from 'swr'; + +import { adminTenantEndpoint, meApi } from '@/consts'; + +import type { RequestError } from './use-api'; +import { useStaticApi } from './use-api'; +import useLogtoUserId from './use-logto-user-id'; + +const useMeCustomData = () => { + const { isAuthenticated, error: authError } = useLogto(); + const userId = useLogtoUserId(); + const shouldFetch = isAuthenticated && !authError && userId; + const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator }); + const fetcher = useCallback( + async (resource, init) => { + const response = await api.get(resource, init); + + return response.json(); + }, + [api] + ); + + const { data, mutate, error } = useSWR( + shouldFetch && `me/custom-data`, + fetcher + ); + + const update = useCallback( + async (data: Record) => { + if (!userId) { + toast.error(t('errors.unexpected_error')); + + return; + } + const updated = await api + .patch(`me/custom-data`, { + json: data, + }) + .json(); + await mutate(updated); + }, + [api, mutate, userId] + ); + + return { + data, + error, + isLoading: !data && !error, + isLoaded: Boolean(data && !error), + update, + }; +}; + +export default useMeCustomData; diff --git a/packages/console/src/hooks/use-user-preferences.ts b/packages/console/src/hooks/use-user-preferences.ts index 3d97b9589..1c2a54bd9 100644 --- a/packages/console/src/hooks/use-user-preferences.ts +++ b/packages/console/src/hooks/use-user-preferences.ts @@ -1,19 +1,14 @@ import { builtInLanguages as builtInConsoleLanguages } from '@logto/phrases'; -import { useLogto } from '@logto/react'; import { AppearanceMode } from '@logto/schemas'; import type { Nullable, Optional } from '@silverhand/essentials'; -import { t } from 'i18next'; -import { useCallback, useEffect, useMemo } from 'react'; -import { toast } from 'react-hot-toast'; -import type { BareFetcher } from 'swr'; -import useSWR from 'swr'; +import { useEffect, useMemo } from 'react'; import { z } from 'zod'; -import { meApi, themeStorageKey, adminTenantEndpoint } from '@/consts'; +import { themeStorageKey } from '@/consts'; -import type { RequestError } from './use-api'; -import { useStaticApi } from './use-api'; -import useLogtoUserId from './use-logto-user-id'; +import useMeCustomData from './use-me-custom-data'; + +const adminConsolePreferencesKey = 'adminConsolePreferences'; const userPreferencesGuard = z.object({ language: z.enum(builtInConsoleLanguages).optional(), @@ -25,63 +20,35 @@ const userPreferencesGuard = z.object({ export type UserPreferences = z.infer; -const key = 'adminConsolePreferences'; - const getEnumFromArray = ( array: T[], value: Nullable> ): Optional => array.find((element) => element === value); const useUserPreferences = () => { - const { isAuthenticated, error: authError } = useLogto(); - const userId = useLogtoUserId(); - const shouldFetch = isAuthenticated && !authError && userId; - const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator }); - const fetcher = useCallback( - async (resource, init) => { - const response = await api.get(resource, init); + const { data, error, isLoading, isLoaded, update: updateMeCustomData } = useMeCustomData(); - return response.json(); - }, - [api] - ); - const { data, mutate, error } = useSWR( - shouldFetch && `me/custom-data`, - fetcher - ); + const userPreferences = useMemo(() => { + const parsed = z.object({ [adminConsolePreferencesKey]: userPreferencesGuard }).safeParse(data); - const parseData = useCallback((): UserPreferences => { - try { - return z.object({ [key]: userPreferencesGuard }).parse(data).adminConsolePreferences; - } catch { - return { - appearanceMode: - getEnumFromArray(Object.values(AppearanceMode), localStorage.getItem(themeStorageKey)) ?? - AppearanceMode.SyncWithSystem, - }; - } + return parsed.success + ? parsed.data[adminConsolePreferencesKey] + : { + appearanceMode: + getEnumFromArray( + Object.values(AppearanceMode), + localStorage.getItem(themeStorageKey) + ) ?? AppearanceMode.SyncWithSystem, + }; }, [data]); - const userPreferences = useMemo(() => parseData(), [parseData]); - const update = async (data: Partial) => { - if (!userId) { - toast.error(t('errors.unexpected_error')); - - return; - } - - const updated = await api - .patch(`me/custom-data`, { - json: { - [key]: { - ...userPreferences, - ...data, - }, - }, - }) - .json(); - void mutate(updated); + await updateMeCustomData({ + [adminConsolePreferencesKey]: { + ...userPreferences, + ...data, + }, + }); }; useEffect(() => { @@ -89,8 +56,8 @@ const useUserPreferences = () => { }, [userPreferences.appearanceMode]); return { - isLoading: !data && !error, - isLoaded: Boolean(data && !error), + isLoading, + isLoaded, data: userPreferences, update, error,