0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(console): support input form fields in get-started steps

This commit is contained in:
Charles Zhao 2022-04-06 15:41:29 +08:00
parent 09703896f3
commit 3ddc177f8e
No known key found for this signature in database
GPG key ID: 4858774754C92DF2
17 changed files with 294 additions and 144 deletions

View file

@ -6,7 +6,7 @@ subtitle: 2 steps
The Logto React SDK provides you tools and hooks to quickly implement your own authorization flow. First, lets enter your redirect URI
```multitextinput
```redirectUris
Redirect URI
```

View file

@ -4,7 +4,7 @@ subtitle: 1 steps
---
Execute signOut() methods will redirect users to the Logto sign out page. After a success sign out, all use session data and auth status will be cleared.
```multitextinput
```postLogoutRedirectUris
Post sign out redirect URI
```

View file

@ -6,7 +6,7 @@ subtitle: 2 steps
The Logto React SDK provides you tools and hooks to quickly implement your own authorization flow. First, lets enter your redirect URI
```multitextinput
```redirectUris
Redirect URI
```

View file

@ -4,7 +4,7 @@ subtitle: 1 steps
---
Execute signOut() methods will redirect users to the Logto sign out page. After a success sign out, all use session data and auth status will be cleared.
```multitextinput
```postLogoutRedirectUris
Post sign out redirect URI
```

View file

@ -1,12 +1,13 @@
import { I18nKey } from '@logto/phrases';
import classNames from 'classnames';
import React, { ReactNode } from 'react';
import React, { ReactElement, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import DangerousRaw from '../DangerousRaw';
import * as styles from './index.module.scss';
type Props = {
title: I18nKey;
title: I18nKey | ReactElement<typeof DangerousRaw>;
children: ReactNode;
isRequired?: boolean;
className?: string;
@ -18,7 +19,7 @@ const FormField = ({ title, children, isRequired, className }: Props) => {
return (
<div className={classNames(styles.field, className)}>
<div className={styles.headline}>
<div className={styles.title}>{t(title)}</div>
<div className={styles.title}>{typeof title === 'string' ? t(title) : title}</div>
{isRequired && <div className={styles.required}>{t('admin_console.form.required')}</div>}
</div>
{children}

View file

@ -128,7 +128,7 @@ const ApplicationDetails = () => {
required: t('application_details.redirect_uri_required'),
pattern: {
regex: noSpaceRegex,
message: t('application_details.no_space_in_uri'),
message: t('errors.no_space_in_uri'),
},
}),
}}
@ -153,7 +153,7 @@ const ApplicationDetails = () => {
validate: createValidatorForRhf({
pattern: {
regex: noSpaceRegex,
message: t('application_details.no_space_in_uri'),
message: t('errors.no_space_in_uri'),
},
}),
}}

View file

@ -2,7 +2,6 @@ import { Application, ApplicationType, Setting } from '@logto/schemas';
import React, { useState } from 'react';
import { useController, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import useSWR from 'swr';
import Button from '@/components/Button';
@ -11,11 +10,10 @@ import ModalLayout from '@/components/ModalLayout';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import TextInput from '@/components/TextInput';
import useApi, { RequestError } from '@/hooks/use-api';
import GetStarted from '@/pages/GetStarted';
import * as modalStyles from '@/scss/modal.module.scss';
import { applicationTypeI18nKey, SupportedJavascriptLibraries } from '@/types/applications';
import { applicationTypeI18nKey } from '@/types/applications';
import { GetStartedForm } from '@/types/get-started';
import LibrarySelector from '../LibrarySelector';
import GetStartedModal from '../GetStartedModal';
import TypeDescription from '../TypeDescription';
import * as styles from './index.module.scss';
@ -31,7 +29,7 @@ type Props = {
const CreateForm = ({ onClose }: Props) => {
const [createdApp, setCreatedApp] = useState<Application>();
const [isQuickStartGuideOpen, setIsQuickStartGuideOpen] = useState(false);
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
const {
handleSubmit,
control,
@ -48,7 +46,7 @@ const CreateForm = ({ onClose }: Props) => {
const isGetStartedSkipped = setting?.adminConsole.applicationSkipGetStarted;
const closeModal = () => {
setIsQuickStartGuideOpen(false);
setIsGetStartedModalOpen(false);
onClose?.(createdApp);
};
@ -63,10 +61,29 @@ const CreateForm = ({ onClose }: Props) => {
if (isGetStartedSkipped) {
closeModal();
} else {
setIsQuickStartGuideOpen(true);
setIsGetStartedModalOpen(true);
}
});
const onComplete = async (data: GetStartedForm) => {
if (!createdApp) {
return;
}
const application = await api
.patch(`/api/applications/${createdApp.id}`, {
json: {
oidcClientMetadata: {
redirectUris: data.redirectUris.filter(Boolean),
postLogoutRedirectUris: data.postLogoutRedirectUris.filter(Boolean),
},
},
})
.json<Application>();
setCreatedApp(application);
closeModal();
};
return (
<ModalLayout
title="applications.create"
@ -108,17 +125,12 @@ const CreateForm = ({ onClose }: Props) => {
</FormField>
</form>
{!isGetStartedSkipped && createdApp && (
<Modal isOpen={isQuickStartGuideOpen} className={modalStyles.fullScreen}>
<GetStarted
bannerComponent={<LibrarySelector />}
title={createdApp.name}
subtitle="applications.get_started.header_description"
type="application"
defaultSubtype={SupportedJavascriptLibraries.React}
onClose={closeModal}
onComplete={closeModal}
/>
</Modal>
<GetStartedModal
appName={createdApp.name}
isOpen={isGetStartedModalOpen}
onClose={closeModal}
onComplete={onComplete}
/>
)}
</ModalLayout>
);

View file

@ -0,0 +1,32 @@
import React from 'react';
import Modal from 'react-modal';
import GetStarted from '@/pages/GetStarted';
import * as modalStyles from '@/scss/modal.module.scss';
import { SupportedJavascriptLibraries } from '@/types/applications';
import { GetStartedForm } from '@/types/get-started';
import LibrarySelector from '../LibrarySelector';
type Props = {
appName: string;
isOpen: boolean;
onClose: () => void;
onComplete: (data: GetStartedForm) => Promise<void>;
};
const GetStartedModal = ({ appName, isOpen, onClose, onComplete }: Props) => (
<Modal isOpen={isOpen} className={modalStyles.fullScreen}>
<GetStarted
bannerComponent={<LibrarySelector />}
title={appName}
subtitle="applications.get_started.header_description"
type="application"
defaultSubtype={SupportedJavascriptLibraries.React}
onClose={onClose}
onComplete={onComplete}
/>
</Modal>
);
export default GetStartedModal;

View file

@ -31,6 +31,7 @@
flex-direction: row;
align-items: center;
flex: 0 0 56px;
height: 56px;
background: var(--color-neutral-variant-90);
border-radius: _.unit(2);
padding: 0 _.unit(4);

View file

@ -0,0 +1,79 @@
import React, { PropsWithChildren, useEffect, useRef } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { CodeProps } from 'react-markdown/lib/ast-to-react.js';
import CodeEditor from '@/components/CodeEditor';
import DangerousRaw from '@/components/DangerousRaw';
import FormField from '@/components/FormField';
import MultiTextInput from '@/components/MultiTextInput';
import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils';
import { GetStartedForm } from '@/types/get-started';
import { noSpaceRegex } from '@/utilities/regex';
type Props = PropsWithChildren<CodeProps> & { onError: () => void };
const CodeComponentRenderer = ({ className, children, onError }: Props) => {
const {
control,
formState: { errors },
} = useFormContext<GetStartedForm>();
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [, codeBlockType] = /language-(\w+)/.exec(className ?? '') ?? [];
const content = String(children);
/** Code block types defined in markdown. E.g.
* ```typescript
* some code
* ```
* These two custom code block types should be replaced with `MultiTextInput` component:
* 'redirectUris' and 'postLogoutRedirectUris'
*/
const isMultilineInput =
codeBlockType === 'redirectUris' || codeBlockType === 'postLogoutRedirectUris';
const firstErrorKey = Object.keys(errors)[0];
const isFirstErrorField = firstErrorKey && firstErrorKey === codeBlockType;
useEffect(() => {
if (isFirstErrorField) {
onError();
}
}, [isFirstErrorField, onError]);
if (isMultilineInput) {
return (
<FormField isRequired title={<DangerousRaw>{content}</DangerousRaw>}>
<Controller
name={codeBlockType}
control={control}
defaultValue={[]}
rules={{
validate: createValidatorForRhf({
required: t('errors.required_field_missing_plural', { field: content }),
pattern: {
regex: noSpaceRegex,
message: t('errors.no_space_in_uri'),
},
}),
}}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<div ref={ref}>
<MultiTextInput
value={value}
error={convertRhfErrorMessage(error?.message)}
onChange={onChange}
/>
</div>
)}
/>
</FormField>
);
}
return <CodeEditor isReadonly language={codeBlockType} value={content} />;
};
export default CodeComponentRenderer;

View file

@ -6,7 +6,7 @@
flex-direction: column;
scroll-margin: _.unit(5);
.cardHeader {
.header {
display: flex;
flex-direction: row;
align-items: center;
@ -48,6 +48,16 @@
justify-content: flex-end;
margin-top: _.unit(6);
}
.content {
max-height: 0;
overflow: hidden;
&.expanded {
max-height: 9999px;
overflow: unset;
}
}
}
.card + .card {

View file

@ -1,41 +1,65 @@
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import React, { forwardRef, Ref } from 'react';
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { CodeProps } from 'react-markdown/lib/ast-to-react.js';
import Button from '@/components/Button';
import Card from '@/components/Card';
import CardTitle from '@/components/CardTitle';
import CodeEditor from '@/components/CodeEditor';
import DangerousRaw from '@/components/DangerousRaw';
import IconButton from '@/components/IconButton';
import Spacer from '@/components/Spacer';
import { ArrowDown, ArrowUp } from '@/icons/Arrow';
import Tick from '@/icons/Tick';
import { StepMetadata } from '@/types/get-started';
import CodeComponentRenderer from '../CodeComponentRenderer';
import * as styles from './index.module.scss';
export type StepMetadata = {
title?: string;
subtitle?: string;
metadata: string; // Markdown formatted string
};
type Props = {
data: StepMetadata;
index: number;
isCompleted: boolean;
isExpanded: boolean;
isActive: boolean;
isComplete: boolean;
isFinalStep: boolean;
onComplete?: () => void;
onNext?: () => void;
onToggle?: () => void;
};
const Step = (
{ data, index, isCompleted, isExpanded, isFinalStep, onComplete, onNext, onToggle }: Props,
ref?: Ref<HTMLDivElement>
) => {
const Step = ({ data, index, isActive, isComplete, isFinalStep, onNext }: Props) => {
const [isExpanded, setIsExpanded] = useState(false);
const { title, subtitle, metadata } = data;
const ref = useRef<HTMLDivElement>(null);
const scrollToStep = useCallback(() => {
ref.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}, []);
const onError = useCallback(() => {
setIsExpanded(true);
scrollToStep();
}, [scrollToStep]);
useEffect(() => {
if (isActive) {
setIsExpanded(true);
}
}, [isActive]);
useEffect(() => {
if (isExpanded) {
scrollToStep();
}
}, [isExpanded, scrollToStep]);
const memoizedComponents = useMemo(
() => ({
code: ({ ...props }: PropsWithChildren<CodeProps>) => (
<CodeComponentRenderer {...props} onError={onError} />
),
}),
[onError]
);
// Steps in get-started must have "title" declared in the Yaml header of the markdown source file
if (!title) {
@ -43,18 +67,22 @@ const Step = (
}
// TODO: add more styles to markdown renderer
// TODO: render form and input fields in steps
return (
<Card key={title} ref={ref} className={styles.card}>
<div className={styles.cardHeader} onClick={onToggle}>
<div
className={styles.header}
onClick={() => {
setIsExpanded(!isExpanded);
}}
>
<div
className={classNames(
styles.index,
isExpanded && styles.active,
isCompleted && styles.completed
isActive && styles.active,
isComplete && styles.completed
)}
>
{isCompleted ? <Tick /> : index + 1}
{isComplete ? <Tick /> : index + 1}
</div>
<CardTitle
size="medium"
@ -64,38 +92,21 @@ const Step = (
<Spacer />
<IconButton>{isExpanded ? <ArrowUp /> : <ArrowDown />}</IconButton>
</div>
{isExpanded && (
<>
<ReactMarkdown
className={styles.markdownContent}
components={{
code: ({ className, children }) => {
const [, language] = /language-(\w+)/.exec(className ?? '') ?? [];
const content = String(children);
return <CodeEditor isReadonly language={language} value={content} />;
},
}}
>
{metadata}
</ReactMarkdown>
<div className={styles.buttonWrapper}>
<Button
type="primary"
title={`general.${isFinalStep ? 'done' : 'next'}`}
onClick={() => {
if (isFinalStep) {
onComplete?.();
} else {
onNext?.();
}
}}
/>
</div>
</>
)}
<div className={classNames(styles.content, isExpanded && styles.expanded)}>
<ReactMarkdown className={styles.markdownContent} components={memoizedComponents}>
{metadata}
</ReactMarkdown>
<div className={styles.buttonWrapper}>
<Button
htmlType={isFinalStep ? 'submit' : 'button'}
type="primary"
title={`general.${isFinalStep ? 'done' : 'next'}`}
onClick={conditional(!isFinalStep && onNext)}
/>
</div>
</div>
</Card>
);
};
export default forwardRef(Step);
export default Step;

View file

@ -3,10 +3,9 @@ import { useMemo } from 'react';
// eslint-disable-next-line node/file-extension-in-import
import useSWRImmutable from 'swr/immutable';
import { StepMetadata } from '@/types/get-started';
import { parseMarkdownWithYamlFrontmatter } from '@/utilities/markdown';
import { StepMetadata } from '../components/Step';
type DocumentFileNames = {
files: string[];
};

View file

@ -1,14 +1,6 @@
import { AdminConsoleKey } from '@logto/phrases';
import { Nullable } from '@silverhand/essentials';
import React, {
cloneElement,
isValidElement,
PropsWithChildren,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import React, { cloneElement, isValidElement, PropsWithChildren, ReactNode, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
@ -16,6 +8,7 @@ import DangerousRaw from '@/components/DangerousRaw';
import IconButton from '@/components/IconButton';
import Spacer from '@/components/Spacer';
import Close from '@/icons/Close';
import { GetStartedForm } from '@/types/get-started';
import Step from './components/Step';
import { GetStartedType, useGetStartedSteps } from './hooks';
@ -31,8 +24,7 @@ type Props = PropsWithChildren<{
defaultSubtype?: string;
bannerComponent?: ReactNode;
onClose?: () => void;
onComplete?: () => void;
onToggleSteps?: () => void;
onComplete?: (data: GetStartedForm) => Promise<void>;
}>;
const onClickFetchSampleProject = (projectName: string) => {
@ -48,23 +40,22 @@ const GetStarted = ({
bannerComponent,
onClose,
onComplete,
onToggleSteps,
}: Props) => {
const [subtype, setSubtype] = useState(defaultSubtype);
const [activeStepIndex, setActiveStepIndex] = useState<number>(-1);
const steps = useGetStartedSteps(type, subtype) ?? [];
const methods = useForm<GetStartedForm>({ reValidateMode: 'onBlur' });
const {
formState: { isSubmitting },
handleSubmit,
} = methods;
const stepReferences = useRef<Array<Nullable<HTMLDivElement>>>(
// eslint-disable-next-line @typescript-eslint/ban-types
Array.from<null>({ length: steps.length }).fill(null)
);
useEffect(() => {
if (activeStepIndex > -1) {
const activeStepRef = stepReferences.current[activeStepIndex];
activeStepRef?.scrollIntoView({ block: 'start', behavior: 'smooth' });
const onSubmit = handleSubmit((data) => {
if (isSubmitting) {
return;
}
}, [activeStepIndex, stepReferences]);
void onComplete?.(data);
});
return (
<div className={styles.container}>
@ -79,7 +70,7 @@ const GetStarted = ({
{subtype && (
<Button
type="outline"
title="admin_console.applications.get_started.get_sample_file"
title="admin_console.get_started.get_sample_file"
onClick={() => {
onClickFetchSampleProject(subtype);
}}
@ -87,40 +78,35 @@ const GetStarted = ({
)}
</div>
<div className={styles.content}>
{isValidElement(bannerComponent) &&
cloneElement(bannerComponent, {
className: styles.banner,
onChange: setSubtype,
onToggle: () => {
setActiveStepIndex(0);
},
})}
{steps.map((step, index) => {
const isFinalStep = index === steps.length - 1;
<FormProvider {...methods}>
<form onSubmit={onSubmit}>
{isValidElement(bannerComponent) &&
cloneElement(bannerComponent, {
className: styles.banner,
onChange: setSubtype,
onToggle: () => {
setActiveStepIndex(0);
},
})}
{steps.map((step, index) => {
const isFinalStep = index === steps.length - 1;
return (
<Step
key={step.title}
ref={(element) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
stepReferences.current[index] = element;
}}
data={step}
index={index}
isCompleted={activeStepIndex > index}
isExpanded={activeStepIndex === index}
isFinalStep={isFinalStep}
onComplete={onComplete}
onNext={() => {
setActiveStepIndex(index + 1);
}}
onToggle={() => {
setActiveStepIndex(index);
onToggleSteps?.();
}}
/>
);
})}
return (
<Step
key={step.title}
data={step}
index={index}
isActive={activeStepIndex === index}
isComplete={activeStepIndex > index}
isFinalStep={isFinalStep}
onNext={() => {
setActiveStepIndex(index + 1);
}}
/>
);
})}
</form>
</FormProvider>
</div>
</div>
);

View file

@ -0,0 +1,11 @@
export type StepMetadata = {
title?: string;
subtitle?: string;
metadata: string; // Markdown formatted string
};
export type GetStartedForm = {
redirectUris: string[];
postLogoutRedirectUris: string[];
connectorConfigJson: string;
};

View file

@ -51,6 +51,9 @@ const translation = {
unknown_server_error: 'Unknown server error occurred.',
empty: 'No Data',
missing_total_number: 'Unable to find Total-Number in response headers.',
no_space_in_uri: 'Space is not allowed in URI',
required_field_missing: 'Please enter {{field}}',
required_field_missing_plural: 'You have to enter at least one {{field}}',
},
tab_sections: {
overview: 'Overview',
@ -103,7 +106,6 @@ const translation = {
get_started: {
header_description:
'Follow a step by step guide to integrate your application or get a sample configured with your account settings',
get_sample_file: 'Get a sample file',
title: 'Congratulations! The application has been created successfully.',
subtitle:
'Now follow the steps below to finish your app settings. Please select the JS library to continue.',
@ -138,7 +140,6 @@ const translation = {
application_deleted: 'The application {{name}} deleted.',
save_success: 'Saved!',
redirect_uri_required: 'You have to enter at least one redirect URI.',
no_space_in_uri: 'Space is not allowed in URI',
},
api_resources: {
title: 'API Resources',
@ -211,6 +212,9 @@ const translation = {
more_options: 'MORE OPTIONS',
connector_deleted: 'The connector has been deleted.',
},
get_started: {
get_sample_file: 'Get a sample file',
},
users: {
title: 'User Management',
subtitle:

View file

@ -53,6 +53,9 @@ const translation = {
unknown_server_error: '服务器发生未知错误。',
empty: '没有数据',
missing_total_number: '无法从返回的头部信息中找到 Total-Number。',
no_space_in_uri: 'URI 中不能包含空格',
required_field_missing: '请输入{{field}}',
required_field_missing_plural: '{{field}}不能全部为空',
},
tab_sections: {
overview: '概览',
@ -105,7 +108,6 @@ const translation = {
get_started: {
header_description:
'参考如下教程,将 Logto 集成到您的应用中。您也可以点击右侧链接,获取我们为您准备好的示范工程。',
get_sample_file: '获取示范工程',
title: '恭喜!您的应用已成功创建。',
subtitle: '请参考以下步骤完成您的应用设置。首先,请选择您要使用的 Javascript 框架:',
description_by_library: '本教程向您演示如何在 {{library}} 应用中集成 Logto 登录功能',
@ -138,7 +140,6 @@ const translation = {
application_deleted: 'The application {{name}} deleted.',
save_success: 'Saved!',
redirect_uri_required: 'You have to enter at least one redirect URI.',
no_space_in_uri: 'Space is not allowed in URI',
},
api_resources: {
title: 'API Resources',
@ -211,6 +212,9 @@ const translation = {
more_options: '更多选项',
connector_deleted: '成功删除连接器。',
},
get_started: {
get_sample_file: '获取示例工程',
},
users: {
title: '用户管理',
subtitle: '管理已注册用户, 创建新用户,编辑用户资料。',