0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): implement webhook basic details page (#3820)

This commit is contained in:
Xiao Yijun 2023-05-10 13:51:02 +08:00 committed by GitHub
parent b91b0f3835
commit 6c3a5a6899
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 460 additions and 69 deletions

View file

@ -8,6 +8,10 @@ export enum ConnectorsTabs {
Social = 'social', Social = 'social',
} }
export enum WebhookDetailsTabs {
Settings = 'settings',
}
export enum SignInExperiencePage { export enum SignInExperiencePage {
BrandingTab = 'branding', BrandingTab = 'branding',
SignUpAndSignInTab = 'sign-up-and-sign-in', SignUpAndSignInTab = 'sign-up-and-sign-in',

View file

@ -7,6 +7,7 @@ import {
ConnectorsTabs, ConnectorsTabs,
UserDetailsTabs, UserDetailsTabs,
RoleDetailsTabs, RoleDetailsTabs,
WebhookDetailsTabs,
} from '@/consts'; } from '@/consts';
import { isHookFeatureEnabled } from '@/consts/webhooks'; import { isHookFeatureEnabled } from '@/consts/webhooks';
import ApiResourceDetails from '@/pages/ApiResourceDetails'; import ApiResourceDetails from '@/pages/ApiResourceDetails';
@ -38,6 +39,8 @@ import UserLogs from '@/pages/UserDetails/UserLogs';
import UserRoles from '@/pages/UserDetails/UserRoles'; import UserRoles from '@/pages/UserDetails/UserRoles';
import UserSettings from '@/pages/UserDetails/UserSettings'; import UserSettings from '@/pages/UserDetails/UserSettings';
import Users from '@/pages/Users'; import Users from '@/pages/Users';
import WebhookDetails from '@/pages/WebhookDetails';
import WebhookSettings from '@/pages/WebhookDetails/WebhookSettings';
import Webhooks from '@/pages/Webhooks'; import Webhooks from '@/pages/Webhooks';
import type { AppContentOutletContext } from '../AppContent/types'; import type { AppContentOutletContext } from '../AppContent/types';
@ -90,6 +93,10 @@ function ConsoleContent() {
<Route path="webhooks"> <Route path="webhooks">
<Route index element={<Webhooks />} /> <Route index element={<Webhooks />} />
<Route path="create" element={<Webhooks />} /> <Route path="create" element={<Webhooks />} />
<Route path=":id" element={<WebhookDetails />}>
<Route index element={<Navigate replace to={WebhookDetailsTabs.Settings} />} />
<Route path={WebhookDetailsTabs.Settings} element={<WebhookSettings />} />
</Route>
</Route> </Route>
)} )}
<Route path="users"> <Route path="users">

View file

@ -0,0 +1,61 @@
import { type Hook } from '@logto/schemas';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import DetailsForm from '@/components/DetailsForm';
import FormCard from '@/components/FormCard';
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import useApi from '@/hooks/use-api';
import BasicWebhookForm from '@/pages/Webhooks/components/BasicWebhookForm';
import { type WebhookDetailsFormType, type WebhookDetailsOutletContext } from '../types';
import { webhookDetailsParser } from '../utils';
function WebhookSettings() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { hook, isDeleting, onHookUpdated } = useOutletContext<WebhookDetailsOutletContext>();
const webhookFormData = webhookDetailsParser.toLocalForm(hook);
const formMethods = useForm<WebhookDetailsFormType>({ defaultValues: webhookFormData });
const api = useApi();
const {
handleSubmit,
reset,
formState: { isSubmitting, isDirty },
} = formMethods;
const onSubmit = handleSubmit(async (formData) => {
const updatedHook = await api
.patch(`api/hooks/${hook.id}`, { json: webhookDetailsParser.toRemoteModel(formData) })
.json<Hook>();
reset(webhookDetailsParser.toLocalForm(updatedHook));
onHookUpdated(updatedHook);
toast.success(t('general.saved'));
});
return (
<>
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}
onSubmit={onSubmit}
onDiscard={reset}
>
<FormCard
title="webhook_details.settings.settings"
description="webhook_details.settings.settings_description"
>
<FormProvider {...formMethods}>
<BasicWebhookForm />
</FormProvider>
</FormCard>
</DetailsForm>
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</>
);
}
export default WebhookSettings;

View file

@ -0,0 +1,50 @@
@use '@/scss/underscore' as _;
.header {
padding: _.unit(6);
display: flex;
align-items: center;
justify-content: space-between;
.webhookIcon {
margin-left: _.unit(2);
width: 60px;
height: 60px;
}
> *:not(:first-child) {
margin-left: _.unit(6);
}
.metadata {
flex: 1;
> div {
display: flex;
align-items: center;
> *:not(:last-child) {
margin-right: _.unit(2);
}
}
.title {
font: var(--font-title-1);
color: var(--color-text);
}
.text {
font: var(--font-label-2);
color: var(--color-text-secondary);
}
.verticalBar {
@include _.vertical-bar;
height: 12px;
}
}
}
.icon {
color: var(--color-text-secondary);
}

View file

@ -0,0 +1,198 @@
import { type Hook } from '@logto/schemas';
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import useSWR from 'swr';
import Delete from '@/assets/images/delete.svg';
import Forbidden from '@/assets/images/forbidden.svg';
import More from '@/assets/images/more.svg';
import Shield from '@/assets/images/shield.svg';
import WebhookDark from '@/assets/images/webhook-dark.svg';
import Webhook from '@/assets/images/webhook.svg';
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
import Card from '@/components/Card';
import ConfirmModal from '@/components/ConfirmModal';
import CopyToClipboard from '@/components/CopyToClipboard';
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
import DetailsPage from '@/components/DetailsPage';
import DynamicT from '@/components/DynamicT';
import PageMeta from '@/components/PageMeta';
import Status from '@/components/Status';
import TabNav, { TabNavItem } from '@/components/TabNav';
import { WebhookDetailsTabs } from '@/consts';
import useApi, { type RequestError } from '@/hooks/use-api';
import useTheme from '@/hooks/use-theme';
import * as styles from './index.module.scss';
import { type WebhookDetailsOutletContext } from './types';
function WebhookDetails() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { pathname } = useLocation();
const navigate = useNavigate();
const { id } = useParams();
const { data, error, mutate } = useSWR<Hook, RequestError>(id && `api/hooks/${id}`);
const isLoading = !data && !error;
const api = useApi();
const theme = useTheme();
const WebhookIcon = theme === 'light' ? Webhook : WebhookDark;
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDisableFormOpen, setIsDisableFormOpen] = useState(false);
const [isUpdatingEnableState, setIsUpdatingEnableState] = useState(false);
const [isEnabled, setIsEnabled] = useState(data?.enabled ?? true);
useEffect(() => {
setIsDeleteFormOpen(false);
setIsDisableFormOpen(false);
}, [pathname]);
const onDelete = async () => {
if (!data || isDeleting) {
return;
}
setIsDeleting(true);
try {
await api.delete(`api/hooks/${data.id}`);
toast.success(t('webhook_details.deleted', { name: data.name }));
navigate('/webhooks');
} finally {
setIsDeleting(false);
}
};
const onToggleEnableState = async () => {
if (!data || isUpdatingEnableState) {
return;
}
setIsUpdatingEnableState(true);
try {
const { enabled } = await api
.patch(`api/hooks/${data.id}`, { json: { enabled: !isEnabled } })
.json<Hook>();
setIsEnabled(enabled);
setIsDisableFormOpen(false);
toast.success(
t(enabled ? 'webhook_details.webhook_reactivated' : 'webhook_details.webhook_disabled')
);
} finally {
setIsUpdatingEnableState(false);
}
};
return (
<DetailsPage
backLink="/webhooks"
backLinkTitle="webhook_details.back_to_webhooks"
isLoading={isLoading}
error={error}
onRetry={mutate}
>
<PageMeta titleKey="webhook_details.page_title" />
{data && (
<>
<Card className={styles.header}>
<WebhookIcon className={styles.webhookIcon} />
<div className={styles.metadata}>
<div className={styles.title}>{data.name}</div>
<div>
{isEnabled ? (
<div>Success Rate (WIP)</div>
) : (
<Status status="disabled" variant="outlined">
<DynamicT forKey="webhook_details.not_in_use" />
</Status>
)}
<div className={styles.verticalBar} />
<div className={styles.text}>ID</div>
<CopyToClipboard size="small" value={data.id} />
</div>
</div>
<div>
<ActionMenu
buttonProps={{ icon: <More className={styles.icon} />, size: 'large' }}
title={t('general.more_options')}
>
<ActionMenuItem
icon={isEnabled ? <Forbidden /> : <Shield />}
iconClassName={styles.icon}
onClick={async () => {
if (isEnabled) {
setIsDisableFormOpen(true);
return;
}
await onToggleEnableState();
}}
>
<DynamicT
forKey={
isEnabled
? 'webhook_details.disable_webhook'
: 'webhook_details.reactivate_webhook'
}
/>
</ActionMenuItem>
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
setIsDeleteFormOpen(true);
}}
>
<DynamicT forKey="webhook_details.delete_webhook" />
</ActionMenuItem>
</ActionMenu>
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
isLoading={isDeleting}
onCancel={() => {
setIsDeleteFormOpen(true);
}}
onConfirm={onDelete}
>
<div>{t('webhook_details.deletion_reminder')}</div>
</DeleteConfirmModal>
<ConfirmModal
isOpen={isDisableFormOpen}
isLoading={isUpdatingEnableState}
confirmButtonText="webhook_details.disable_webhook"
onCancel={async () => {
setIsDisableFormOpen(false);
}}
onConfirm={onToggleEnableState}
>
<DynamicT forKey="webhook_details.disable_reminder" />
</ConfirmModal>
</div>
</Card>
<TabNav>
<TabNavItem href={`/webhooks/${data.id}/${WebhookDetailsTabs.Settings}`}>
<DynamicT forKey="webhook_details.settings_tab" />
</TabNavItem>
</TabNav>
<Outlet
context={
{
hook: data,
isDeleting,
onHookUpdated: (hook) => {
void mutate(hook);
},
} satisfies WebhookDetailsOutletContext
}
/>
</>
)}
</DetailsPage>
);
}
export default WebhookDetails;

View file

@ -0,0 +1,11 @@
import { type HookConfig, type Hook } from '@logto/schemas';
import { type BasicWebhookFormType } from '../Webhooks/types';
export type WebhookDetailsOutletContext = {
hook: Hook;
isDeleting: boolean;
onHookUpdated: (hook?: Hook) => void;
};
export type WebhookDetailsFormType = BasicWebhookFormType & { headers: HookConfig['headers'] };

View file

@ -0,0 +1,34 @@
import { type Hook } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { type WebhookDetailsFormType } from './types';
export const webhookDetailsParser = {
toLocalForm: (data: Hook): WebhookDetailsFormType => {
const {
event,
events,
name,
config: { url, headers },
} = data;
return {
events: conditional(events.length > 0 && events) ?? (event ? [event] : []),
name,
url,
headers,
};
},
toRemoteModel: (formData: WebhookDetailsFormType): Partial<Hook> => {
const { name, events, url, headers } = formData;
return {
name,
events,
config: {
url,
headers,
},
};
},
};

View file

@ -0,0 +1,78 @@
import { HookEvent } from '@logto/schemas';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { CheckboxGroup } from '@/components/Checkbox';
import FormField from '@/components/FormField';
import TextInput from '@/components/TextInput';
import { hookEventLabel } from '@/consts/webhooks';
import { uriValidator } from '@/utils/validator';
import { type BasicWebhookFormType } from '../../types';
import * as styles from './index.module.scss';
export const hookEventOptions = Object.values(HookEvent).map((event) => ({
title: hookEventLabel[event],
value: event,
}));
function BasicWebhookForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
register,
control,
formState: { errors },
} = useFormContext<BasicWebhookFormType>();
return (
<>
<FormField title="webhooks.create_form.events">
<div className={styles.formFieldDescription}>
{t('webhooks.create_form.events_description')}
</div>
<Controller
name="events"
control={control}
defaultValue={[]}
rules={{
validate: (value) =>
value.length === 0 ? t('webhooks.create_form.missing_event_error') : true,
}}
render={({ field: { onChange, value } }) => (
<CheckboxGroup options={hookEventOptions} value={value} onChange={onChange} />
)}
/>
{errors.events && <div className={styles.errorMessage}>{errors.events.message}</div>}
</FormField>
<FormField isRequired title="webhooks.create_form.name">
<TextInput
{...register('name', { required: true })}
placeholder={t('webhooks.create_form.name_placeholder')}
error={Boolean(errors.name)}
/>
</FormField>
<FormField
isRequired
title="webhooks.create_form.endpoint_url"
tip={t('webhooks.create_form.endpoint_url_tip')}
>
<TextInput
{...register('url', {
required: true,
validate: (value) => {
if (!uriValidator(value)) {
return t('errors.invalid_uri_format');
}
return value.startsWith('https://') || t('webhooks.create_form.https_format_error');
},
})}
placeholder={t('webhooks.create_form.endpoint_url_placeholder')}
error={errors.url?.type === 'required' ? true : errors.url?.message}
/>
</FormField>
</>
);
}
export default BasicWebhookForm;

View file

@ -1,27 +1,17 @@
import { type Hook, type CreateHook, HookEvent, type HookConfig } from '@logto/schemas'; import { type Hook, type CreateHook, type HookEvent, type HookConfig } from '@logto/schemas';
import { Controller, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { CheckboxGroup } from '@/components/Checkbox';
import FormField from '@/components/FormField';
import ModalLayout from '@/components/ModalLayout'; import ModalLayout from '@/components/ModalLayout';
import TextInput from '@/components/TextInput';
import { hookEventLabel } from '@/consts/webhooks';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import { uriValidator } from '@/utils/validator';
import * as styles from './CreateForm.module.scss'; import { type BasicWebhookFormType } from '../../types';
import BasicWebhookForm from '../BasicWebhookForm';
type Props = { type Props = {
onClose: (createdHook?: Hook) => void; onClose: (createdHook?: Hook) => void;
}; };
type FormData = Pick<CreateHook, 'name'> & {
events: HookEvent[];
url: HookConfig['url'];
};
type CreateHookPayload = Pick<CreateHook, 'name'> & { type CreateHookPayload = Pick<CreateHook, 'name'> & {
events: HookEvent[]; events: HookEvent[];
config: { config: {
@ -29,20 +19,12 @@ type CreateHookPayload = Pick<CreateHook, 'name'> & {
}; };
}; };
const hookEventOptions = Object.values(HookEvent).map((event) => ({
title: hookEventLabel[event],
value: event,
}));
function CreateForm({ onClose }: Props) { function CreateForm({ onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const formMethods = useForm<BasicWebhookFormType>();
const { const {
handleSubmit, handleSubmit,
register, formState: { isSubmitting },
control, } = formMethods;
formState: { isSubmitting, errors },
} = useForm<FormData>();
const api = useApi(); const api = useApi();
@ -76,50 +58,9 @@ function CreateForm({ onClose }: Props) {
} }
onClose={onClose} onClose={onClose}
> >
<FormField title="webhooks.create_form.events"> <FormProvider {...formMethods}>
<div className={styles.formFieldDescription}> <BasicWebhookForm />
{t('webhooks.create_form.events_description')} </FormProvider>
</div>
<Controller
name="events"
control={control}
defaultValue={[]}
rules={{
validate: (value) =>
value.length === 0 ? t('webhooks.create_form.missing_event_error') : true,
}}
render={({ field: { onChange, value } }) => (
<CheckboxGroup options={hookEventOptions} value={value} onChange={onChange} />
)}
/>
{errors.events && <div className={styles.errorMessage}>{errors.events.message}</div>}
</FormField>
<FormField isRequired title="webhooks.create_form.name">
<TextInput
{...register('name', { required: true })}
placeholder={t('webhooks.create_form.name_placeholder')}
error={Boolean(errors.name)}
/>
</FormField>
<FormField
isRequired
title="webhooks.create_form.endpoint_url"
tip={t('webhooks.create_form.endpoint_url_tip')}
>
<TextInput
{...register('url', {
required: true,
validate: (value) => {
if (!uriValidator(value)) {
return t('errors.invalid_uri_format');
}
return value.startsWith('https://') || t('webhooks.create_form.https_format_error');
},
})}
placeholder={t('webhooks.create_form.endpoint_url_placeholder')}
error={errors.url?.type === 'required' ? true : errors.url?.message}
/>
</FormField>
</ModalLayout> </ModalLayout>
); );
} }

View file

@ -0,0 +1,7 @@
import { type Hook, type HookConfig, type HookEvent } from '@logto/schemas';
export type BasicWebhookFormType = {
name: Hook['name'];
events: HookEvent[];
url: HookConfig['url'];
};