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

Merge pull request #481 from logto-io/charles-log-2000-refactor-get-started-guide

refactor(console): promote and re-use get-started component in both applications and connectors
This commit is contained in:
Charles Zhao 2022-04-02 12:37:43 +08:00 committed by GitHub
commit 08dd968127
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 501 additions and 359 deletions

View file

@ -17,7 +17,6 @@ import Applications from './pages/Applications';
import Callback from './pages/Callback';
import ConnectorDetails from './pages/ConnectorDetails';
import Connectors from './pages/Connectors';
import GetStarted from './pages/GetStarted';
import NotFound from './pages/NotFound';
import UserDetails from './pages/UserDetails';
import Users from './pages/Users';
@ -44,7 +43,6 @@ const Main = () => {
<Route path="callback" element={<Callback />} />
<Route element={<AppContent theme="light" />}>
<Route path="*" element={<NotFound />} />
<Route path="get-started" element={<GetStarted />} />
<Route path="applications">
<Route index element={<Applications />} />
<Route path=":id">

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import React, { forwardRef, LegacyRef, ReactNode } from 'react';
import React, { forwardRef, Ref, ReactNode } from 'react';
import * as styles from './index.module.scss';
@ -8,7 +8,7 @@ type Props = {
className?: string;
};
const Card = (props: Props, reference?: LegacyRef<HTMLDivElement>) => {
const Card = (props: Props, reference?: Ref<HTMLDivElement>) => {
const { children, className } = props;
return (

View file

@ -11,10 +11,11 @@ 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 } from '@/types/applications';
import { applicationTypeI18nKey, SupportedJavascriptLibraries } from '@/types/applications';
import GetStarted from '../GetStarted';
import LibrarySelector from '../LibrarySelector';
import TypeDescription from '../TypeDescription';
import * as styles from './index.module.scss';
@ -108,7 +109,15 @@ const CreateForm = ({ onClose }: Props) => {
</form>
{!isGetStartedSkipped && createdApp && (
<Modal isOpen={isQuickStartGuideOpen} className={modalStyles.fullScreen}>
<GetStarted appName={createdApp.name} onClose={closeModal} />
<GetStarted
bannerComponent={<LibrarySelector />}
title={createdApp.name}
subtitle="applications.get_started.header_description"
type="application"
defaultSubtype={SupportedJavascriptLibraries.React}
onClose={closeModal}
onComplete={closeModal}
/>
</Modal>
)}
</ModalLayout>

View file

@ -1,134 +0,0 @@
@use '@/scss/underscore' as _;
.quickStartGuide {
display: flex;
flex-direction: column;
background-color: var(--color-main-background);
height: 100vh;
.header {
display: flex;
align-items: center;
background-color: var(--color-on-primary);
height: 64px;
padding: 0 _.unit(21) 0 _.unit(2);
button {
margin-left: _.unit(4);
}
.separator {
@include _.vertical-bar;
height: 20px;
margin: 0 _.unit(5) 0 _.unit(4);
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
padding: _.unit(6) 0 _.unit(20);
}
.card {
width: 858px;
padding: _.unit(5) _.unit(6);
display: flex;
flex-direction: column;
scroll-margin: _.unit(5);
&.selector {
display: block;
.title {
font: var(--font-title-large);
}
.subtitle {
font: var(--font-body-medium);
color: var(--color-component-text);
margin-top: _.unit(3);
}
.radioGroup {
margin-top: _.unit(6);
}
.radio {
border-radius: _.unit(2);
width: 240px;
max-width: unset;
}
&.folded {
display: flex;
flex-direction: row;
align-items: center;
flex: 0 0 56px;
background: var(--color-neutral-variant-90);
border-radius: _.unit(2);
padding: 0 _.unit(4);
font: var(--font-body-medium);
color: var(--color-component-text);
img {
margin-right: _.unit(3);
}
}
}
.cardHeader {
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
> svg {
fill: var(--color-icon);
}
.index {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: var(--color-primary);
background: var(--color-surface-5);
font: var(--font-title-medium);
margin-right: _.unit(4);
&.active {
color: var(--color-on-primary);
background: var(--color-primary);
}
&.completed {
background: var(--color-primary);
> svg {
fill: var(--color-on-primary);
}
}
}
}
.buttonWrapper {
display: flex;
justify-content: flex-end;
margin-top: _.unit(6);
}
}
.card + .card {
margin-top: _.unit(6);
}
.markdownContent {
margin-top: _.unit(6);
}
}

View file

@ -1,215 +0,0 @@
import classNames from 'classnames';
import i18next from 'i18next';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
// eslint-disable-next-line node/file-extension-in-import
import useSWRImmutable from 'swr/immutable';
import highFive from '@/assets/images/high-five.svg';
import tada from '@/assets/images/tada.svg';
import Button from '@/components/Button';
import Card from '@/components/Card';
import CardTitle from '@/components/CardTitle';
import DangerousRaw from '@/components/DangerousRaw';
import IconButton from '@/components/IconButton';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import Spacer from '@/components/Spacer';
import { ArrowDown, ArrowUp } from '@/icons/Arrow';
import Close from '@/icons/Close';
import Tick from '@/icons/Tick';
import { SupportedJavascriptLibraries } from '@/types/applications';
import { parseMarkdownWithYamlFrontmatter } from '@/utilities/markdown';
import * as styles from './index.module.scss';
type Props = {
appName: string;
onClose: () => void;
};
type DocumentFileNames = {
files: string[];
};
type Step = {
title?: string;
subtitle?: string;
metadata: string;
};
const GetStarted = ({ appName, onClose }: Props) => {
const [isLibrarySelectorFolded, setIsLibrarySelectorFolded] = useState(false);
const [libraryName, setLibraryName] = useState<string>(SupportedJavascriptLibraries.React);
const [activeStepIndex, setActiveStepIndex] = useState<number>(-1);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const publicPath = useMemo(
() => `/console/get-started/${libraryName}/${i18next.language}`,
[libraryName]
);
const { data: jsonData } = useSWRImmutable<DocumentFileNames>(`${publicPath}/index.json`);
const { data: steps } = useSWRImmutable<Step[]>(jsonData, async ({ files }: DocumentFileNames) =>
Promise.all(
files.map(async (fileName) => {
const response = await fetch(`${publicPath}/${fileName}`);
const markdownFile = await response.text();
return parseMarkdownWithYamlFrontmatter<Step>(markdownFile);
})
)
);
const stepReferences = useMemo(
() => Array.from({ length: steps?.length ?? 0 }).map(() => React.createRef<HTMLDivElement>()),
[steps?.length]
);
useEffect(() => {
if (activeStepIndex > -1) {
const activeStepReference = stepReferences[activeStepIndex];
activeStepReference?.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}
}, [activeStepIndex, stepReferences]);
const onClickFetchSampleProject = () => {
window.open(
`https://github.com/logto-io/js/tree/master/packages/${libraryName.toLowerCase()}-sample`,
'_blank'
);
};
const librarySelector = useMemo(
() => (
<Card className={classNames(styles.card, styles.selector)}>
<img src={highFive} alt="success" />
<div>
<div className={styles.title}>{t('applications.get_started.title')}</div>
<div className={styles.subtitle}>{t('applications.get_started.subtitle')}</div>
</div>
<RadioGroup
className={styles.radioGroup}
name="libraryName"
value={libraryName}
onChange={setLibraryName}
>
{Object.values(SupportedJavascriptLibraries).map((library) => (
<Radio key={library} className={styles.radio} title={library} value={library} />
))}
</RadioGroup>
<div className={styles.buttonWrapper}>
<Button
type="primary"
title="general.next"
onClick={() => {
setIsLibrarySelectorFolded(true);
setActiveStepIndex(0);
}}
/>
</div>
</Card>
),
[libraryName, t]
);
const librarySelectorFolded = useMemo(
() => (
<div className={classNames(styles.card, styles.selector, styles.folded)}>
<img src={tada} alt="Tada!" />
<span>
{t('applications.get_started.description_by_library', { library: libraryName })}
</span>
</div>
),
[libraryName, t]
);
return (
<div className={styles.quickStartGuide}>
<div className={styles.header}>
<IconButton size="large" onClick={onClose}>
<Close />
</IconButton>
<div className={styles.separator} />
<CardTitle
size="small"
title={<DangerousRaw>{appName}</DangerousRaw>}
subtitle="applications.get_started.header_description"
/>
<Spacer />
<Button type="plain" size="small" title="general.skip" onClick={onClose} />
<Button
type="outline"
title="admin_console.applications.get_started.get_sample_file"
onClick={onClickFetchSampleProject}
/>
</div>
<div className={styles.content}>
{isLibrarySelectorFolded ? librarySelectorFolded : librarySelector}
{steps?.map((step, index) => {
const { title, subtitle, metadata } = step;
const isExpanded = activeStepIndex === index;
const isCompleted = activeStepIndex > index;
const isLastStep = index === steps.length - 1;
// Steps in get-started must have "title" declared in the Yaml header of the markdown source file
if (!title) {
return null;
}
// TODO: add more styles to markdown renderer
// TODO: render form and input fields in steps
return (
<Card key={title} ref={stepReferences[index]} className={styles.card}>
<div
className={styles.cardHeader}
onClick={() => {
setIsLibrarySelectorFolded(true);
setActiveStepIndex(index);
}}
>
<div
className={classNames(
styles.index,
isExpanded && styles.active,
isCompleted && styles.completed
)}
>
{isCompleted ? <Tick /> : index + 1}
</div>
<CardTitle
size="medium"
title={<DangerousRaw>{title}</DangerousRaw>}
subtitle={<DangerousRaw>{subtitle}</DangerousRaw>}
/>
<Spacer />
<IconButton>{isExpanded ? <ArrowUp /> : <ArrowDown />}</IconButton>
</div>
{isExpanded && (
<>
<ReactMarkdown className={styles.markdownContent}>{metadata}</ReactMarkdown>
<div className={styles.buttonWrapper}>
<Button
type="primary"
title={`general.${isLastStep ? 'done' : 'next'}`}
onClick={() => {
if (isLastStep) {
// TO-DO: submit form
onClose();
} else {
setActiveStepIndex(index + 1);
}
}}
/>
</div>
</>
)}
</Card>
);
})}
</div>
</div>
);
};
export default GetStarted;

View file

@ -0,0 +1,50 @@
@use '@/scss/underscore' as _;
.card {
padding: _.unit(5) _.unit(6);
display: block;
flex-direction: column;
scroll-margin: _.unit(5);
.title {
font: var(--font-title-large);
}
.subtitle {
font: var(--font-body-medium);
color: var(--color-component-text);
margin-top: _.unit(3);
}
.radioGroup {
margin-top: _.unit(6);
}
.radio {
border-radius: _.unit(2);
width: 240px;
max-width: unset;
}
&.folded {
display: flex;
flex-direction: row;
align-items: center;
flex: 0 0 56px;
background: var(--color-neutral-variant-90);
border-radius: _.unit(2);
padding: 0 _.unit(4);
font: var(--font-body-medium);
color: var(--color-component-text);
img {
margin-right: _.unit(3);
}
}
.buttonWrapper {
display: flex;
justify-content: flex-end;
margin-top: _.unit(6);
}
}

View file

@ -0,0 +1,78 @@
import classNames from 'classnames';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import highFive from '@/assets/images/high-five.svg';
import tada from '@/assets/images/tada.svg';
import Button from '@/components/Button';
import Card from '@/components/Card';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import { SupportedJavascriptLibraries } from '@/types/applications';
import * as styles from './index.module.scss';
type Props = {
className?: string;
libraryName?: SupportedJavascriptLibraries;
onChange?: (value: string) => void;
onToggle?: () => void;
};
const LibrarySelector = ({
className,
libraryName = SupportedJavascriptLibraries.React,
onChange,
onToggle,
}: Props) => {
const [isFolded, setIsFolded] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const librarySelector = useMemo(
() => (
<Card className={classNames(styles.card, className)}>
<img src={highFive} alt="success" />
<div>
<div className={styles.title}>{t('applications.get_started.title')}</div>
<div className={styles.subtitle}>{t('applications.get_started.subtitle')}</div>
</div>
<RadioGroup
className={styles.radioGroup}
name="libraryName"
value={libraryName}
onChange={onChange}
>
{Object.values(SupportedJavascriptLibraries).map((library) => (
<Radio key={library} className={styles.radio} title={library} value={library} />
))}
</RadioGroup>
<div className={styles.buttonWrapper}>
<Button
type="primary"
title="general.next"
onClick={() => {
setIsFolded(true);
onToggle?.();
}}
/>
</div>
</Card>
),
[className, libraryName, onChange, onToggle, t]
);
const librarySelectorFolded = useMemo(
() => (
<div className={classNames(styles.card, styles.folded, className)}>
<img src={tada} alt="Tada!" />
<span>
{t('applications.get_started.description_by_library', { library: libraryName })}
</span>
</div>
),
[className, libraryName, t]
);
return isFolded ? librarySelectorFolded : librarySelector;
};
export default LibrarySelector;

View file

@ -0,0 +1,59 @@
@use '@/scss/underscore' as _;
.card {
padding: _.unit(5) _.unit(6);
display: flex;
flex-direction: column;
scroll-margin: _.unit(5);
.cardHeader {
display: flex;
flex-direction: row;
align-items: center;
cursor: pointer;
> svg {
fill: var(--color-icon);
}
.index {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: var(--color-primary);
background: var(--color-surface-5);
font: var(--font-title-medium);
margin-right: _.unit(4);
&.active {
color: var(--color-on-primary);
background: var(--color-primary);
}
&.completed {
background: var(--color-primary);
> svg {
fill: var(--color-on-primary);
}
}
}
}
.buttonWrapper {
display: flex;
justify-content: flex-end;
margin-top: _.unit(6);
}
}
.card + .card {
margin-top: _.unit(6);
}
.markdownContent {
margin-top: _.unit(6);
}

View file

@ -0,0 +1,88 @@
import classNames from 'classnames';
import React, { forwardRef, Ref } from 'react';
import ReactMarkdown from 'react-markdown';
import Button from '@/components/Button';
import Card from '@/components/Card';
import CardTitle from '@/components/CardTitle';
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 * 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;
isFinalStep: boolean;
onComplete?: () => void;
onNext?: () => void;
onToggle?: () => void;
};
const Step = (
{ data, index, isCompleted, isExpanded, isFinalStep, onComplete, onNext, onToggle }: Props,
ref?: Ref<HTMLDivElement>
) => {
const { title, subtitle, metadata } = data;
// Steps in get-started must have "title" declared in the Yaml header of the markdown source file
if (!title) {
return null;
}
// 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={classNames(
styles.index,
isExpanded && styles.active,
isCompleted && styles.completed
)}
>
{isCompleted ? <Tick /> : index + 1}
</div>
<CardTitle
size="medium"
title={<DangerousRaw>{title}</DangerousRaw>}
subtitle={<DangerousRaw>{subtitle}</DangerousRaw>}
/>
<Spacer />
<IconButton>{isExpanded ? <ArrowUp /> : <ArrowDown />}</IconButton>
</div>
{isExpanded && (
<>
<ReactMarkdown className={styles.markdownContent}>{metadata}</ReactMarkdown>
<div className={styles.buttonWrapper}>
<Button
type="primary"
title={`general.${isFinalStep ? 'done' : 'next'}`}
onClick={() => {
if (isFinalStep) {
onComplete?.();
} else {
onNext?.();
}
}}
/>
</div>
</>
)}
</Card>
);
};
export default forwardRef(Step);

View file

@ -0,0 +1,44 @@
import i18next from 'i18next';
import { useMemo } from 'react';
// eslint-disable-next-line node/file-extension-in-import
import useSWRImmutable from 'swr/immutable';
import { parseMarkdownWithYamlFrontmatter } from '@/utilities/markdown';
import { StepMetadata } from '../components/Step';
type DocumentFileNames = {
files: string[];
};
export type GetStartedType = 'application' | 'connector';
/**
* Fetch the markdown files for the given type and subtype.
* @param type 'application' or 'connector'
* @param subtype Application library name or connector name
* @returns List of step metadata including Yaml frontmatter and markdown content
*/
export const useGetStartedSteps = (type: GetStartedType, subtype?: string) => {
const subPath = subtype ? `/${subtype}` : '';
const publicPath = useMemo(
() => `/console/get-started/${type}${subPath}/${i18next.language}`,
[type, subPath]
);
const { data: jsonData } = useSWRImmutable<DocumentFileNames>(`${publicPath}/index.json`);
const { data: steps } = useSWRImmutable<StepMetadata[]>(
jsonData,
async ({ files }: DocumentFileNames) =>
Promise.all(
files.map(async (fileName) => {
const response = await fetch(`${publicPath}/${fileName}`);
const markdownFile = await response.text();
return parseMarkdownWithYamlFrontmatter<StepMetadata>(markdownFile);
})
)
);
return steps;
};

View file

@ -0,0 +1,43 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
background-color: var(--color-main-background);
height: 100vh;
.header {
display: flex;
align-items: center;
background-color: var(--color-on-primary);
height: 64px;
padding: 0 _.unit(21) 0 _.unit(2);
button {
margin-left: _.unit(4);
}
.separator {
@include _.vertical-bar;
height: 20px;
margin: 0 _.unit(5) 0 _.unit(4);
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
padding: _.unit(6) 0 _.unit(20);
> * {
width: 858px;
}
.banner {
margin-bottom: _.unit(6);
}
}
}

View file

@ -1,7 +1,129 @@
import React from 'react';
import { AdminConsoleKey } from '@logto/phrases';
import { Nullable } from '@silverhand/essentials';
import React, {
cloneElement,
isValidElement,
PropsWithChildren,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
const GetStarted = () => {
return <div>GetStarted</div>;
import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle';
import DangerousRaw from '@/components/DangerousRaw';
import IconButton from '@/components/IconButton';
import Spacer from '@/components/Spacer';
import Close from '@/icons/Close';
import Step from './components/Step';
import { GetStartedType, useGetStartedSteps } from './hooks';
import * as styles from './index.module.scss';
type Props = PropsWithChildren<{
title: string;
subtitle?: AdminConsoleKey;
type: GetStartedType;
/** `subtype` can be an actual type of an application or connector.
* e.g. React, Angular, Vue, etc. for application. Or Github, WeChat, etc. for connector.
*/
defaultSubtype?: string;
bannerComponent?: ReactNode;
onClose?: () => void;
onComplete?: () => void;
onToggleSteps?: () => void;
}>;
const onClickFetchSampleProject = (projectName: string) => {
const sampleUrl = `https://github.com/logto-io/js/tree/master/packages/${projectName}-sample`;
window.open(sampleUrl, '_blank');
};
const GetStarted = ({
title,
subtitle,
type,
defaultSubtype,
bannerComponent,
onClose,
onComplete,
onToggleSteps,
}: Props) => {
const [subtype, setSubtype] = useState(defaultSubtype);
const [activeStepIndex, setActiveStepIndex] = useState<number>(-1);
const steps = useGetStartedSteps(type, subtype) ?? [];
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' });
}
}, [activeStepIndex, stepReferences]);
return (
<div className={styles.container}>
<div className={styles.header}>
<IconButton size="large" onClick={onClose}>
<Close />
</IconButton>
<div className={styles.separator} />
<CardTitle size="small" title={<DangerousRaw>{title}</DangerousRaw>} subtitle={subtitle} />
<Spacer />
<Button type="plain" size="small" title="general.skip" onClick={onClose} />
{subtype && (
<Button
type="outline"
title="admin_console.applications.get_started.get_sample_file"
onClick={() => {
onClickFetchSampleProject(subtype);
}}
/>
)}
</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;
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?.();
}}
/>
);
})}
</div>
</div>
);
};
export default GetStarted;