0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(console): connector config form (#3074)

This commit is contained in:
wangsijie 2023-02-10 16:59:32 +08:00 committed by GitHub
parent 24f2cd20e7
commit 111e2973c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 353 additions and 61 deletions

View file

@ -19,6 +19,7 @@
},
"devDependencies": {
"@fontsource/roboto-mono": "^4.5.7",
"@logto/connector-kit": "workspace:*",
"@logto/core-kit": "workspace:*",
"@logto/language-kit": "workspace:*",
"@logto/phrases": "workspace:*",

View file

@ -11,6 +11,14 @@
outline-color: var(--color-focused-variant);
}
&.error {
border-color: var(--color-error);
&:focus-within {
outline-color: var(--color-danger-focused);
}
}
textarea {
width: 100%;
height: 100%;
@ -23,7 +31,7 @@
padding: 0;
&::placeholder {
color: var(--color-caption);
color: var(--color-placeholder);
}
}
}

View file

@ -6,11 +6,15 @@ import * as styles from './index.module.scss';
type Props = HTMLProps<HTMLTextAreaElement> & {
className?: string;
hasError?: boolean;
};
const Textarea = ({ className, ...rest }: Props, reference: ForwardedRef<HTMLTextAreaElement>) => {
const Textarea = (
{ className, hasError, ...rest }: Props,
reference: ForwardedRef<HTMLTextAreaElement>
) => {
return (
<div className={classNames(styles.container, className)}>
<div className={classNames(styles.container, hasError && styles.error, className)}>
<textarea {...rest} ref={reference} />
</div>
);

View file

@ -11,9 +11,10 @@ import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import ConnectorForm from '@/pages/Connectors/components/ConnectorForm';
import { useConfigParser } from '@/pages/Connectors/components/ConnectorForm/hooks';
import { initFormData, parseFormConfig } from '@/pages/Connectors/components/ConnectorForm/utils';
import type { ConnectorFormType } from '@/pages/Connectors/types';
import { SyncProfileMode } from '@/pages/Connectors/types';
import { safeParseJson } from '@/utilities/json';
import * as styles from '../index.module.scss';
import SenderTester from './SenderTester';
@ -27,6 +28,7 @@ type Props = {
const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
const parseJsonConfig = useConfigParser();
const api = useApi();
const methods = useForm<ConnectorFormType>({
reValidateMode: 'onBlur',
@ -42,9 +44,11 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
} = methods;
useEffect(() => {
const { name, logo, logoDark, target } = connectorData.metadata;
const { config, syncProfile } = connectorData;
const { formItems, metadata, config, syncProfile } = connectorData;
const { name, logo, logoDark, target } = metadata;
reset({
...(formItems ? initFormData(formItems, config) : {}),
target,
logo,
logoDark: logoDark ?? '',
@ -54,36 +58,26 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
});
}, [connectorData, reset]);
const onSubmit = handleSubmit(async ({ config, syncProfile, ...metadata }) => {
if (!config) {
toast.error(t('connector_details.save_error_empty_config'));
return;
}
const result = safeParseJson(config);
if (!result.success) {
toast.error(result.error);
return;
}
const onSubmit = handleSubmit(async (data) => {
const { formItems, isStandard, id } = connectorData;
const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
const { syncProfile, name, logo, logoDark, target } = data;
const payload =
connectorData.type === ConnectorType.Social
? {
config: result.data,
config,
syncProfile: syncProfile === SyncProfileMode.EachSignIn,
}
: { config: result.data };
: { config };
const standardConnectorPayload = {
...payload,
metadata: { ...metadata, name: { en: metadata.name } },
metadata: { name: { en: name }, logo, logoDark, target },
};
const body = connectorData.isStandard ? standardConnectorPayload : payload;
const body = isStandard ? standardConnectorPayload : payload;
const updatedConnector = await api
.patch(`/api/connectors/${connectorData.id}`, {
.patch(`/api/connectors/${id}`, {
json: body,
})
.json<ConnectorResponse>();
@ -109,6 +103,7 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
connectorType={connectorData.type}
isStandard={connectorData.isStandard}
isDarkDefaultVisible={Boolean(connectorData.metadata.logoDark)}
formItems={connectorData.formItems}
/>
{connectorData.type !== ConnectorType.Social && (
<SenderTester

View file

@ -0,0 +1,151 @@
import type { ConnectorConfigFormItem } from '@logto/connector-kit';
import { ConnectorConfigFormItemType } from '@logto/connector-kit';
import { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import CodeEditor from '@/components/CodeEditor';
import DangerousRaw from '@/components/DangerousRaw';
import FormField from '@/components/FormField';
import Select from '@/components/Select';
import Switch from '@/components/Switch';
import TextInput from '@/components/TextInput';
import Textarea from '@/components/Textarea';
import { jsonValidator } from '@/utilities/validator';
import type { ConnectorFormType } from '../../types';
type Props = {
formItems: ConnectorConfigFormItem[];
};
const ConfigForm = ({ formItems }: Props) => {
const {
watch,
register,
control,
formState: { errors },
} = useFormContext<ConnectorFormType>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const values = watch();
const filteredFormItems = useMemo(() => {
return formItems.filter((item) => {
if (!item.showConditions) {
return true;
}
return item.showConditions.every(({ expectValue, targetKey }) => {
const targetValue = values[targetKey];
return targetValue === expectValue;
});
});
}, [formItems, values]);
const renderFormItem = (item: ConnectorConfigFormItem) => {
const hasError = Boolean(errors[item.key]);
const errorMessage = errors[item.key]?.message;
const commonProperties = {
...register(item.key, { required: item.required }),
placeholder: item.placeholder,
hasError,
};
if (item.type === ConnectorConfigFormItemType.Text) {
return <TextInput {...commonProperties} />;
}
if (item.type === ConnectorConfigFormItemType.MultilineText) {
return <Textarea rows={5} {...commonProperties} />;
}
if (item.type === ConnectorConfigFormItemType.Number) {
return <TextInput type="number" {...commonProperties} />;
}
return (
<Controller
name={item.key}
control={control}
rules={
item.type === ConnectorConfigFormItemType.Json
? {
validate: (value) =>
(typeof value === 'string' && jsonValidator(value)) ||
t('errors.invalid_json_format'),
}
: undefined
}
render={({ field: { onChange, value } }) => {
if (item.type === ConnectorConfigFormItemType.Switch) {
return (
<Switch
label={item.label}
checked={typeof value === 'boolean' ? value : false}
onChange={({ currentTarget: { checked } }) => {
onChange(checked);
}}
/>
);
}
if (item.type === ConnectorConfigFormItemType.Select) {
return (
<Select
options={item.selectItems}
value={typeof value === 'string' ? value : undefined}
hasError={hasError}
onChange={onChange}
/>
);
}
if (item.type === ConnectorConfigFormItemType.Json) {
return (
<CodeEditor
language="json"
hasError={hasError}
errorMessage={errorMessage}
value={typeof value === 'string' ? value : '{}'}
onChange={onChange}
/>
);
}
// Default (unknown) type is "Text"
// This will happen when connector's version is ahead of AC
return (
<TextInput
hasError={hasError}
value={typeof value === 'string' ? value : ''}
onChange={onChange}
/>
);
}}
/>
);
};
return (
<>
{filteredFormItems.map((item) => (
<FormField
key={item.key}
isRequired={item.required}
title={
<DangerousRaw>
{item.type !== ConnectorConfigFormItemType.Switch && item.label}
</DangerousRaw>
}
>
{renderFormItem(item)}
</FormField>
))}
</>
);
};
export default ConfigForm;

View file

@ -0,0 +1,26 @@
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { safeParseJson } from '@/utilities/json';
export const useConfigParser = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (config: string) => {
if (!config) {
toast.error(t('connector_details.save_error_empty_config'));
return;
}
const result = safeParseJson(config);
if (!result.success) {
toast.error(result.error);
return;
}
return result;
};
};

View file

@ -1,3 +1,4 @@
import type { ConnectorConfigFormItem } from '@logto/connector-kit';
import type { ConnectorFactoryResponse } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { useState } from 'react';
@ -17,6 +18,7 @@ import { uriValidator, jsonValidator } from '@/utilities/validator';
import type { ConnectorFormType } from '../../types';
import { SyncProfileMode } from '../../types';
import ConfigForm from '../ConfigForm';
import * as styles from './index.module.scss';
type Props = {
@ -25,6 +27,7 @@ type Props = {
configTemplate?: ConnectorFactoryResponse['configTemplate'];
isAllowEditTarget?: boolean;
isDarkDefaultVisible?: boolean;
formItems?: ConnectorConfigFormItem[];
};
const ConnectorForm = ({
@ -33,6 +36,7 @@ const ConnectorForm = ({
isAllowEditTarget,
isDarkDefaultVisible,
connectorType,
formItems,
}: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
@ -132,25 +136,29 @@ const ConnectorForm = ({
</FormField>
</>
)}
<FormField title="connectors.guide.config">
<Controller
name="config"
control={control}
defaultValue={configTemplate}
rules={{
validate: (value) => jsonValidator(value) || t('errors.invalid_json_format'),
}}
render={({ field: { onChange, value } }) => (
<CodeEditor
hasError={Boolean(errors.config)}
errorMessage={errors.config?.message}
language="json"
value={value}
onChange={onChange}
/>
)}
/>
</FormField>
{formItems ? (
<ConfigForm formItems={formItems} />
) : (
<FormField title="connectors.guide.config">
<Controller
name="config"
control={control}
defaultValue={configTemplate}
rules={{
validate: (value) => jsonValidator(value) || t('errors.invalid_json_format'),
}}
render={({ field: { onChange, value } }) => (
<CodeEditor
hasError={Boolean(errors.config)}
errorMessage={errors.config?.message}
language="json"
value={value}
onChange={onChange}
/>
)}
/>
</FormField>
)}
{connectorType === ConnectorType.Social && (
<FormField title="connectors.guide.sync_profile">
<Controller

View file

@ -0,0 +1,56 @@
import type { ConnectorConfigFormItem } from '@logto/connector-kit';
import { ConnectorConfigFormItemType } from '@logto/connector-kit';
import { safeParseJson } from '@/utilities/json';
import type { ConnectorFormType } from '../../types';
export const initFormData = (
formItems: ConnectorConfigFormItem[],
config?: Record<string, unknown>
) => {
const data: Array<[string, unknown]> = formItems.map((item) => {
const value = config?.[item.key] ?? item.defaultValue;
if (item.type === ConnectorConfigFormItemType.Json) {
return [item.key, JSON.stringify(value, null, 2)];
}
return [item.key, value];
});
return Object.fromEntries(data);
};
export const parseFormConfig = (data: ConnectorFormType, formItems: ConnectorConfigFormItem[]) => {
return Object.fromEntries(
Object.entries(data)
.map(([key, value]) => {
// Filter out empty input
if (value === '') {
return null;
}
const formItem = formItems.find((item) => item.key === key);
if (!formItem) {
return null;
}
if (formItem.type === ConnectorConfigFormItemType.Number) {
// The number input my return string value.
return [key, Number(value)];
}
if (formItem.type === ConnectorConfigFormItemType.Json) {
// The JSON validation is done in the form
const result = safeParseJson(typeof value === 'string' ? value : '');
return [key, result.success ? result.data : {}];
}
return [key, value];
})
.filter((item): item is [string, unknown] => Array.isArray(item))
);
};

View file

@ -18,11 +18,12 @@ 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 { safeParseJson } from '@/utilities/json';
import type { ConnectorFormType } from '../../types';
import { SyncProfileMode } from '../../types';
import ConnectorForm from '../ConnectorForm';
import { useConfigParser } from '../ConnectorForm/hooks';
import { initFormData, parseFormConfig } from '../ConnectorForm/utils';
import * as styles from './index.module.scss';
type Props = {
@ -34,8 +35,9 @@ const Guide = ({ connector, onClose }: Props) => {
const api = useApi();
const navigate = useNavigate();
const { updateConfigs } = useConfigs();
const parseJsonConfig = useConfigParser();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { id: connectorId, type: connectorType, name, readme, isStandard } = connector;
const { id: connectorId, type: connectorType, name, readme, isStandard, formItems } = connector;
const { language } = i18next;
const connectorName = conditional(isLanguageTag(language) && name[language]) ?? name.en;
const isSocialConnector =
@ -43,6 +45,7 @@ const Guide = ({ connector, onClose }: Props) => {
const methods = useForm<ConnectorFormType>({
reValidateMode: 'onBlur',
defaultValues: {
...(formItems ? initFormData(formItems) : {}),
syncProfile: SyncProfileMode.OnlyAtRegister,
},
});
@ -57,23 +60,18 @@ const Guide = ({ connector, onClose }: Props) => {
return;
}
const { config, name, syncProfile, ...otherData } = data;
const result = safeParseJson(config);
if (!result.success) {
toast.error(result.error);
return;
}
const { id: connectorId } = connector;
const { formItems, isStandard, id: connectorId } = connector;
const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
const { syncProfile, name, logo, logoDark, target } = data;
const basePayload = {
config: result.data,
config,
connectorId,
metadata: conditional(
isStandard && {
...otherData,
logo,
logoDark,
target,
name: { en: name },
}
),
@ -127,6 +125,7 @@ const Guide = ({ connector, onClose }: Props) => {
connectorType={connector.type}
configTemplate={connector.configTemplate}
isStandard={connector.isStandard}
formItems={connector.formItems}
/>
{!isSocialConnector && (
<SenderTester

View file

@ -5,7 +5,7 @@ export type ConnectorFormType = {
logoDark: string;
target: string;
syncProfile: SyncProfileMode;
};
} & Record<string, unknown>; // Extend custom connector config form
export enum SyncProfileMode {
OnlyAtRegister = 'OnlyAtRegister',

View file

@ -59,7 +59,8 @@ export const parseMetadata = async (
logo: await readUrl(metadata.logo, packagePath, 'svg'),
logoDark: metadata.logoDark && (await readUrl(metadata.logoDark, packagePath, 'svg')),
readme: await readUrl(metadata.readme, packagePath, 'text'),
configTemplate: await readUrl(metadata.configTemplate, packagePath, 'text'),
configTemplate:
metadata.configTemplate && (await readUrl(metadata.configTemplate, packagePath, 'text')),
};
};

View file

@ -93,6 +93,46 @@ export enum MessageType {
/** @deprecated Use `verificationCodeTypeGuard` instead. */
export const messageTypesGuard = verificationCodeTypeGuard;
export enum ConnectorConfigFormItemType {
Text = 'Text',
Number = 'Number',
MultilineText = 'MultilineText',
Switch = 'Switch',
Select = 'Select',
Json = 'Json',
}
const baseConfigFormItem = {
key: z.string(),
label: z.string(),
placeholder: z.string().optional(),
required: z.boolean().optional(),
defaultValue: z.unknown().optional(),
showConditions: z
.array(z.object({ targetKey: z.string(), expectValue: z.unknown().optional() }))
.optional(),
};
const connectorConfigFormItemGuard = z.discriminatedUnion('type', [
z.object({
type: z.literal(ConnectorConfigFormItemType.Select),
selectItems: z.array(z.object({ value: z.string(), title: z.string() })),
...baseConfigFormItem,
}),
z.object({
type: z.enum([
ConnectorConfigFormItemType.Text,
ConnectorConfigFormItemType.Number,
ConnectorConfigFormItemType.MultilineText,
ConnectorConfigFormItemType.Switch,
ConnectorConfigFormItemType.Json,
]),
...baseConfigFormItem,
}),
]);
export type ConnectorConfigFormItem = z.infer<typeof connectorConfigFormItemGuard>;
const connectorMetadataGuard = z.object({
id: z.string(),
target: z.string(),
@ -103,7 +143,8 @@ const connectorMetadataGuard = z.object({
description: i18nPhrasesGuard,
isStandard: z.boolean().optional(),
readme: z.string(),
configTemplate: z.string(),
configTemplate: z.string().optional(),
formItems: connectorConfigFormItemGuard.array().optional(),
});
export const configurableConnectorMetadataGuard = connectorMetadataGuard

View file

@ -107,6 +107,7 @@ importers:
packages/console:
specifiers:
'@fontsource/roboto-mono': ^4.5.7
'@logto/connector-kit': workspace:*
'@logto/core-kit': workspace:*
'@logto/language-kit': workspace:*
'@logto/phrases': workspace:*
@ -178,6 +179,7 @@ importers:
zod: ^3.20.2
devDependencies:
'@fontsource/roboto-mono': 4.5.7
'@logto/connector-kit': link:../toolkit/connector-kit
'@logto/core-kit': link:../toolkit/core-kit
'@logto/language-kit': link:../toolkit/language-kit
'@logto/phrases': link:../phrases