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:
commit
08dd968127
24 changed files with 501 additions and 359 deletions
|
@ -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">
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
44
packages/console/src/pages/GetStarted/hooks/index.ts
Normal file
44
packages/console/src/pages/GetStarted/hooks/index.ts
Normal 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;
|
||||
};
|
43
packages/console/src/pages/GetStarted/index.module.scss
Normal file
43
packages/console/src/pages/GetStarted/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue