Merge pull request #1650 from symonbaikov/feature/webhook-scope-filter

feat: filter webhooks per project, board, and account
This commit is contained in:
Daniel Hiller
2026-09-17 02:00:30 +02:00
committed by GitHub
12 changed files with 446 additions and 131 deletions
@@ -15,6 +15,9 @@ import styles from './WebhooksPane.module.scss';
const WebhooksPane = React.memo(() => {
const webhookIds = useSelector(selectors.selectWebhookIds);
const projects = useSelector(selectors.selectProjects);
const boards = useSelector(selectors.selectBoards);
const users = useSelector(selectors.selectUsers);
const dispatch = useDispatch();
@@ -27,7 +30,13 @@ const WebhooksPane = React.memo(() => {
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Webhooks ids={webhookIds} onCreate={handleCreate} />
<Webhooks
ids={webhookIds}
projects={projects}
boards={boards}
users={users}
onCreate={handleCreate}
/>
</Tab.Pane>
);
});
+210 -120
View File
@@ -3,7 +3,7 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useImperativeHandle } from 'react';
import React, { useCallback, useImperativeHandle, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Dropdown, Input } from 'semantic-ui-react';
@@ -13,134 +13,224 @@ import WEBHOOK_EVENTS from '../../../constants/WebhookEvents';
import styles from './Editor.module.scss';
const Editor = React.forwardRef(({ data, isReadOnly, onFieldChange }, ref) => {
const [t] = useTranslation();
const Editor = React.forwardRef(
({ data, projects, boards, users, isReadOnly, onFieldChange }, ref) => {
const [t] = useTranslation();
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef');
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef');
const focusNameField = useCallback(() => {
nameFieldRef.current.focus({
preventScroll: true,
});
}, [nameFieldRef]);
const focusNameField = useCallback(() => {
nameFieldRef.current.focus({
preventScroll: true,
});
}, [nameFieldRef]);
const selectNameField = useCallback(() => {
nameFieldRef.current.select();
}, [nameFieldRef]);
const selectNameField = useCallback(() => {
nameFieldRef.current.select();
}, [nameFieldRef]);
const selectUrlField = useCallback(() => {
urlFieldRef.current.select();
}, [urlFieldRef]);
const selectUrlField = useCallback(() => {
urlFieldRef.current.select();
}, [urlFieldRef]);
useImperativeHandle(
ref,
() => ({
focusNameField,
selectNameField,
selectUrlField,
}),
[focusNameField, selectNameField, selectUrlField],
);
useImperativeHandle(
ref,
() => ({
focusNameField,
selectNameField,
selectUrlField,
}),
[focusNameField, selectNameField, selectUrlField],
);
return (
<>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={handleNameFieldRef}
name="name"
value={data.name}
maxLength={128}
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
<div className={styles.text}>{t('common.url')}</div>
<Input
fluid
ref={handleUrlFieldRef}
name="url"
value={data.url}
maxLength={2048}
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
<div className={styles.text}>
{t('common.accessToken')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Input
fluid
name="accessToken"
value={data.accessToken}
maxLength={512}
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
{data.excludedEvents.length === 0 && (
<>
<div className={styles.text}>
{t('common.events')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
multiple
fluid
name="events"
options={WEBHOOK_EVENTS.map((event) => ({
text: event,
value: event,
}))}
value={data.events}
placeholder="All"
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
</>
)}
{data.events.length === 0 && (
<>
<div className={styles.text}>
{t('common.excludedEvents')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
multiple
fluid
name="excludedEvents"
options={WEBHOOK_EVENTS.map((event) => ({
text: event,
value: event,
}))}
value={data.excludedEvents}
placeholder="None"
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
</>
)}
</>
);
});
const handleScopeChange = useCallback(
(_, { name, value }) => {
onFieldChange(null, { name, value: value === '' ? null : value });
},
[onFieldChange],
);
const projectOptions = useMemo(
() => projects.map((project) => ({ text: project.name, value: project.id })),
[projects],
);
const boardOptions = useMemo(() => {
const filtered = data.projectId
? boards.filter((board) => board.projectId === data.projectId)
: boards;
return filtered.map((board) => ({ text: board.name, value: board.id }));
}, [boards, data.projectId]);
const userOptions = useMemo(
() =>
users.map((user) => ({
text: user.name || user.username || user.email,
value: user.id,
})),
[users],
);
return (
<>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={handleNameFieldRef}
name="name"
value={data.name}
maxLength={128}
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
<div className={styles.text}>{t('common.url')}</div>
<Input
fluid
ref={handleUrlFieldRef}
name="url"
value={data.url}
maxLength={2048}
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
<div className={styles.text}>
{t('common.accessToken')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Input
fluid
name="accessToken"
value={data.accessToken}
maxLength={512}
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
<div className={styles.text}>
{t('common.project')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
clearable
fluid
name="projectId"
options={projectOptions}
value={data.projectId || ''}
placeholder={t('common.all')}
disabled={isReadOnly}
className={styles.field}
onChange={handleScopeChange}
/>
<div className={styles.text}>
{t('common.board')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
clearable
fluid
name="boardId"
options={boardOptions}
value={data.boardId || ''}
placeholder={t('common.all')}
disabled={isReadOnly}
className={styles.field}
onChange={handleScopeChange}
/>
<div className={styles.text}>
{t('common.account')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
clearable
fluid
name="userId"
options={userOptions}
value={data.userId || ''}
placeholder={t('common.all')}
disabled={isReadOnly}
className={styles.field}
onChange={handleScopeChange}
/>
{data.excludedEvents.length === 0 && (
<>
<div className={styles.text}>
{t('common.events')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
multiple
fluid
name="events"
options={WEBHOOK_EVENTS.map((event) => ({
text: event,
value: event,
}))}
value={data.events}
placeholder="All"
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
</>
)}
{data.events.length === 0 && (
<>
<div className={styles.text}>
{t('common.excludedEvents')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Dropdown
selection
multiple
fluid
name="excludedEvents"
options={WEBHOOK_EVENTS.map((event) => ({
text: event,
value: event,
}))}
value={data.excludedEvents}
placeholder="None"
readOnly={isReadOnly}
className={styles.field}
onChange={onFieldChange}
/>
</>
)}
</>
);
},
);
Editor.propTypes = {
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
isReadOnly: PropTypes.bool,
onFieldChange: PropTypes.func.isRequired,
};
@@ -20,7 +20,7 @@ import ConfirmationStep from '../../common/ConfirmationStep';
import styles from './Item.module.scss';
import { useToggle } from '../../../lib/hooks';
const Item = React.memo(({ id }) => {
const Item = React.memo(({ id, projects, boards, users }) => {
const selectWebhookById = useMemo(() => selectors.makeSelectWebhookById(), []);
const webhook = useSelector((state) => selectWebhookById(state, id));
@@ -36,6 +36,9 @@ const Item = React.memo(({ id }) => {
accessToken: webhook.accessToken,
events: webhook.events,
excludedEvents: webhook.excludedEvents,
projectId: webhook.projectId,
boardId: webhook.boardId,
userId: webhook.userId,
}),
[webhook],
);
@@ -47,6 +50,9 @@ const Item = React.memo(({ id }) => {
accessToken: defaultData.accessToken || '',
events: defaultData.events || [],
excludedEvents: defaultData.excludedEvents || [],
projectId: defaultData.projectId || null,
boardId: defaultData.boardId || null,
userId: defaultData.userId || null,
}));
const cleanData = useMemo(
@@ -57,6 +63,9 @@ const Item = React.memo(({ id }) => {
accessToken: data.accessToken.trim() || null,
events: data.events.length === 0 ? null : data.events,
excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents,
projectId: data.projectId || null,
boardId: data.boardId || null,
userId: data.userId || null,
}),
[data],
);
@@ -99,6 +108,9 @@ const Item = React.memo(({ id }) => {
<Editor
ref={editorRef}
data={data}
projects={projects}
boards={boards}
users={users}
isReadOnly={!webhook.isPersisted}
onFieldChange={handleFieldChange}
/>
@@ -132,6 +144,9 @@ const Item = React.memo(({ id }) => {
Item.propTypes = {
id: PropTypes.string.isRequired,
projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
};
export default Item;
@@ -15,6 +15,9 @@ import Item from './Item';
import Editor from './Editor';
const DEFAULT_DATA = {
projectId: null,
boardId: null,
userId: null,
name: '',
url: '',
accessToken: '',
@@ -22,7 +25,7 @@ const DEFAULT_DATA = {
excludedEvents: [],
};
const Webhooks = React.memo(({ ids, onCreate }) => {
const Webhooks = React.memo(({ ids, projects, boards, users, onCreate }) => {
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
@@ -38,6 +41,9 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
accessToken: data.accessToken.trim() || null,
events: data.events.length === 0 ? null : data.events,
excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents,
projectId: data.projectId || null,
boardId: data.boardId || null,
userId: data.userId || null,
};
if (!cleanData.name) {
@@ -72,14 +78,21 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
{ids.length > 0 && (
<Accordion styled fluid>
{ids.map((id) => (
<Item key={id} id={id} />
<Item key={id} id={id} projects={projects} boards={boards} users={users} />
))}
</Accordion>
)}
{ids.length < 10 && (
<Segment>
<Form onSubmit={handleCreateSubmit}>
<Editor ref={editorRef} data={data} onFieldChange={handleFieldChange} />
<Editor
ref={editorRef}
data={data}
projects={projects}
boards={boards}
users={users}
onFieldChange={handleFieldChange}
/>
<Button positive>{t('action.addWebhook')}</Button>
</Form>
</Segment>
@@ -90,6 +103,9 @@ const Webhooks = React.memo(({ ids, onCreate }) => {
Webhooks.propTypes = {
ids: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
projects: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
boards: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
users: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
onCreate: PropTypes.func.isRequired,
};
+10
View File
@@ -18,11 +18,21 @@ export default class extends BaseModel {
accessToken: attr(),
events: attr(),
excludedEvents: attr(),
projectId: fk({
to: 'Project',
as: 'project',
relatedName: 'webhooks',
}),
boardId: fk({
to: 'Board',
as: 'board',
relatedName: 'webhooks',
}),
userId: fk({
to: 'User',
as: 'user',
relatedName: 'webhooks',
}),
};
static reducer({ type, payload }, Webhook) {
+3
View File
@@ -12,6 +12,8 @@ import { isLocalId } from '../utils/local-id';
import { isListArchiveOrTrash } from '../utils/record-helpers';
import { ListTypes } from '../constants/Enums';
export const selectBoards = createSelector(orm, ({ Board }) => Board.all().toRefArray());
export const makeSelectBoardById = () =>
createSelector(
orm,
@@ -484,6 +486,7 @@ export const selectIsBoardWithIdExists = createSelector(
);
export default {
selectBoards,
makeSelectBoardById,
selectBoardById,
makeSelectCurrentUserMembershipByBoardId,
+3
View File
@@ -10,6 +10,8 @@ import { selectPath } from './router';
import { selectCurrentUserId } from './users';
import { isLocalId } from '../utils/local-id';
export const selectProjects = createSelector(orm, ({ Project }) => Project.all().toRefArray());
export const makeSelectProjectById = () =>
createSelector(
orm,
@@ -311,6 +313,7 @@ export const selectIsCurrentUserManagerForCurrentProject = createSelector(
);
export default {
selectProjects,
makeSelectProjectById,
selectProjectById,
makeSelectBoardIdsByProjectId,