0
Fork 0
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:
Charles Zhao 2022-04-12 21:30:25 +08:00
parent f9b384a312
commit eeeb80ba4b
No known key found for this signature in database
GPG key ID: 4858774754C92DF2
13 changed files with 322 additions and 73 deletions

View file

@ -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;
};

View file

@ -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>
);
};

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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);
}}
/>
</>
);
};

View file

@ -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);
}

View file

@ -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)}

View file

@ -41,3 +41,7 @@
}
}
}
.markdownContent {
margin-top: _.unit(6);
}

View file

@ -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>

View file

@ -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%);
}

View file

@ -4,9 +4,9 @@ The GitHub connector provides the ability to easily integrate GitHubs 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

View file

@ -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',

View file

@ -213,6 +213,9 @@ const translation = {
sms: '设置短信服务商',
social: '添加社会化登录',
},
get_started: {
subtitle: '请参考下列分步指南,配置您的 connector或点击按钮获取示例配置文件',
},
},
connector_details: {
back_to_connectors: '返回连接器',