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:
parent
fe1fa2a7bb
commit
e8e623a2bf
7 changed files with 219 additions and 89 deletions
35
packages/console/src/cloud/hooks/use-user-onboarding-data.ts
Normal file
35
packages/console/src/cloud/hooks/use-user-onboarding-data.ts
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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 = () => (
|
||||
<div className={styles.cloud}>
|
||||
<Routes>
|
||||
<Route index element={<Navigate replace to={CloudPage.Welcome} />} />
|
||||
<Route path={CloudPage.Welcome} element={<Welcome />} />
|
||||
<Route path={CloudPage.AboutUser} element={<About />} />
|
||||
<Route path={CloudPage.Congrats} element={<Congrats />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
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={welcomePathname} />} />
|
||||
<Route path={CloudPage.Welcome} element={<Welcome />} />
|
||||
<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;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>;
|
||||
|
|
59
packages/console/src/hooks/use-me-custom-data.ts
Normal file
59
packages/console/src/hooks/use-me-custom-data.ts
Normal 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;
|
|
@ -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 {
|
||||
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<UserPreferences>) => {
|
||||
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,
|
||||
|
|
Loading…
Reference in a new issue