mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(console): connectors guide page
This commit is contained in:
parent
f9b384a312
commit
eeeb80ba4b
13 changed files with 322 additions and 73 deletions
|
@ -14,7 +14,7 @@ import * as styles from './index.module.scss';
|
|||
type Props = {
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ConnectorDTO, ConnectorType } from '@logto/schemas';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import ReactModal from 'react-modal';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import Modal from 'react-modal';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
|
@ -11,24 +11,30 @@ import UnnamedTrans from '@/components/UnnamedTrans';
|
|||
import { RequestError } from '@/hooks/use-api';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import GetStartedModal from '../GetStartedModal';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
type?: ConnectorType;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const CreateForm = ({ isOpen, onClose, type }: Props) => {
|
||||
const [connectorId, setConnectorId] = useState<string>('');
|
||||
const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
|
||||
const { data, error } = useSWR<ConnectorDTO[], RequestError>('/api/connectors');
|
||||
const isLoading = !data && !error;
|
||||
const [activeConnectorId, setActiveConnectorId] = useState<string>();
|
||||
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setConnectorId('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
const connectors = useMemo(
|
||||
() => data?.filter((connector) => connector.metadata.type === type),
|
||||
[data, type]
|
||||
);
|
||||
|
||||
const activeConnector = useMemo(
|
||||
() => connectors?.find(({ id }) => id === activeConnectorId),
|
||||
[activeConnectorId, connectors]
|
||||
);
|
||||
|
||||
const cardTitle = useMemo(() => {
|
||||
if (type === ConnectorType.Email) {
|
||||
|
@ -42,14 +48,14 @@ const CreateForm = ({ isOpen, onClose, type }: Props) => {
|
|||
return 'connectors.setup_title.social';
|
||||
}, [type]);
|
||||
|
||||
const connectors = useMemo(
|
||||
() => data?.filter((connector) => connector.metadata.type === type),
|
||||
[data, type]
|
||||
);
|
||||
const closeModal = () => {
|
||||
setIsGetStartedModalOpen(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
<Modal
|
||||
isOpen={isFormOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
>
|
||||
|
@ -59,9 +65,9 @@ const CreateForm = ({ isOpen, onClose, type }: Props) => {
|
|||
<Button
|
||||
title="admin_console.connectors.next"
|
||||
type="primary"
|
||||
disabled={!connectorId}
|
||||
disabled={!activeConnectorId}
|
||||
onClick={() => {
|
||||
console.log("Charles's job.");
|
||||
setIsGetStartedModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
@ -72,35 +78,32 @@ const CreateForm = ({ isOpen, onClose, type }: Props) => {
|
|||
{isLoading && 'Loading...'}
|
||||
{error && error}
|
||||
{connectors && (
|
||||
<RadioGroup
|
||||
name="connector"
|
||||
value={connectorId}
|
||||
onChange={(value) => {
|
||||
setConnectorId(value);
|
||||
}}
|
||||
>
|
||||
{connectors.map((connector) => (
|
||||
<Radio key={connector.id} value={connector.id} className={styles.connector}>
|
||||
<RadioGroup name="connector" value={activeConnectorId} onChange={setActiveConnectorId}>
|
||||
{connectors.map(({ id, metadata: { name, logo, description } }) => (
|
||||
<Radio key={id} value={id} className={styles.connector}>
|
||||
<div className={styles.logo}>
|
||||
{connector.metadata.logo.startsWith('http') ? (
|
||||
<img src={connector.metadata.logo} />
|
||||
) : (
|
||||
<ImagePlaceholder size={32} />
|
||||
)}
|
||||
{logo.startsWith('http') ? <img src={logo} /> : <ImagePlaceholder size={32} />}
|
||||
</div>
|
||||
<div className={styles.name}>
|
||||
<UnnamedTrans resource={connector.metadata.name} />{' '}
|
||||
<UnnamedTrans resource={name} />
|
||||
</div>
|
||||
<div className={styles.connectorId}>{connector.id}</div>
|
||||
<div className={styles.connectorId}>{id}</div>
|
||||
<div className={styles.description}>
|
||||
<UnnamedTrans resource={connector.metadata.description} />
|
||||
<UnnamedTrans resource={description} />
|
||||
</div>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
{activeConnector && (
|
||||
<GetStartedModal
|
||||
connector={activeConnector}
|
||||
isOpen={isGetStartedModalOpen}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
</ModalLayout>
|
||||
</ReactModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
@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;
|
||||
overflow: hidden;
|
||||
padding: _.unit(6) _.unit(15);
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
margin: 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.readme {
|
||||
background-color: var(--color-surface-variant);
|
||||
border-radius: 16px;
|
||||
padding: _.unit(6);
|
||||
}
|
||||
|
||||
form + div {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,166 @@
|
|||
import { ConnectorDTO, ConnectorType } from '@logto/schemas';
|
||||
import i18next from 'i18next';
|
||||
import React, { useState } from 'react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
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 useApi from '@/hooks/use-api';
|
||||
import Close from '@/icons/Close';
|
||||
import SenderTester from '@/pages/ConnectorDetails/components/SenderTester';
|
||||
import Step from '@/pages/GetStarted/components/Step';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { GetStartedForm } from '@/types/get-started';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
connector: ConnectorDTO;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: (data: GetStartedForm) => Promise<void>;
|
||||
};
|
||||
|
||||
const onClickFetchSampleProject = (name: string) => {
|
||||
const sampleUrl = `https://github.com/logto-io/js/tree/master/packages/connectors/${name}-sample`;
|
||||
window.open(sampleUrl, '_blank');
|
||||
};
|
||||
|
||||
const GetStartedModal = ({ connector, isOpen, onClose }: Props) => {
|
||||
const api = useApi();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
id: connectorId,
|
||||
type: connectorType,
|
||||
metadata: { name, configTemplate, readme },
|
||||
} = connector;
|
||||
|
||||
const locale = i18next.language;
|
||||
const connectorName = name[locale] ?? name.en;
|
||||
const isSocialConnector =
|
||||
connectorType !== ConnectorType.SMS && connectorType !== ConnectorType.Email;
|
||||
const [activeStepIndex, setActiveStepIndex] = useState<number>(0);
|
||||
const methods = useForm<GetStartedForm>({ reValidateMode: 'onBlur' });
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
} = methods;
|
||||
|
||||
const onSubmit = handleSubmit(async ({ connectorConfigJson }) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(connectorConfigJson) as JSON;
|
||||
await api
|
||||
.patch(`/api/connectors/${connectorId}`, {
|
||||
json: { config },
|
||||
})
|
||||
.json<ConnectorDTO>();
|
||||
|
||||
setActiveStepIndex(activeStepIndex + 1);
|
||||
toast.success(t('connector_details.save_success'));
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SyntaxError) {
|
||||
toast.error(t('connector_details.save_error_json_parse_error'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} className={modalStyles.fullScreen}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<IconButton size="large" onClick={onClose}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
<div className={styles.separator} />
|
||||
<CardTitle
|
||||
size="small"
|
||||
title={<DangerousRaw>{connectorName}</DangerousRaw>}
|
||||
subtitle="connectors.get_started.subtitle"
|
||||
/>
|
||||
<Spacer />
|
||||
<Button type="plain" size="small" title="general.skip" onClick={onClose} />
|
||||
{connectorName && (
|
||||
<Button
|
||||
type="outline"
|
||||
title="admin_console.get_started.get_sample_file"
|
||||
onClick={() => {
|
||||
onClickFetchSampleProject(connectorName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<ReactMarkdown
|
||||
className={styles.readme}
|
||||
components={{
|
||||
code: ({ node, inline, className, children, ...props }) => {
|
||||
const [, codeBlockType] = /language-(\w+)/.exec(className ?? '') ?? [];
|
||||
|
||||
return inline ? (
|
||||
<code {...props}>{children}</code>
|
||||
) : (
|
||||
<CodeEditor isReadonly language={codeBlockType} value={String(children)} />
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{readme}
|
||||
</ReactMarkdown>
|
||||
<div>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Step
|
||||
title="Enter your json here"
|
||||
subtitle="Lorem ipsum dolor sit amet, consectetuer adipiscing elit."
|
||||
index={0}
|
||||
isActive={activeStepIndex === 0}
|
||||
isComplete={activeStepIndex > 0}
|
||||
isFinalStep={isSocialConnector}
|
||||
buttonHtmlType="submit"
|
||||
>
|
||||
<Controller
|
||||
name="connectorConfigJson"
|
||||
control={control}
|
||||
defaultValue={configTemplate}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CodeEditor language="json" value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</Step>
|
||||
</form>
|
||||
</FormProvider>
|
||||
{!isSocialConnector && (
|
||||
<Step
|
||||
isFinalStep
|
||||
title="Test your message"
|
||||
subtitle="Lorem ipsum dolor sit amet, consectetuer adipiscing elit."
|
||||
index={1}
|
||||
isActive={activeStepIndex === 1}
|
||||
isComplete={activeStepIndex > 1}
|
||||
buttonHtmlType="button"
|
||||
onNext={onClose}
|
||||
>
|
||||
<SenderTester connectorType={connectorType} />
|
||||
</Step>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GetStartedModal;
|
|
@ -125,15 +125,13 @@ const Connectors = () => {
|
|||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
{data && (
|
||||
<CreateForm
|
||||
isOpen={Boolean(createType)}
|
||||
type={createType}
|
||||
onClose={() => {
|
||||
setCreateType(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CreateForm
|
||||
isOpen={Boolean(createType)}
|
||||
type={createType}
|
||||
onClose={() => {
|
||||
setCreateType(undefined);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -50,10 +50,12 @@
|
|||
}
|
||||
|
||||
.content {
|
||||
margin-top: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&.expanded {
|
||||
margin-top: _.unit(6);
|
||||
max-height: 9999px;
|
||||
overflow: unset;
|
||||
}
|
||||
|
@ -63,7 +65,3 @@
|
|||
.card + .card {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
|
||||
.markdownContent {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import React, {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { CodeProps } from 'react-markdown/lib/ast-to-react.js';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
|
@ -12,23 +20,33 @@ 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';
|
||||
|
||||
type Props = {
|
||||
data: StepMetadata;
|
||||
type Props = PropsWithChildren<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
isComplete: boolean;
|
||||
isFinalStep: boolean;
|
||||
buttonHtmlType: 'submit' | 'button';
|
||||
onNext?: () => void;
|
||||
};
|
||||
}>;
|
||||
|
||||
const Step = ({ data, index, isActive, isComplete, isFinalStep, onNext }: Props) => {
|
||||
const Step = ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
index,
|
||||
isActive,
|
||||
isComplete,
|
||||
isFinalStep,
|
||||
buttonHtmlType,
|
||||
onNext,
|
||||
}: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { title, subtitle, metadata } = data;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToStep = useCallback(() => {
|
||||
|
@ -61,11 +79,6 @@ const Step = ({ data, index, isActive, isComplete, isFinalStep, onNext }: Props)
|
|||
[onError]
|
||||
);
|
||||
|
||||
// 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
|
||||
return (
|
||||
<Card key={title} ref={ref} className={styles.card}>
|
||||
|
@ -93,12 +106,10 @@ const Step = ({ data, index, isActive, isComplete, isFinalStep, onNext }: Props)
|
|||
<IconButton>{isExpanded ? <ArrowUp /> : <ArrowDown />}</IconButton>
|
||||
</div>
|
||||
<div className={classNames(styles.content, isExpanded && styles.expanded)}>
|
||||
<ReactMarkdown className={styles.markdownContent} components={memoizedComponents}>
|
||||
{metadata}
|
||||
</ReactMarkdown>
|
||||
{isValidElement(children) && cloneElement(children, { components: memoizedComponents })}
|
||||
<div className={styles.buttonWrapper}>
|
||||
<Button
|
||||
htmlType={isFinalStep ? 'submit' : 'button'}
|
||||
htmlType={buttonHtmlType}
|
||||
type="primary"
|
||||
title={`general.${isFinalStep ? 'done' : 'next'}`}
|
||||
onClick={conditional(!isFinalStep && onNext)}
|
||||
|
|
|
@ -41,3 +41,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.markdownContent {
|
||||
margin-top: _.unit(6);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { AdminConsoleKey } from '@logto/phrases';
|
||||
import React, { cloneElement, isValidElement, PropsWithChildren, ReactNode, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import CardTitle from '@/components/CardTitle';
|
||||
|
@ -88,21 +89,30 @@ const GetStarted = ({
|
|||
setActiveStepIndex(0);
|
||||
},
|
||||
})}
|
||||
{steps.map((step, index) => {
|
||||
{steps.map(({ title, subtitle, metadata }, index) => {
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
const isFinalStep = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<Step
|
||||
key={step.title}
|
||||
data={step}
|
||||
key={title}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
index={index}
|
||||
isActive={activeStepIndex === index}
|
||||
isComplete={activeStepIndex > index}
|
||||
isFinalStep={isFinalStep}
|
||||
buttonHtmlType={isFinalStep ? 'submit' : 'button'}
|
||||
onNext={() => {
|
||||
setActiveStepIndex(index + 1);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{metadata && (
|
||||
<ReactMarkdown className={styles.markdownContent}>{metadata}</ReactMarkdown>
|
||||
)}
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</form>
|
||||
|
|
|
@ -83,6 +83,7 @@
|
|||
--color-code-comment: #66bb6a;
|
||||
--color-surface-3: #e3dff5;
|
||||
--color-surface-5: #dfd9f5;
|
||||
--color-surface-variant: var(--color-neutral-variant-90);
|
||||
--shadow-light-s1: 0 2px 8px rgba(0, 0, 0, 8%);
|
||||
--shadow-light-s2: 0 4px 12px rgba(0, 0, 0, 12%);
|
||||
}
|
||||
|
|
|
@ -4,9 +4,9 @@ The GitHub connector provides the ability to easily integrate GitHub’s OAuth A
|
|||
|
||||
Official guide on OAuth App: [https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app](https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app)
|
||||
|
||||
## Prerequests
|
||||
## Prerequisites
|
||||
|
||||
A GitHub account, both personal and organazation are OK.
|
||||
A GitHub account, both personal and organization are OK.
|
||||
|
||||
## Configuration On GitHub
|
||||
|
||||
|
|
|
@ -215,6 +215,10 @@ const translation = {
|
|||
sms: 'Setup SMS sender',
|
||||
social: 'Add social connector',
|
||||
},
|
||||
get_started: {
|
||||
subtitle:
|
||||
'A step by step guide to integrate your connector or get a sample configured with your account settings',
|
||||
},
|
||||
},
|
||||
connector_details: {
|
||||
back_to_connectors: 'Back to Connectors',
|
||||
|
|
|
@ -213,6 +213,9 @@ const translation = {
|
|||
sms: '设置短信服务商',
|
||||
social: '添加社会化登录',
|
||||
},
|
||||
get_started: {
|
||||
subtitle: '请参考下列分步指南,配置您的 connector,或点击按钮获取示例配置文件',
|
||||
},
|
||||
},
|
||||
connector_details: {
|
||||
back_to_connectors: '返回连接器',
|
||||
|
|
Loading…
Add table
Reference in a new issue