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 Callback from './pages/Callback';
|
||||||
import ConnectorDetails from './pages/ConnectorDetails';
|
import ConnectorDetails from './pages/ConnectorDetails';
|
||||||
import Connectors from './pages/Connectors';
|
import Connectors from './pages/Connectors';
|
||||||
import GetStarted from './pages/GetStarted';
|
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
import UserDetails from './pages/UserDetails';
|
import UserDetails from './pages/UserDetails';
|
||||||
import Users from './pages/Users';
|
import Users from './pages/Users';
|
||||||
|
@ -44,7 +43,6 @@ const Main = () => {
|
||||||
<Route path="callback" element={<Callback />} />
|
<Route path="callback" element={<Callback />} />
|
||||||
<Route element={<AppContent theme="light" />}>
|
<Route element={<AppContent theme="light" />}>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
<Route path="get-started" element={<GetStarted />} />
|
|
||||||
<Route path="applications">
|
<Route path="applications">
|
||||||
<Route index element={<Applications />} />
|
<Route index element={<Applications />} />
|
||||||
<Route path=":id">
|
<Route path=":id">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import classNames from 'classnames';
|
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';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Card = (props: Props, reference?: LegacyRef<HTMLDivElement>) => {
|
const Card = (props: Props, reference?: Ref<HTMLDivElement>) => {
|
||||||
const { children, className } = props;
|
const { children, className } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -11,10 +11,11 @@ import ModalLayout from '@/components/ModalLayout';
|
||||||
import RadioGroup, { Radio } from '@/components/RadioGroup';
|
import RadioGroup, { Radio } from '@/components/RadioGroup';
|
||||||
import TextInput from '@/components/TextInput';
|
import TextInput from '@/components/TextInput';
|
||||||
import useApi, { RequestError } from '@/hooks/use-api';
|
import useApi, { RequestError } from '@/hooks/use-api';
|
||||||
|
import GetStarted from '@/pages/GetStarted';
|
||||||
import * as modalStyles from '@/scss/modal.module.scss';
|
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 TypeDescription from '../TypeDescription';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -108,7 +109,15 @@ const CreateForm = ({ onClose }: Props) => {
|
||||||
</form>
|
</form>
|
||||||
{!isGetStartedSkipped && createdApp && (
|
{!isGetStartedSkipped && createdApp && (
|
||||||
<Modal isOpen={isQuickStartGuideOpen} className={modalStyles.fullScreen}>
|
<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>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</ModalLayout>
|
</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 = () => {
|
import Button from '@/components/Button';
|
||||||
return <div>GetStarted</div>;
|
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;
|
export default GetStarted;
|
||||||
|
|
Loading…
Add table
Reference in a new issue