Skip to content

Commit

Permalink
feat(console, phrases): update the supported webhook events (#5856)
Browse files Browse the repository at this point in the history
* test(core): add integration tests

add integration tests for interaction hooks

* chore(test): remove legacy test

remove legacy test

* feat(console, phrases): update the supported webhook events

update the supported webhook events

* refactor(console): rename webhook and webhook log keys

rename webhook and webhook log keys

* fix(test): fix integration test

fix integration test

* feat(console): add devFeature guard

add devFeature guard

* chore: add changeset

add changeset

* chore(console): remove the lint rule disable comment

remove the lint rule disable comment

* fix(test): fix the integartion tests

fix the integration tests

* fix(console): refine the code

refine the code

* chore(console): refine comments

refine comments
  • Loading branch information
simeng-li authored May 15, 2024
1 parent c2a8e45 commit e04d952
Show file tree
Hide file tree
Showing 30 changed files with 438 additions and 151 deletions.
12 changes: 12 additions & 0 deletions .changeset/curvy-boxes-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@logto/console": patch
"@logto/phrases": patch
---

replace the i18n translated hook event label with the hook event value directly in the console

- remove all the legacy interaction hook events i18n phrases
- replace the translated label with the hook event value directly in the console
- `Create new account` -> `PostRegister`
- `Sign in` -> `PostSignIn`
- `Reset password` -> `PostResetPassword`
71 changes: 45 additions & 26 deletions packages/console/src/components/BasicWebhookForm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
import { type HookEvent, type Hook, type HookConfig, InteractionHookEvent } from '@logto/schemas';
import { type Hook, type HookConfig, type HookEvent } from '@logto/schemas';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';

import { hookEventLabel } from '@/consts/webhooks';
import { CheckboxGroup } from '@/ds-components/Checkbox';
import { isDevFeaturesEnabled } from '@/consts/env';
import {
dataHookEventsLabel,
interactionHookEvents,
schemaGroupedDataHookEvents,
} from '@/consts/webhooks';
import CategorizedCheckboxGroup, {
type CheckboxOptionGroup,
} from '@/ds-components/Checkbox/CategorizedCheckboxGroup';
import FormField from '@/ds-components/FormField';
import TextInput from '@/ds-components/TextInput';
import { uriValidator } from '@/utils/validator';

import * as styles from './index.module.scss';

// TODO: Implement all hook events
const hookEventOptions = Object.values(InteractionHookEvent).map((event) => ({
title: hookEventLabel[event],
value: event,
}));
const hookEventGroups: Array<CheckboxOptionGroup<HookEvent>> = [
// TODO: Remove dev feature guard
...(isDevFeaturesEnabled
? schemaGroupedDataHookEvents.map(([schema, events]) => ({
title: dataHookEventsLabel[schema],
options: events.map((event) => ({
value: event,
})),
}))
: []),
{
title: 'webhooks.schemas.interaction',
options: interactionHookEvents.map((event) => ({
value: event,
})),
},
];

export type BasicWebhookFormType = {
name: Hook['name'];
Expand All @@ -32,24 +51,6 @@ function BasicWebhookForm() {

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 })}
Expand All @@ -71,6 +72,24 @@ function BasicWebhookForm() {
error={errors.url?.type === 'required' ? true : errors.url?.message}
/>
</FormField>
<FormField
title="webhooks.create_form.events"
tip={t('webhooks.create_form.events_description')}
>
<Controller
name="events"
control={control}
defaultValue={[]}
rules={{
validate: (value) =>
value.length === 0 ? t('webhooks.create_form.missing_event_error') : true,
}}
render={({ field: { onChange, value } }) => (
<CategorizedCheckboxGroup value={value} groups={hookEventGroups} onChange={onChange} />
)}
/>
{errors.events && <div className={styles.errorMessage}>{errors.events.message}</div>}
</FormField>
</>
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/console/src/consts/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { yes } from '@silverhand/essentials';
const isProduction = process.env.NODE_ENV === 'production';
export const isCloud = yes(process.env.IS_CLOUD);
export const adminEndpoint = process.env.ADMIN_ENDPOINT;
// eslint-disable-next-line import/no-unused-modules

export const isDevFeaturesEnabled =
!isProduction || yes(process.env.DEV_FEATURES_ENABLED) || yes(process.env.INTEGRATION_TEST);
70 changes: 52 additions & 18 deletions packages/console/src/consts/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,58 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { InteractionHookEvent, type LogKey } from '@logto/schemas';
import {
DataHookSchema,
InteractionHookEvent,
hookEvents,
type DataHookEvent,
} from '@logto/schemas';

type HookEventLabel = {
// TODO: Implement all hook events
[key in InteractionHookEvent]: AdminConsoleKey;
};
export const dataHookEventsLabel = Object.freeze({
[DataHookSchema.User]: 'webhooks.schemas.user',
[DataHookSchema.Organization]: 'webhooks.schemas.organization',
[DataHookSchema.Role]: 'webhooks.schemas.role',
[DataHookSchema.Scope]: 'webhooks.schemas.scope',
[DataHookSchema.OrganizationRole]: 'webhooks.schemas.organization_role',
[DataHookSchema.OrganizationScope]: 'webhooks.schemas.organization_scope',
} satisfies Record<DataHookSchema, AdminConsoleKey>);

export const interactionHookEvents = Object.values(InteractionHookEvent);

const dataHookEvents: DataHookEvent[] = hookEvents.filter(
// eslint-disable-next-line no-restricted-syntax
(event): event is DataHookEvent => !interactionHookEvents.includes(event as InteractionHookEvent)
);

const isDataHookSchema = (schema: string): schema is DataHookSchema =>
// eslint-disable-next-line no-restricted-syntax
Object.values(DataHookSchema).includes(schema as DataHookSchema);

// Group DataHook events by schema
// TODO: Replace this using `groupBy` once Node v22 goes LTS
const schemaGroupedDataHookEventsMap = dataHookEvents.reduce<Map<DataHookSchema, DataHookEvent[]>>(
(eventGroup, event) => {
const [schema] = event.split('.');

if (schema && isDataHookSchema(schema)) {
eventGroup.set(schema, [...(eventGroup.get(schema) ?? []), event]);
}

export const hookEventLabel = Object.freeze({
[InteractionHookEvent.PostRegister]: 'webhooks.events.post_register',
[InteractionHookEvent.PostResetPassword]: 'webhooks.events.post_reset_password',
[InteractionHookEvent.PostSignIn]: 'webhooks.events.post_sign_in',
}) satisfies HookEventLabel;
return eventGroup;
},
new Map()
);

type HookEventLogKey = {
// TODO: Implement all hook events
[key in InteractionHookEvent]: LogKey;
// Sort the grouped `DataHook` events per console product design
const hookEventSchemaOrder: {
[key in DataHookSchema]: number;
} = {
[DataHookSchema.User]: 0,
[DataHookSchema.Organization]: 1,
[DataHookSchema.Role]: 2,
[DataHookSchema.OrganizationRole]: 3,
[DataHookSchema.Scope]: 4,
[DataHookSchema.OrganizationScope]: 5,
};

export const hookEventLogKey = Object.freeze({
[InteractionHookEvent.PostRegister]: 'TriggerHook.PostRegister',
[InteractionHookEvent.PostResetPassword]: 'TriggerHook.PostResetPassword',
[InteractionHookEvent.PostSignIn]: 'TriggerHook.PostSignIn',
}) satisfies HookEventLogKey;
export const schemaGroupedDataHookEvents = Array.from(schemaGroupedDataHookEventsMap.entries())
.slice()
.sort(([schemaA], [schemaB]) => hookEventSchemaOrder[schemaA] - hookEventSchemaOrder[schemaB]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@use '@/scss/underscore' as _;

.groupTitle {
font: var(--font-body-2);
color: var(--color-text-secondary);
margin-bottom: _.unit(2);
}

.groupList {
// Max two columns
gap: _.unit(5);
display: grid;
grid-template-columns: repeat(2, 1fr);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { type AdminConsoleKey } from '@logto/phrases';
import classNames from 'classnames';

import DynamicT from '@/ds-components/DynamicT';

import CheckboxGroup, { type Option } from '../CheckboxGroup';

import * as styles from './index.module.scss';

export type CheckboxOptionGroup<T> = {
title: AdminConsoleKey;
options: Array<Option<T>>;
};

type Props<T> = {
readonly groups: Array<CheckboxOptionGroup<T>>;
readonly value: T[];
readonly onChange: (value: T[]) => void;
readonly className?: string;
};

function CategorizedCheckboxGroup<T extends string>({
groups,
value: checkedValues,
onChange,
className,
}: Props<T>) {
return (
<div className={classNames(styles.groupList, className)}>
{groups.map(({ title, options }) => (
<div key={title}>
<div className={styles.groupTitle}>
<DynamicT forKey={title} />
</div>
<CheckboxGroup options={options} value={checkedValues} onChange={onChange} />
</div>
))}
</div>
);
}

export default CategorizedCheckboxGroup;
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import Checkbox from '../Checkbox';

import * as styles from './index.module.scss';

type Option<T> = {
title: AdminConsoleKey;
export type Option<T> = {
title?: AdminConsoleKey;
tag?: ReactNode;
value: T;
};
Expand Down Expand Up @@ -42,7 +42,7 @@ function CheckboxGroup<T extends string>({
key={value}
label={
<>
<DynamicT forKey={title} />
{title ? <DynamicT forKey={title} /> : value}
{tag}
</>
}
Expand Down
11 changes: 5 additions & 6 deletions packages/console/src/pages/AuditLogDetails/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Application, User, Log, Hook } from '@logto/schemas';
/* eslint-disable complexity */
import type { Application, Hook, Log, User } from '@logto/schemas';
import { demoAppApplicationId } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useTranslation } from 'react-i18next';
Expand All @@ -10,13 +11,13 @@ import DetailsPage from '@/components/DetailsPage';
import PageMeta from '@/components/PageMeta';
import UserName from '@/components/UserName';
import { logEventTitle } from '@/consts/logs';
import { hookEventLogKey } from '@/consts/webhooks';
import Card from '@/ds-components/Card';
import CodeEditor from '@/ds-components/CodeEditor';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import type { RequestError } from '@/hooks/use-api';
import { isWebhookEventLogKey } from '@/pages/WebhookDetails/utils';
import { getUserTitle } from '@/utils/user';

import EventIcon from './components/EventIcon';
Expand All @@ -28,9 +29,6 @@ const getAuditLogDetailsRelatedResourceLink = (pathname: string) =>
const getDetailsTabNavLink = (logId: string, userId?: string) =>
userId ? `/users/${userId}/logs/${logId}` : `/audit-logs/${logId}`;

const isWebhookEventLog = (key?: string) =>
key && Object.values<string>(hookEventLogKey).includes(key);

function AuditLogDetails() {
const { appId, userId, hookId, logId } = useParams();
const { pathname } = useLocation();
Expand Down Expand Up @@ -70,7 +68,7 @@ function AuditLogDetails() {
return null;
}

const isWebHookEvent = isWebhookEventLog(data?.key);
const isWebHookEvent = isWebhookEventLogKey(data?.key ?? '');

return (
<DetailsPage
Expand Down Expand Up @@ -161,3 +159,4 @@ function AuditLogDetails() {
}

export default AuditLogDetails;
/* eslint-enable complexity */
25 changes: 11 additions & 14 deletions packages/console/src/pages/WebhookDetails/WebhookLogs/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Log, InteractionHookEvent } from '@logto/schemas';
import { hookEvents, type Log } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
Expand All @@ -8,8 +8,8 @@ import { z } from 'zod';
import EventSelector from '@/components/AuditLogTable/components/EventSelector';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import { defaultPageSize } from '@/consts';
import { hookEventLabel, hookEventLogKey } from '@/consts/webhooks';
import DynamicT from '@/ds-components/DynamicT';
import { isDevFeaturesEnabled } from '@/consts/env';
import { interactionHookEvents } from '@/consts/webhooks';
import Table from '@/ds-components/Table';
import Tag from '@/ds-components/Tag';
import { type RequestError } from '@/hooks/use-api';
Expand All @@ -18,13 +18,16 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import { buildUrl } from '@/utils/url';

import { type WebhookDetailsOutletContext } from '../types';
import { buildHookEventLogKey, getHookEventKey } from '../utils';

import * as styles from './index.module.scss';

// TODO: Implement all hook events
const hookLogEventOptions = Object.values(InteractionHookEvent).map((event) => ({
title: <DynamicT forKey={hookEventLabel[event]} />,
value: hookEventLogKey[event],
// TODO: Remove dev feature guard
const webhookEvents = isDevFeaturesEnabled ? hookEvents : interactionHookEvents;

const hookLogEventOptions = webhookEvents.map((event) => ({
title: event,
value: buildHookEventLogKey(event),
}));

function WebhookLogs() {
Expand Down Expand Up @@ -96,13 +99,7 @@ function WebhookLogs() {
title: t('logs.event'),
dataIndex: 'event',
colSpan: 6,
render: ({ key }) => {
// TODO: Implement all hook events
const event = Object.values(InteractionHookEvent).find(
(event) => hookEventLogKey[event] === key
);
return conditional(event && t(hookEventLabel[event])) ?? '-';
},
render: ({ key }) => getHookEventKey(key),
},
{
title: t('logs.time'),
Expand Down
19 changes: 18 additions & 1 deletion packages/console/src/pages/WebhookDetails/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Hook } from '@logto/schemas';
import { hookEvents, type Hook, type HookEvent, type WebhookLogKey } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';

import { type WebhookDetailsFormType } from './types';
Expand Down Expand Up @@ -47,3 +47,20 @@ export const webhookDetailsParser = {
};
},
};

export const buildHookEventLogKey = (event: HookEvent): WebhookLogKey => `TriggerHook.${event}`;

export const isWebhookEventLogKey = (logKey: string): logKey is WebhookLogKey => {
const [prefix, ...events] = logKey.split('.');

// eslint-disable-next-line no-restricted-syntax
return prefix === 'TriggerHook' && hookEvents.includes(events.join('.') as HookEvent);
};

export const getHookEventKey = (logKey: string) => {
if (!isWebhookEventLogKey(logKey)) {
return ' - ';
}

return logKey.replace('TriggerHook.', '');
};
Loading

0 comments on commit e04d952

Please sign in to comment.