0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): submit cloud questionnaire (#3172)

This commit is contained in:
Xiao Yijun 2023-02-23 16:49:45 +08:00 committed by GitHub
parent fe1fa2a7bb
commit e8e623a2bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 219 additions and 89 deletions

View file

@ -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<UserOnboardingData>) => {
await updateMeCustomData({
[userOnboardingDataKey]: {
...userOnboardingData,
...data,
},
});
},
[updateMeCustomData, userOnboardingData]
);
return { data: userOnboardingData, error, isLoading, isLoaded, update };
};
export default useUserOnboardingData;

View file

@ -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<Questionnaire>({
const {
data: { questionnaire },
update,
} = useUserOnboardingData();
const { control, register, handleSubmit, reset } = useForm<Questionnaire>({
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 = () => {
<Controller
control={control}
name="titles"
defaultValue={[]}
render={({ field: { onChange, value } }) => (
<MultiCardSelector
className={styles.titleSelector}
optionClassName={styles.option}
value={value}
value={value ?? []}
options={titleOptions}
onChange={onChange}
onChange={(value) => {
onChange(value.length === 0 ? undefined : value);
}}
/>
)}
/>
@ -77,10 +91,12 @@ const About = () => {
render={({ field: { onChange, value, name } }) => (
<CardSelector
name={name}
value={value}
value={value ?? ''}
options={companySizeOptions}
optionClassName={styles.option}
onChange={onChange}
onChange={(value) => {
onChange(conditional(value && value));
}}
/>
)}
/>
@ -92,9 +108,14 @@ const About = () => {
<Controller
control={control}
name="reasons"
defaultValue={[]}
render={({ field: { onChange, value } }) => (
<MultiCardSelector value={value} options={reasonOptions} onChange={onChange} />
<MultiCardSelector
value={value ?? []}
options={reasonOptions}
onChange={(value) => {
onChange(value.length === 0 ? undefined : value);
}}
/>
)}
/>
</FormField>

View file

@ -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 (
<div className={styles.cloud}>
<Routes>
<Route index element={<Navigate replace to={CloudPage.Welcome} />} />
<Route index element={<Navigate replace to={welcomePathname} />} />
<Route path={CloudPage.Welcome} element={<Welcome />} />
<Route path={CloudPage.AboutUser} element={<About />} />
<Route path={CloudPage.Congrats} element={<Congrats />} />
<Route
path={CloudPage.AboutUser}
element={
conditional(questionnaire && <About />) ?? <Navigate replace to={welcomePathname} />
}
/>
<Route
path={CloudPage.Congrats}
element={
conditional(questionnaire && <Congrats />) ?? <Navigate replace to={welcomePathname} />
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</div>
);
};
export default Cloud;

View file

@ -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<Questionnaire>({ mode: 'onChange' });
reset,
} = useForm<Questionnaire>({ 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 () => {

View file

@ -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<typeof questionnaireGuard>;
export const userOnboardingDataGuard = z.object({
questionnaire: questionnaireGuard.optional(),
});
export type UserOnboardingData = z.infer<typeof userOnboardingDataGuard>;

View file

@ -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<BareFetcher>(
async (resource, init) => {
const response = await api.get(resource, init);
return response.json();
},
[api]
);
const { data, mutate, error } = useSWR<unknown, RequestError>(
shouldFetch && `me/custom-data`,
fetcher
);
const update = useCallback(
async (data: Record<string, unknown>) => {
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;

View file

@ -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<typeof userPreferencesGuard>;
const key = 'adminConsolePreferences';
const getEnumFromArray = <T extends string>(
array: T[],
value: Nullable<Optional<string>>
): Optional<T> => 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<BareFetcher>(
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<unknown, RequestError>(
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 {
return parsed.success
? parsed.data[adminConsolePreferencesKey]
: {
appearanceMode:
getEnumFromArray(Object.values(AppearanceMode), localStorage.getItem(themeStorageKey)) ??
AppearanceMode.SyncWithSystem,
getEnumFromArray(
Object.values(AppearanceMode),
localStorage.getItem(themeStorageKey)
) ?? AppearanceMode.SyncWithSystem,
};
}
}, [data]);
const userPreferences = useMemo(() => parseData(), [parseData]);
const update = async (data: Partial<UserPreferences>) => {
if (!userId) {
toast.error(t('errors.unexpected_error'));
return;
}
const updated = await api
.patch(`me/custom-data`, {
json: {
[key]: {
await updateMeCustomData({
[adminConsolePreferencesKey]: {
...userPreferences,
...data,
},
},
})
.json();
void mutate(updated);
});
};
useEffect(() => {
@ -89,8 +56,8 @@ const useUserPreferences = () => {
}, [userPreferences.appearanceMode]);
return {
isLoading: !data && !error,
isLoaded: Boolean(data && !error),
isLoading,
isLoaded,
data: userPreferences,
update,
error,