1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-19 13:19:44 +02:00

feat: Move webhooks configuration from environment variable to UI

This commit is contained in:
Maksim Eltyshev 2025-07-04 22:04:11 +02:00
parent f0680831c2
commit b22dba0d11
128 changed files with 2077 additions and 206 deletions

View file

@ -12,6 +12,7 @@ import { Modal, Tab } from 'semantic-ui-react';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import UsersPane from './UsersPane';
import WebhooksPane from './WebhooksPane';
import styles from './AdministrationModal.module.scss';
@ -37,6 +38,12 @@ const AdministrationModal = React.memo(() => {
}),
render: () => <UsersPane />,
},
{
menuItem: t('common.webhooks', {
context: 'title',
}),
render: () => <WebhooksPane />,
},
];
const isUsersPaneActive = activeTabIndex === 0;

View file

@ -0,0 +1,35 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import Webhooks from '../../webhooks/Webhooks';
import styles from './WebhooksPane.module.scss';
const WebhooksPane = React.memo(() => {
const webhookIds = useSelector(selectors.selectWebhookIds);
const dispatch = useDispatch();
const handleCreate = useCallback(
(data) => {
dispatch(entryActions.createWebhook(data));
},
[dispatch],
);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Webhooks ids={webhookIds} onCreate={handleCreate} />
</Tab.Pane>
);
});
export default WebhooksPane;

View file

@ -0,0 +1,11 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
border: none;
box-shadow: none;
}
}

View file

@ -28,21 +28,26 @@ const ConfirmationStep = React.memo(
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
if (typeValue) {
const cleanData = {
...data,
typeValue: data.typeValue.trim(),
};
const handleSubmit = useCallback(
(event) => {
event.stopPropagation();
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
nameFieldRef.current.select();
return;
if (typeValue) {
const cleanData = {
...data,
typeValue: data.typeValue.trim(),
};
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
nameFieldRef.current.select();
return;
}
}
}
onConfirm();
}, [typeValue, onConfirm, data, nameFieldRef]);
onConfirm();
},
[typeValue, onConfirm, data, nameFieldRef],
);
useEffect(() => {
if (typeValue) {

View file

@ -125,7 +125,7 @@ const Item = React.memo(({ id }) => {
dispatch(entryActions.deleteNotificationService(id));
}, [id, dispatch]);
const handleUpdateSubmit = useCallback(() => {
const handleSubmit = useCallback(() => {
urlFieldRef.current.blur();
}, [urlFieldRef]);
@ -153,7 +153,7 @@ const Item = React.memo(({ id }) => {
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
<Form className={styles.wrapper} onSubmit={handleUpdateSubmit}>
<Form className={styles.wrapper} onSubmit={handleSubmit}>
<Input
ref={handleUrlFieldRef}
name="url"

View file

@ -0,0 +1,152 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useImperativeHandle } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Dropdown, Input } from 'semantic-ui-react';
import { useNestedRef } from '../../../hooks';
import WEBHOOK_EVENTS from '../../../constants/WebhookEvents';
import styles from './Editor.module.scss';
const Editor = React.forwardRef(({ data, isReadOnly, onFieldChange }, ref) => {
const [t] = useTranslation();
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef');
const focusNameField = useCallback(() => {
nameFieldRef.current.focus({
preventScroll: true,
});
}, [nameFieldRef]);
const selectNameField = useCallback(() => {
nameFieldRef.current.select();
}, [nameFieldRef]);
const selectUrlField = useCallback(() => {
urlFieldRef.current.select();
}, [urlFieldRef]);
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}
/>
</>
)}
</>
);
});
Editor.propTypes = {
data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isReadOnly: PropTypes.bool,
onFieldChange: PropTypes.func.isRequired,
};
Editor.defaultProps = {
isReadOnly: false,
};
export default React.memo(Editor);

View file

@ -0,0 +1,17 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View file

@ -0,0 +1,137 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import { dequal } from 'dequal';
import React, { useCallback, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Accordion, Button, Form, Icon } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, usePopupInClosableContext } from '../../../hooks';
import { isUrl } from '../../../utils/validator';
import Editor from './Editor';
import ConfirmationStep from '../../common/ConfirmationStep';
import styles from './Item.module.scss';
import { useToggle } from '../../../lib/hooks';
const Item = React.memo(({ id }) => {
const selectWebhookById = useMemo(() => selectors.makeSelectWebhookById(), []);
const webhook = useSelector((state) => selectWebhookById(state, id));
const dispatch = useDispatch();
const [t] = useTranslation();
const [isOpened, toggleOpened] = useToggle();
const defaultData = useMemo(
() => ({
name: webhook.name,
url: webhook.url,
accessToken: webhook.accessToken,
events: webhook.events,
excludedEvents: webhook.excludedEvents,
}),
[webhook],
);
const [data, handleFieldChange] = useForm(() => ({
name: '',
url: '',
...defaultData,
accessToken: defaultData.accessToken || '',
events: defaultData.events || [],
excludedEvents: defaultData.excludedEvents || [],
}));
const cleanData = useMemo(
() => ({
...data,
name: data.name.trim(),
url: data.url.trim(),
accessToken: data.accessToken.trim() || null,
events: data.events.length === 0 ? null : data.events,
excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents,
}),
[data],
);
const editorRef = useRef(null);
const handleDeleteConfirm = useCallback(() => {
dispatch(entryActions.deleteWebhook(id));
}, [id, dispatch]);
const handleSubmit = useCallback(() => {
if (!cleanData.name) {
editorRef.current.selectNameField();
return;
}
if (!cleanData.url || !isUrl(cleanData.url)) {
editorRef.current.selectUrlField();
return;
}
dispatch(entryActions.updateWebhook(id, cleanData));
}, [id, dispatch, cleanData]);
const handleOpenClick = useCallback(() => {
toggleOpened();
}, [toggleOpened]);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
<>
<Accordion.Title active={isOpened} className={styles.title} onClick={handleOpenClick}>
<Icon name="dropdown" />
{defaultData.name}
</Accordion.Title>
<Accordion.Content active={isOpened}>
<div>
<Form onSubmit={handleSubmit}>
<Editor
ref={editorRef}
data={data}
isReadOnly={!webhook.isPersisted}
onFieldChange={handleFieldChange}
/>
<div className={styles.controls}>
<Button
positive
disabled={dequal(cleanData, defaultData)}
content={t('action.save')}
/>
<ConfirmationPopup
title="common.deleteWebhook"
content="common.areYouSureYouWantToDeleteThisWebhook"
buttonContent="action.deleteWebhook"
onConfirm={handleDeleteConfirm}
>
<Button
type="button"
disabled={!webhook.isPersisted}
className={styles.deleteButton}
>
{t('action.delete')}
</Button>
</ConfirmationPopup>
</div>
</Form>
</div>
</Accordion.Content>
</>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
};
export default Item;

View file

@ -0,0 +1,22 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.controls {
display: flex;
justify-content: space-between;
}
.deleteButton {
box-shadow: 0 1px 0 #cbcccc;
margin-right: 0;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View file

@ -0,0 +1,96 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Accordion, Button, Form, Segment } from 'semantic-ui-react';
import { useDidUpdate, useToggle } from '../../../lib/hooks';
import { useForm } from '../../../hooks';
import { isUrl } from '../../../utils/validator';
import Item from './Item';
import Editor from './Editor';
const DEFAULT_DATA = {
name: '',
url: '',
accessToken: '',
events: [],
excludedEvents: [],
};
const Webhooks = React.memo(({ ids, onCreate }) => {
const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [focusNameFieldState, focusNameField] = useToggle();
const editorRef = useRef(null);
const handleCreateSubmit = useCallback(() => {
const cleanData = {
...data,
name: data.name.trim(),
url: data.url.trim(),
accessToken: data.accessToken.trim() || null,
events: data.events.length === 0 ? null : data.events,
excludedEvents: data.excludedEvents.length === 0 ? null : data.excludedEvents,
};
if (!cleanData.name) {
editorRef.current.selectNameField();
return;
}
if (!cleanData.url || !isUrl(cleanData.url)) {
editorRef.current.selectUrlField();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
focusNameField();
}, [onCreate, data, setData, focusNameField]);
useEffect(() => {
if (editorRef.current) {
editorRef.current.focusNameField();
}
}, []);
useDidUpdate(() => {
if (editorRef.current) {
editorRef.current.focusNameField();
}
}, [focusNameFieldState]);
return (
<>
{ids.length > 0 && (
<Accordion styled fluid>
{ids.map((id) => (
<Item key={id} id={id} />
))}
</Accordion>
)}
{ids.length < 10 && (
<Segment>
<Form onSubmit={handleCreateSubmit}>
<Editor ref={editorRef} data={data} onFieldChange={handleFieldChange} />
<Button positive>{t('action.addWebhook')}</Button>
</Form>
</Segment>
)}
</>
);
});
Webhooks.propTypes = {
ids: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
onCreate: PropTypes.func.isRequired,
};
export default Webhooks;

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import Webhooks from './Webhooks';
export default Webhooks;