0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

refactor(console): add dedicated routes to AC guides (#3358)

This commit is contained in:
Charles Zhao 2023-03-13 16:01:12 +08:00 committed by GitHub
parent b1b5200876
commit d2769823da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 362 additions and 334 deletions

View file

@ -55,6 +55,7 @@ const ConsoleContent = () => {
<Route path="applications">
<Route index element={<Applications />} />
<Route path="create" element={<Applications />} />
<Route path=":id/guide" element={<ApplicationDetails />} />
<Route path=":id" element={<ApplicationDetails />} />
</Route>
<Route path="api-resources">
@ -77,6 +78,7 @@ const ConsoleContent = () => {
<Route index element={<Navigate replace to={ConnectorsTabs.Passwordless} />} />
<Route path=":tab" element={<Connectors />} />
<Route path=":tab/create/:createType" element={<Connectors />} />
<Route path=":tab/guide/:factoryId" element={<Connectors />} />
<Route path=":tab/:connectorId" element={<ConnectorDetails />} />
</Route>
<Route path="users">

View file

@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import useSWR from 'swr';
import Back from '@/assets/images/back.svg';
@ -42,6 +42,8 @@ const mapToUriOriginFormatArrays = (value?: string[]) =>
const ApplicationDetails = () => {
const { id } = useParams();
const { pathname } = useLocation();
const isGuideView = !!id && pathname === `/applications/${id}/guide`;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error, mutate } = useSWR<ApplicationResponse, RequestError>(
id && `api/applications/${id}`
@ -224,6 +226,14 @@ const ApplicationDetails = () => {
</>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
{isGuideView && (
<Guide
app={data}
onClose={(id) => {
navigate(`/applications/${id}`);
}}
/>
)}
</div>
);
};

View file

@ -3,27 +3,24 @@ import { ApplicationType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button';
import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import * as modalStyles from '@/scss/modal.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import Guide from '../Guide';
import TypeDescription from '../TypeDescription';
import * as styles from './index.module.scss';
const defaultAppName = 'My App';
const ApplicationsPlaceholder = () => {
type Props = {
onCreate: (createdApp: Application) => void;
};
const ApplicationsPlaceholder = ({ onCreate }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const [isCreating, setIsCreating] = useState(false);
const [createdApplication, setCreatedApplication] = useState<Application>();
const isGetStartedModalOpen = Boolean(createdApplication);
const api = useApi();
const { updateConfigs } = useConfigs();
@ -41,28 +38,18 @@ const ApplicationsPlaceholder = () => {
try {
const createdApp = await api.post('api/applications', { json: payload }).json<Application>();
setCreatedApplication(createdApp);
void updateConfigs({
applicationCreated: true,
...conditional(
createdApp.type === ApplicationType.MachineToMachine && { m2mApplicationCreated: true }
),
});
onCreate(createdApp);
} finally {
setIsCreating(false);
}
};
const closeGuideModal = () => {
if (!createdApplication) {
return;
}
navigate(`/applications/${createdApplication.id}`);
setCreatedApplication(undefined);
};
return (
<div className={styles.placeholder}>
<div className={styles.title}>{t('applications.placeholder_title')}</div>
@ -81,23 +68,13 @@ const ApplicationsPlaceholder = () => {
className={styles.createButton}
disabled={isCreating}
title="general.create"
onClick={async () => {
await handleCreate(type);
onClick={() => {
void handleCreate(type);
}}
/>
</div>
))}
</div>
{createdApplication && (
<Modal
shouldCloseOnEsc
isOpen={isGetStartedModalOpen}
className={modalStyles.fullScreen}
onRequestClose={closeGuideModal}
>
<Guide app={createdApplication} onClose={closeGuideModal} />
</Modal>
)}
</div>
);
};

View file

@ -1,7 +1,6 @@
import type { Application } from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useState } from 'react';
import { useController, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
@ -16,7 +15,6 @@ import useConfigs from '@/hooks/use-configs';
import * as modalStyles from '@/scss/modal.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import Guide from '../Guide';
import TypeDescription from '../TypeDescription';
import * as styles from './index.module.scss';
@ -27,13 +25,12 @@ type FormData = {
};
type Props = {
isOpen: boolean;
onClose?: (createdApp?: Application) => void;
};
const CreateForm = ({ onClose }: Props) => {
const CreateForm = ({ isOpen, onClose }: Props) => {
const { updateConfigs } = useConfigs();
const [createdApp, setCreatedApp] = useState<Application>();
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
const {
handleSubmit,
control,
@ -46,10 +43,9 @@ const CreateForm = ({ onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useApi();
const closeModal = () => {
setIsGetStartedModalOpen(false);
onClose?.(createdApp);
};
if (!isOpen) {
return null;
}
const onSubmit = handleSubmit(async (data) => {
if (isSubmitting) {
@ -57,83 +53,82 @@ const CreateForm = ({ onClose }: Props) => {
}
const createdApp = await api.post('api/applications', { json: data }).json<Application>();
setCreatedApp(createdApp);
setIsGetStartedModalOpen(true);
void updateConfigs({
applicationCreated: true,
...conditional(
createdApp.type === ApplicationType.MachineToMachine && { m2mApplicationCreated: true }
),
});
onClose?.(createdApp);
});
return (
<ModalLayout
title="applications.create"
subtitle="applications.subtitle"
size="large"
footer={
<Button
isLoading={isSubmitting}
htmlType="submit"
title="applications.create"
size="large"
type="primary"
onClick={onSubmit}
/>
}
onClose={onClose}
<Modal
shouldCloseOnEsc
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose?.();
}}
>
<form>
<FormField title="applications.select_application_type">
<RadioGroup
ref={ref}
className={styles.radioGroup}
name={name}
value={value}
type="card"
onChange={onChange}
>
{Object.values(ApplicationType).map((value) => (
<Radio key={value} value={value}>
<TypeDescription
type={value}
title={t(`${applicationTypeI18nKey[value]}.title`)}
subtitle={t(`${applicationTypeI18nKey[value]}.subtitle`)}
description={t(`${applicationTypeI18nKey[value]}.description`)}
/>
</Radio>
))}
</RadioGroup>
{errors.type?.type === 'required' && (
<div className={styles.error}>{t('applications.no_application_type_selected')}</div>
)}
</FormField>
<FormField isRequired title="applications.application_name">
<TextInput
{...register('name', { required: true })}
placeholder={t('applications.application_name_placeholder')}
hasError={Boolean(errors.name)}
<ModalLayout
title="applications.create"
subtitle="applications.subtitle"
size="large"
footer={
<Button
isLoading={isSubmitting}
htmlType="submit"
title="applications.create"
size="large"
type="primary"
onClick={onSubmit}
/>
</FormField>
<FormField title="applications.application_description">
<TextInput
{...register('description')}
placeholder={t('applications.application_description_placeholder')}
/>
</FormField>
</form>
{createdApp && (
<Modal
shouldCloseOnEsc
isOpen={isGetStartedModalOpen}
className={modalStyles.fullScreen}
onRequestClose={closeModal}
>
<Guide app={createdApp} onClose={closeModal} />
</Modal>
)}
</ModalLayout>
}
onClose={onClose}
>
<form>
<FormField title="applications.select_application_type">
<RadioGroup
ref={ref}
className={styles.radioGroup}
name={name}
value={value}
type="card"
onChange={onChange}
>
{Object.values(ApplicationType).map((value) => (
<Radio key={value} value={value}>
<TypeDescription
type={value}
title={t(`${applicationTypeI18nKey[value]}.title`)}
subtitle={t(`${applicationTypeI18nKey[value]}.subtitle`)}
description={t(`${applicationTypeI18nKey[value]}.description`)}
/>
</Radio>
))}
</RadioGroup>
{errors.type?.type === 'required' && (
<div className={styles.error}>{t('applications.no_application_type_selected')}</div>
)}
</FormField>
<FormField isRequired title="applications.application_name">
<TextInput
{...register('name', { required: true })}
placeholder={t('applications.application_name_placeholder')}
hasError={Boolean(errors.name)}
/>
</FormField>
<FormField title="applications.application_description">
<TextInput
{...register('description')}
placeholder={t('applications.application_description_placeholder')}
/>
</FormField>
</form>
</ModalLayout>
</Modal>
);
};

View file

@ -4,12 +4,14 @@ import type { Optional } from '@silverhand/essentials';
import i18next from 'i18next';
import type { MDXProps } from 'mdx/types';
import type { LazyExoticComponent } from 'react';
import { useContext, cloneElement, lazy, Suspense, useEffect, useState } from 'react';
import { useEffect, useContext, cloneElement, lazy, Suspense, useState } from 'react';
import Modal from 'react-modal';
import CodeEditor from '@/components/CodeEditor';
import TextLink from '@/components/TextLink';
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
import DetailsSummary from '@/mdx-components/DetailsSummary';
import * as modalStyles from '@/scss/modal.module.scss';
import type { SupportedSdk } from '@/types/applications';
import { applicationTypeAndSdkTypeMappings } from '@/types/applications';
@ -19,9 +21,9 @@ import StepsSkeleton from '../StepsSkeleton';
import * as styles from './index.module.scss';
type Props = {
app: Application;
app?: Application;
isCompact?: boolean;
onClose: () => void;
onClose: (id: string) => void;
};
const Guides: Record<string, LazyExoticComponent<(props: MDXProps) => JSX.Element>> = {
@ -50,83 +52,93 @@ const Guides: Record<string, LazyExoticComponent<(props: MDXProps) => JSX.Elemen
};
const Guide = ({ app, isCompact, onClose }: Props) => {
const { id: appId, secret: appSecret, name: appName, type: appType, oidcClientMetadata } = app;
const sdks = applicationTypeAndSdkTypeMappings[appType];
const [selectedSdk, setSelectedSdk] = useState<Optional<SupportedSdk>>(sdks[0]);
const sdks = app && applicationTypeAndSdkTypeMappings[app.type];
const [selectedSdk, setSelectedSdk] = useState<Optional<SupportedSdk>>();
const [activeStepIndex, setActiveStepIndex] = useState(-1);
const { userEndpoint } = useContext(AppEndpointsContext);
// Directly close guide if no SDK available
useEffect(() => {
if (!selectedSdk) {
onClose();
if (sdks?.length) {
setSelectedSdk(sdks[0]);
}
}, [onClose, selectedSdk]);
}, [sdks]);
if (!selectedSdk) {
if (!app || !sdks || !selectedSdk) {
return null;
}
const { id: appId, secret: appSecret, name: appName, oidcClientMetadata } = app;
const locale = i18next.language;
const guideI18nKey = `${selectedSdk}_${locale}`.toLowerCase();
const GuideComponent = Guides[guideI18nKey] ?? Guides[selectedSdk.toLowerCase()];
return (
<div className={styles.container}>
<GuideHeader
appName={appName}
selectedSdk={selectedSdk}
isCompact={isCompact}
onClose={onClose}
/>
<div className={styles.content}>
{cloneElement(<SdkSelector sdks={sdks} selectedSdk={selectedSdk} />, {
className: styles.banner,
isCompact,
onChange: setSelectedSdk,
onToggle: () => {
setActiveStepIndex(0);
},
})}
<MDXProvider
components={{
code: ({ className, children }) => {
const [, language] = /language-(\w+)/.exec(className ?? '') ?? [];
const closeModal = () => {
onClose(appId);
};
return language ? (
<CodeEditor isReadonly language={language} value={String(children)} />
) : (
<code>{String(children)}</code>
);
return (
<Modal
shouldCloseOnEsc
isOpen={Boolean(app)}
className={modalStyles.fullScreen}
onRequestClose={closeModal}
>
<div className={styles.container}>
<GuideHeader
appName={appName}
selectedSdk={selectedSdk}
isCompact={isCompact}
onClose={closeModal}
/>
<div className={styles.content}>
{cloneElement(<SdkSelector sdks={sdks} selectedSdk={selectedSdk} />, {
className: styles.banner,
isCompact,
onChange: setSelectedSdk,
onToggle: () => {
setActiveStepIndex(0);
},
a: ({ children, ...props }) => (
<TextLink {...props} target="_blank" rel="noopener noreferrer">
{children}
</TextLink>
),
details: DetailsSummary,
}}
>
<Suspense fallback={<StepsSkeleton />}>
{GuideComponent && (
<GuideComponent
appId={appId}
appSecret={appSecret}
endpoint={userEndpoint}
redirectUris={oidcClientMetadata.redirectUris}
postLogoutRedirectUris={oidcClientMetadata.postLogoutRedirectUris}
activeStepIndex={activeStepIndex}
isCompact={isCompact}
onNext={(nextIndex: number) => {
setActiveStepIndex(nextIndex);
}}
onComplete={onClose}
/>
)}
</Suspense>
</MDXProvider>
})}
<MDXProvider
components={{
code: ({ className, children }) => {
const [, language] = /language-(\w+)/.exec(className ?? '') ?? [];
return language ? (
<CodeEditor isReadonly language={language} value={String(children)} />
) : (
<code>{String(children)}</code>
);
},
a: ({ children, ...props }) => (
<TextLink {...props} target="_blank" rel="noopener noreferrer">
{children}
</TextLink>
),
details: DetailsSummary,
}}
>
<Suspense fallback={<StepsSkeleton />}>
{GuideComponent && (
<GuideComponent
appId={appId}
appSecret={appSecret}
endpoint={userEndpoint}
redirectUris={oidcClientMetadata.redirectUris}
postLogoutRedirectUris={oidcClientMetadata.postLogoutRedirectUris}
activeStepIndex={activeStepIndex}
isCompact={isCompact}
onNext={(nextIndex: number) => {
setActiveStepIndex(nextIndex);
}}
onComplete={closeModal}
/>
)}
</Suspense>
</MDXProvider>
</div>
</div>
</div>
</Modal>
);
};

View file

@ -1,6 +1,6 @@
import type { Application } from '@logto/schemas';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
@ -15,7 +15,6 @@ import Table from '@/components/Table';
import { defaultPageSize } from '@/consts';
import type { RequestError } from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
import * as modalStyles from '@/scss/modal.module.scss';
import * as resourcesStyles from '@/scss/resources.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import { buildUrl } from '@/utils/url';
@ -28,11 +27,12 @@ const pageSize = defaultPageSize;
const applicationsPathname = '/applications';
const createApplicationPathname = `${applicationsPathname}/create`;
const buildDetailsPathname = (id: string) => `${applicationsPathname}/${id}`;
const buildGuidePathname = (id: string) => `${buildDetailsPathname(id)}/guide`;
const Applications = () => {
const navigate = useNavigate();
const { pathname, search } = useLocation();
const isCreateNew = pathname === createApplicationPathname;
const isShowingCreationForm = pathname === createApplicationPathname;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [{ page }, updateSearchParameters] = useSearchParametersWatcher({
@ -49,6 +49,13 @@ const Applications = () => {
const isLoading = !data && !error;
const [applications, totalCount] = data ?? [];
const mutateApplicationList = useCallback(
async (newApp: Application) => {
await mutate([[newApp, ...(applications ?? [])], (totalCount ?? 0) + 1]);
},
[applications, totalCount, mutate]
);
return (
<div className={resourcesStyles.container}>
<div className={resourcesStyles.headline}>
@ -65,32 +72,6 @@ const Applications = () => {
});
}}
/>
<Modal
shouldCloseOnEsc
isOpen={isCreateNew}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
navigate({
pathname: applicationsPathname,
search,
});
}}
>
<CreateForm
onClose={(createdApp) => {
if (createdApp) {
navigate(buildDetailsPathname(createdApp.id), { replace: true });
return;
}
navigate({
pathname: applicationsPathname,
search,
});
}}
/>
</Modal>
</div>
<Table
className={resourcesStyles.table}
@ -119,7 +100,14 @@ const Applications = () => {
render: ({ id }) => <CopyToClipboard value={id} variant="text" />,
},
]}
placeholder={<ApplicationsPlaceholder />}
placeholder={
<ApplicationsPlaceholder
onCreate={async (newApp) => {
await mutateApplicationList(newApp);
navigate(buildGuidePathname(newApp.id), { replace: true });
}}
/>
}
rowClickHandler={({ id }) => {
navigate(buildDetailsPathname(id));
}}
@ -134,6 +122,20 @@ const Applications = () => {
updateSearchParameters({ page });
}}
/>
<CreateForm
isOpen={isShowingCreationForm}
onClose={async (newApp) => {
if (newApp) {
navigate(buildGuidePathname(newApp.id), { replace: true });
return;
}
navigate({
pathname: applicationsPathname,
search,
});
}}
/>
</div>
);
};

View file

@ -20,7 +20,7 @@ type Props = {
formItems?: ConnectorConfigFormItem[];
className?: string;
connectorId: string;
connectorType: ConnectorType;
connectorType?: ConnectorType;
};
const ConfigForm = ({

View file

@ -14,7 +14,6 @@ import type { RequestError } from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { getConnectorGroups } from '../../utils';
import Guide from '../Guide';
import PlatformSelector from './PlatformSelector';
import * as styles from './index.module.scss';
import { getConnectorOrder } from './utils';
@ -37,7 +36,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
const isLoading = !factories && !existingConnectors && !connectorsError && !factoriesError;
const [activeGroupId, setActiveGroupId] = useState<string>();
const [activeFactoryId, setActiveFactoryId] = useState<string>();
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
const groups = useMemo(() => {
if (!factories || !existingConnectors) {
@ -73,11 +71,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
[activeGroupId, groups]
);
const activeFactory = useMemo(
() => factories?.find(({ id }) => id === activeFactoryId),
[activeFactoryId, factories]
);
const cardTitle = useMemo(() => {
if (type === ConnectorType.Email) {
return 'connectors.setup_title.email';
@ -90,6 +83,22 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
return 'connectors.setup_title.social';
}, [type]);
const modalSize = useMemo(() => {
if (groups.length <= 2) {
return 'medium';
}
if (groups.length === 3) {
return 'large';
}
return 'xlarge';
}, [groups.length]);
if (!isFormOpen) {
return null;
}
const handleGroupChange = (groupId: string) => {
setActiveGroupId(groupId);
@ -104,25 +113,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
setActiveFactoryId(firstAvailableConnector?.id);
};
const closeModal = () => {
setIsGetStartedModalOpen(false);
onClose?.(activeFactoryId);
setActiveGroupId(undefined);
setActiveFactoryId(undefined);
};
const modalSize = useMemo(() => {
if (groups.length <= 2) {
return 'medium';
}
if (groups.length === 3) {
return 'large';
}
return 'xlarge';
}, [groups]);
return (
<Modal
shouldCloseOnEsc
@ -141,7 +131,7 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
type="primary"
disabled={!activeFactoryId}
onClick={() => {
setIsGetStartedModalOpen(true);
onClose?.(activeFactoryId);
}}
/>
}
@ -181,16 +171,6 @@ const CreateForm = ({ onClose, isOpen: isFormOpen, type }: Props) => {
onConnectorIdChange={setActiveFactoryId}
/>
)}
{activeFactory && (
<Modal
shouldCloseOnEsc
isOpen={isGetStartedModalOpen}
className={modalStyles.fullScreen}
onRequestClose={closeModal}
>
<Guide connector={activeFactory} onClose={closeModal} />
</Modal>
)}
</ModalLayout>
</Modal>
);

View file

@ -5,10 +5,11 @@ import { ConnectorType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import i18next from 'i18next';
import { HTTPError } from 'ky';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { useNavigate } from 'react-router-dom';
import Close from '@/assets/images/close.svg';
@ -21,6 +22,7 @@ import { ConnectorsTabs } from '@/consts/page-tabs';
import useApi from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import SenderTester from '@/pages/ConnectorDetails/components/SenderTester';
import * as modalStyles from '@/scss/modal.module.scss';
import type { ConnectorFormType } from '../../types';
import { SyncProfileMode } from '../../types';
@ -34,30 +36,22 @@ import * as styles from './index.module.scss';
const targetErrorCode = 'connector.multiple_target_with_same_platform';
type Props = {
connector: ConnectorFactoryResponse;
onClose: () => void;
connector?: ConnectorFactoryResponse;
onClose: (id?: string) => void;
};
const Guide = ({ connector, onClose }: Props) => {
const api = useApi({ hideErrorToast: true });
const navigate = useNavigate();
const [callbackConnectorId, setCallbackConnectorId] = useState<string>(generateStandardId());
const callbackConnectorId = useRef(generateStandardId());
const { updateConfigs } = useConfigs();
const parseJsonConfig = useConfigParser();
const [conflictConnectorName, setConflictConnectorName] = useState<Record<string, string>>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
id: connectorId,
type: connectorType,
name,
readme,
formItems,
target,
isStandard,
} = connector;
const { title, content } = splitMarkdownByTitle(readme);
const { type: connectorType, formItems, target, isStandard } = connector ?? {};
const { language } = i18next;
const connectorName = conditional(isLanguageTag(language) && name[language]) ?? name.en;
const isSocialConnector =
connectorType !== ConnectorType.Sms && connectorType !== ConnectorType.Email;
const methods = useForm<ConnectorFormType>({
@ -76,11 +70,19 @@ const Guide = ({ connector, onClose }: Props) => {
} = methods;
useEffect(() => {
if (isSocialConnector && !isStandard) {
if (isSocialConnector && !isStandard && target) {
setValue('target', target);
}
}, [isSocialConnector, target, isStandard, setValue]);
if (!connector) {
return null;
}
const { id: connectorId, name, readme, configTemplate } = connector;
const { title, content } = splitMarkdownByTitle(readme);
const connectorName = conditional(isLanguageTag(language) && name[language]) ?? name.en;
const onSubmit = handleSubmit(async (data) => {
if (isSubmitting) {
return;
@ -89,14 +91,13 @@ const Guide = ({ connector, onClose }: Props) => {
// Recover error state
setConflictConnectorName(undefined);
const { formItems, isStandard, id: connectorId, type } = connector;
const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
const { syncProfile, name, logo, logoDark, target } = data;
const basePayload = {
config,
connectorId,
id: conditional(type === ConnectorType.Social && callbackConnectorId),
id: conditional(connectorType === ConnectorType.Social && callbackConnectorId.current),
metadata: conditional(
isStandard && {
logo,
@ -149,77 +150,91 @@ const Guide = ({ connector, onClose }: Props) => {
});
return (
<div className={styles.container}>
<div className={styles.header}>
<IconButton size="large" onClick={onClose}>
<Close className={styles.closeIcon} />
</IconButton>
<div className={styles.separator} />
<CardTitle
size="small"
title={<DangerousRaw>{connectorName}</DangerousRaw>}
subtitle="connectors.guide.subtitle"
/>
</div>
<div className={styles.content}>
<div className={styles.readme}>
<div className={styles.readmeTitle}>README: {title}</div>
<Markdown className={styles.readmeContent}>{content}</Markdown>
<Modal
shouldCloseOnEsc
isOpen={Boolean(connector)}
className={modalStyles.fullScreen}
onRequestClose={() => {
onClose();
}}
>
<div className={styles.container}>
<div className={styles.header}>
<IconButton
size="large"
onClick={() => {
onClose(callbackConnectorId.current);
}}
>
<Close className={styles.closeIcon} />
</IconButton>
<div className={styles.separator} />
<CardTitle
size="small"
title={<DangerousRaw>{connectorName}</DangerousRaw>}
subtitle="connectors.guide.subtitle"
/>
</div>
<div className={styles.setup}>
<FormProvider {...methods}>
<form onSubmit={onSubmit}>
{isSocialConnector && (
<div className={styles.content}>
<div className={styles.readme}>
<div className={styles.readmeTitle}>README: {title}</div>
<Markdown className={styles.readmeContent}>{content}</Markdown>
</div>
<div className={styles.setup}>
<FormProvider {...methods}>
<form onSubmit={onSubmit}>
{isSocialConnector && (
<div className={styles.block}>
<div className={styles.blockTitle}>
<div className={styles.number}>1</div>
<div>{t('connectors.guide.general_setting')}</div>
</div>
<BasicForm
isAllowEditTarget={isStandard}
isStandard={isStandard}
conflictConnectorName={conflictConnectorName}
/>
</div>
)}
<div className={styles.block}>
<div className={styles.blockTitle}>
<div className={styles.number}>1</div>
<div>{t('connectors.guide.general_setting')}</div>
<div className={styles.number}>{isSocialConnector ? 2 : 1}</div>
<div>{t('connectors.guide.parameter_configuration')}</div>
</div>
<BasicForm
isAllowEditTarget={connector.isStandard}
isStandard={connector.isStandard}
conflictConnectorName={conflictConnectorName}
/>
</div>
)}
<div className={styles.block}>
<div className={styles.blockTitle}>
<div className={styles.number}>{isSocialConnector ? 2 : 1}</div>
<div>{t('connectors.guide.parameter_configuration')}</div>
</div>
<ConfigForm
connectorId={callbackConnectorId}
configTemplate={connector.configTemplate}
connectorType={connectorType}
formItems={connector.formItems}
/>
</div>
{!isSocialConnector && (
<div className={styles.block}>
<div className={styles.blockTitle}>
<div className={styles.number}>2</div>
<div>{t('connectors.guide.test_connection')}</div>
</div>
<SenderTester
connectorId={connectorId}
<ConfigForm
connectorId={callbackConnectorId.current}
configTemplate={configTemplate}
connectorType={connectorType}
config={watch('config')}
formItems={formItems}
/>
</div>
)}
<div className={styles.footer}>
<Button
title="connectors.save_and_done"
type="primary"
htmlType="submit"
isLoading={isSubmitting}
/>
</div>
</form>
</FormProvider>
{!isSocialConnector && (
<div className={styles.block}>
<div className={styles.blockTitle}>
<div className={styles.number}>2</div>
<div>{t('connectors.guide.test_connection')}</div>
</div>
<SenderTester
connectorId={connectorId}
connectorType={connectorType}
config={watch('config')}
/>
</div>
)}
<div className={styles.footer}>
<Button
title="connectors.save_and_done"
type="primary"
htmlType="submit"
isLoading={isSubmitting}
/>
</div>
</form>
</FormProvider>
</div>
</div>
</div>
</div>
</Modal>
);
};

View file

@ -1,9 +1,11 @@
import type { ConnectorFactoryResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import useSWR from 'swr';
import Plus from '@/assets/images/plus.svg';
import SocialConnectorEmptyDark from '@/assets/images/social-connector-empty-dark.svg';
@ -15,6 +17,7 @@ import Table from '@/components/Table';
import TablePlaceholder from '@/components/Table/TablePlaceholder';
import { defaultEmailConnectorGroup, defaultSmsConnectorGroup } from '@/consts';
import { ConnectorsTabs } from '@/consts/page-tabs';
import type { RequestError } from '@/hooks/use-api';
import useConnectorGroups from '@/hooks/use-connector-groups';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import * as resourcesStyles from '@/scss/resources.module.scss';
@ -24,6 +27,7 @@ import ConnectorStatus from './components/ConnectorStatus';
import ConnectorStatusField from './components/ConnectorStatusField';
import ConnectorTypeColumn from './components/ConnectorTypeColumn';
import CreateForm from './components/CreateForm';
import Guide from './components/Guide';
import SignInExperienceSetupNotice from './components/SignInExperienceSetupNotice';
import * as styles from './index.module.scss';
@ -31,10 +35,19 @@ const basePathname = '/connectors';
const passwordlessPathname = `${basePathname}/${ConnectorsTabs.Passwordless}`;
const socialPathname = `${basePathname}/${ConnectorsTabs.Social}`;
const buildCreatePathname = (connectorType: ConnectorType) => {
const pathname = connectorType === ConnectorType.Social ? socialPathname : passwordlessPathname;
const buildTabPathname = (connectorType: ConnectorType) =>
connectorType === ConnectorType.Social ? socialPathname : passwordlessPathname;
return `${pathname}/create/${connectorType}`;
const buildCreatePathname = (connectorType: ConnectorType) => {
const tabPath = buildTabPathname(connectorType);
return `${tabPath}/create/${connectorType}`;
};
const buildGuidePathname = (connectorType: ConnectorType, factoryId: string) => {
const tabPath = buildTabPathname(connectorType);
return `${tabPath}/guide/${factoryId}`;
};
const isConnectorType = (value: string): value is ConnectorType =>
@ -44,14 +57,20 @@ const parseToConnectorType = (value?: string): ConnectorType | undefined =>
conditional(value && isConnectorType(value) && value);
const Connectors = () => {
const { tab = ConnectorsTabs.Passwordless, createType } = useParams();
const { tab = ConnectorsTabs.Passwordless, createType, factoryId } = useParams();
const createConnectorType = parseToConnectorType(createType);
const navigate = useNavigate();
const isSocial = tab === ConnectorsTabs.Social;
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
const { data, error, mutate } = useConnectorGroups();
const isLoading = !data && !error;
const { data: factories, error: factoriesError } = useSWR<
ConnectorFactoryResponse[],
RequestError
>('api/connector-factories');
const isLoading = !data && !factories && !error && !factoriesError;
const passwordlessConnectors = useMemo(() => {
const smsConnector =
@ -70,6 +89,12 @@ const Connectors = () => {
const connectors = isSocial ? socialConnectors : passwordlessConnectors;
const connectorToShowInGuide = useMemo(() => {
if (factories && factoryId) {
return factories.find(({ id }) => id === factoryId);
}
}, [factoryId, factories]);
return (
<>
<div className={classNames(resourcesStyles.container, styles.container)}>
@ -159,16 +184,26 @@ const Connectors = () => {
onRetry={async () => mutate(undefined, true)}
/>
</div>
{Boolean(createConnectorType) && (
<CreateForm
isOpen
type={createConnectorType}
onClose={() => {
navigate(`${basePathname}/${tab}`);
void mutate();
}}
/>
)}
<CreateForm
isOpen={Boolean(createConnectorType)}
type={createConnectorType}
onClose={async (id) => {
await mutate();
if (createConnectorType && id) {
navigate(buildGuidePathname(createConnectorType, id), { replace: true });
return;
}
navigate(`${basePathname}/${tab}`);
}}
/>
<Guide
connector={connectorToShowInGuide}
onClose={() => {
navigate(`${basePathname}/${tab}`);
}}
/>
</>
);
};