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:
parent
f0680831c2
commit
b22dba0d11
128 changed files with 2077 additions and 206 deletions
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
|
152
client/src/components/webhooks/Webhooks/Editor.jsx
Normal file
152
client/src/components/webhooks/Webhooks/Editor.jsx
Normal 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);
|
17
client/src/components/webhooks/Webhooks/Editor.module.scss
Normal file
17
client/src/components/webhooks/Webhooks/Editor.module.scss
Normal 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;
|
||||
}
|
||||
}
|
137
client/src/components/webhooks/Webhooks/Item.jsx
Normal file
137
client/src/components/webhooks/Webhooks/Item.jsx
Normal 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;
|
22
client/src/components/webhooks/Webhooks/Item.module.scss
Normal file
22
client/src/components/webhooks/Webhooks/Item.module.scss
Normal 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;
|
||||
}
|
||||
}
|
96
client/src/components/webhooks/Webhooks/Webhooks.jsx
Normal file
96
client/src/components/webhooks/Webhooks/Webhooks.jsx
Normal 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;
|
8
client/src/components/webhooks/Webhooks/index.js
Normal file
8
client/src/components/webhooks/Webhooks/index.js
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue