From 3ddc177f8eedb1be699672b07de03645131de1f7 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Wed, 6 Apr 2022 15:41:29 +0800 Subject: [PATCH] feat(console): support input form fields in get-started steps --- .../get-started/application/react/en/step3.md | 2 +- .../get-started/application/react/en/step4.md | 2 +- .../application/react/zh-CN/step3.md | 2 +- .../application/react/zh-CN/step4.md | 2 +- .../src/components/FormField/index.tsx | 7 +- .../src/pages/ApplicationDetails/index.tsx | 4 +- .../components/CreateForm/index.tsx | 50 +++++--- .../components/GetStartedModal/index.tsx | 32 +++++ .../LibrarySelector/index.module.scss | 1 + .../CodeComponentRenderer/index.tsx | 79 ++++++++++++ .../components/Step/index.module.scss | 12 +- .../GetStarted/components/Step/index.tsx | 115 ++++++++++-------- .../src/pages/GetStarted/hooks/index.ts | 3 +- .../console/src/pages/GetStarted/index.tsx | 100 +++++++-------- packages/console/src/types/get-started.ts | 11 ++ packages/phrases/src/locales/en.ts | 8 +- packages/phrases/src/locales/zh-cn.ts | 8 +- 17 files changed, 294 insertions(+), 144 deletions(-) create mode 100644 packages/console/src/pages/Applications/components/GetStartedModal/index.tsx create mode 100644 packages/console/src/pages/GetStarted/components/CodeComponentRenderer/index.tsx create mode 100644 packages/console/src/types/get-started.ts diff --git a/packages/console/public/get-started/application/react/en/step3.md b/packages/console/public/get-started/application/react/en/step3.md index 6bb9cdac3..f8a4afc91 100644 --- a/packages/console/public/get-started/application/react/en/step3.md +++ b/packages/console/public/get-started/application/react/en/step3.md @@ -6,7 +6,7 @@ subtitle: 2 steps The Logto React SDK provides you tools and hooks to quickly implement your own authorization flow. First, let’s enter your redirect URI -```multitextinput +```redirectUris Redirect URI ``` diff --git a/packages/console/public/get-started/application/react/en/step4.md b/packages/console/public/get-started/application/react/en/step4.md index ec789011e..0df80c5c8 100644 --- a/packages/console/public/get-started/application/react/en/step4.md +++ b/packages/console/public/get-started/application/react/en/step4.md @@ -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 ``` diff --git a/packages/console/public/get-started/application/react/zh-CN/step3.md b/packages/console/public/get-started/application/react/zh-CN/step3.md index ec589302e..c59cad0e9 100644 --- a/packages/console/public/get-started/application/react/zh-CN/step3.md +++ b/packages/console/public/get-started/application/react/zh-CN/step3.md @@ -6,7 +6,7 @@ subtitle: 2 steps The Logto React SDK provides you tools and hooks to quickly implement your own authorization flow. First, let’s enter your redirect URI -```multitextinput +```redirectUris Redirect URI ``` diff --git a/packages/console/public/get-started/application/react/zh-CN/step4.md b/packages/console/public/get-started/application/react/zh-CN/step4.md index 4259aa6cd..52dbb2f4b 100644 --- a/packages/console/public/get-started/application/react/zh-CN/step4.md +++ b/packages/console/public/get-started/application/react/zh-CN/step4.md @@ -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 ``` diff --git a/packages/console/src/components/FormField/index.tsx b/packages/console/src/components/FormField/index.tsx index 478a60a37..19bf7f9ff 100644 --- a/packages/console/src/components/FormField/index.tsx +++ b/packages/console/src/components/FormField/index.tsx @@ -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; children: ReactNode; isRequired?: boolean; className?: string; @@ -18,7 +19,7 @@ const FormField = ({ title, children, isRequired, className }: Props) => { return (
-
{t(title)}
+
{typeof title === 'string' ? t(title) : title}
{isRequired &&
{t('admin_console.form.required')}
}
{children} diff --git a/packages/console/src/pages/ApplicationDetails/index.tsx b/packages/console/src/pages/ApplicationDetails/index.tsx index 7d9d4336c..ede38de75 100644 --- a/packages/console/src/pages/ApplicationDetails/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/index.tsx @@ -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'), }, }), }} diff --git a/packages/console/src/pages/Applications/components/CreateForm/index.tsx b/packages/console/src/pages/Applications/components/CreateForm/index.tsx index 424fe8ad0..5a787b913 100644 --- a/packages/console/src/pages/Applications/components/CreateForm/index.tsx +++ b/packages/console/src/pages/Applications/components/CreateForm/index.tsx @@ -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(); - 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(); + setCreatedApp(application); + closeModal(); + }; + return ( { {!isGetStartedSkipped && createdApp && ( - - } - title={createdApp.name} - subtitle="applications.get_started.header_description" - type="application" - defaultSubtype={SupportedJavascriptLibraries.React} - onClose={closeModal} - onComplete={closeModal} - /> - + )} ); diff --git a/packages/console/src/pages/Applications/components/GetStartedModal/index.tsx b/packages/console/src/pages/Applications/components/GetStartedModal/index.tsx new file mode 100644 index 000000000..5b5370925 --- /dev/null +++ b/packages/console/src/pages/Applications/components/GetStartedModal/index.tsx @@ -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; +}; + +const GetStartedModal = ({ appName, isOpen, onClose, onComplete }: Props) => ( + + } + title={appName} + subtitle="applications.get_started.header_description" + type="application" + defaultSubtype={SupportedJavascriptLibraries.React} + onClose={onClose} + onComplete={onComplete} + /> + +); + +export default GetStartedModal; diff --git a/packages/console/src/pages/Applications/components/LibrarySelector/index.module.scss b/packages/console/src/pages/Applications/components/LibrarySelector/index.module.scss index 6e1e59dcd..48f44eeb5 100644 --- a/packages/console/src/pages/Applications/components/LibrarySelector/index.module.scss +++ b/packages/console/src/pages/Applications/components/LibrarySelector/index.module.scss @@ -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); diff --git a/packages/console/src/pages/GetStarted/components/CodeComponentRenderer/index.tsx b/packages/console/src/pages/GetStarted/components/CodeComponentRenderer/index.tsx new file mode 100644 index 000000000..c96a3cc11 --- /dev/null +++ b/packages/console/src/pages/GetStarted/components/CodeComponentRenderer/index.tsx @@ -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 & { onError: () => void }; + +const CodeComponentRenderer = ({ className, children, onError }: Props) => { + const { + control, + formState: { errors }, + } = useFormContext(); + + const ref = useRef(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 ( + {content}}> + ( +
+ +
+ )} + /> +
+ ); + } + + return ; +}; + +export default CodeComponentRenderer; diff --git a/packages/console/src/pages/GetStarted/components/Step/index.module.scss b/packages/console/src/pages/GetStarted/components/Step/index.module.scss index 78867a8ef..99b1e6ff2 100644 --- a/packages/console/src/pages/GetStarted/components/Step/index.module.scss +++ b/packages/console/src/pages/GetStarted/components/Step/index.module.scss @@ -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 { diff --git a/packages/console/src/pages/GetStarted/components/Step/index.tsx b/packages/console/src/pages/GetStarted/components/Step/index.tsx index 0c439398d..e8e9ea395 100644 --- a/packages/console/src/pages/GetStarted/components/Step/index.tsx +++ b/packages/console/src/pages/GetStarted/components/Step/index.tsx @@ -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 -) => { +const Step = ({ data, index, isActive, isComplete, isFinalStep, onNext }: Props) => { + const [isExpanded, setIsExpanded] = useState(false); const { title, subtitle, metadata } = data; + const ref = useRef(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) => ( + + ), + }), + [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 ( -
+
{ + setIsExpanded(!isExpanded); + }} + >
- {isCompleted ? : index + 1} + {isComplete ? : index + 1}
{isExpanded ? : }
- {isExpanded && ( - <> - { - const [, language] = /language-(\w+)/.exec(className ?? '') ?? []; - const content = String(children); - - return ; - }, - }} - > - {metadata} - -
-
- - )} +
+ + {metadata} + +
+
+
); }; -export default forwardRef(Step); +export default Step; diff --git a/packages/console/src/pages/GetStarted/hooks/index.ts b/packages/console/src/pages/GetStarted/hooks/index.ts index 4de36229c..2b0e7e8f7 100644 --- a/packages/console/src/pages/GetStarted/hooks/index.ts +++ b/packages/console/src/pages/GetStarted/hooks/index.ts @@ -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[]; }; diff --git a/packages/console/src/pages/GetStarted/index.tsx b/packages/console/src/pages/GetStarted/index.tsx index fc891763e..38cd29e18 100644 --- a/packages/console/src/pages/GetStarted/index.tsx +++ b/packages/console/src/pages/GetStarted/index.tsx @@ -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; }>; const onClickFetchSampleProject = (projectName: string) => { @@ -48,23 +40,22 @@ const GetStarted = ({ bannerComponent, onClose, onComplete, - onToggleSteps, }: Props) => { const [subtype, setSubtype] = useState(defaultSubtype); const [activeStepIndex, setActiveStepIndex] = useState(-1); const steps = useGetStartedSteps(type, subtype) ?? []; + const methods = useForm({ reValidateMode: 'onBlur' }); + const { + formState: { isSubmitting }, + handleSubmit, + } = methods; - const stepReferences = useRef>>( - // eslint-disable-next-line @typescript-eslint/ban-types - Array.from({ 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 (
@@ -79,7 +70,7 @@ const GetStarted = ({ {subtype && (
- {isValidElement(bannerComponent) && - cloneElement(bannerComponent, { - className: styles.banner, - onChange: setSubtype, - onToggle: () => { - setActiveStepIndex(0); - }, - })} - {steps.map((step, index) => { - const isFinalStep = index === steps.length - 1; + +
+ {isValidElement(bannerComponent) && + cloneElement(bannerComponent, { + className: styles.banner, + onChange: setSubtype, + onToggle: () => { + setActiveStepIndex(0); + }, + })} + {steps.map((step, index) => { + const isFinalStep = index === steps.length - 1; - return ( - { - // 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 ( + index} + isFinalStep={isFinalStep} + onNext={() => { + setActiveStepIndex(index + 1); + }} + /> + ); + })} + +
); diff --git a/packages/console/src/types/get-started.ts b/packages/console/src/types/get-started.ts new file mode 100644 index 000000000..b610af3e9 --- /dev/null +++ b/packages/console/src/types/get-started.ts @@ -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; +}; diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index dcc5462f7..6972f619c 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -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: diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index ccb9aa1d2..6b8405ddf 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -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: '管理已注册用户, 创建新用户,编辑用户资料。',