feat: Version 2

Closes #627, closes #1047
This commit is contained in:
Maksim Eltyshev
2025-05-10 02:09:06 +02:00
parent ad7fb51cfa
commit 2ee1166747
1557 changed files with 76832 additions and 47042 deletions
@@ -0,0 +1,213 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Button, Dropdown, Form, Icon, Input } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import {
useEscapeInterceptor,
useForm,
useNestedRef,
usePopupInClosableContext,
} from '../../../hooks';
import { NotificationServiceFormats } from '../../../constants/Enums';
import ConfirmationStep from '../../common/ConfirmationStep';
import styles from './Item.module.scss';
const Item = React.memo(({ id }) => {
const selectNotificationServiceById = useMemo(
() => selectors.makeSelectNotificationServiceById(),
[],
);
const notificationService = useSelector((state) => selectNotificationServiceById(state, id));
const dispatch = useDispatch();
const defaultData = useMemo(
() => ({
url: notificationService.url,
format: notificationService.format,
}),
[notificationService.url, notificationService.format],
);
const prevDefaultData = usePrevious(defaultData);
const [data, handleFieldChange, setData] = useForm(() => ({
url: '',
format: NotificationServiceFormats.MARKDOWN,
...defaultData,
}));
const [blurUrlFieldState, blurUrlField] = useToggle();
const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef');
const isUrlFocusedRef = useRef(false);
const resetUrl = useCallback(() => {
setData((prevData) => ({
...prevData,
url: defaultData.url,
}));
}, [defaultData.url, setData]);
const handleUrlEscape = useCallback(() => {
resetUrl();
blurUrlField();
}, [blurUrlField, resetUrl]);
const [activateEscapeInterceptor, deactivateEscapeInterceptor] =
useEscapeInterceptor(handleUrlEscape);
const handleUrlFocus = useCallback(() => {
activateEscapeInterceptor();
isUrlFocusedRef.current = true;
}, [activateEscapeInterceptor]);
const handleUrlBlur = useCallback(() => {
deactivateEscapeInterceptor();
isUrlFocusedRef.current = false;
const cleanUrl = data.url.trim();
if (!cleanUrl) {
resetUrl();
return;
}
if (cleanUrl !== defaultData.url) {
dispatch(
entryActions.updateNotificationService(id, {
url: cleanUrl,
}),
);
}
}, [
id,
dispatch,
defaultData.url,
data.url,
isUrlFocusedRef,
resetUrl,
deactivateEscapeInterceptor,
]);
const handleFormatChange = useCallback(
(_, { value: format }) => {
setData((prevData) => ({
...prevData,
format,
}));
dispatch(
entryActions.updateNotificationService(id, {
format,
}),
);
},
[id, dispatch, setData],
);
const handleTestClick = useCallback(() => {
dispatch(entryActions.testNotificationService(id));
}, [id, dispatch]);
const handleDeleteConfirm = useCallback(() => {
dispatch(entryActions.deleteNotificationService(id));
}, [id, dispatch]);
const handleUpdateSubmit = useCallback(() => {
urlFieldRef.current.blur();
}, [urlFieldRef]);
useDidUpdate(() => {
const nextData = {};
if (!isUrlFocusedRef.current && defaultData.url !== prevDefaultData.url) {
nextData.url = defaultData.url;
}
if (defaultData.format !== prevDefaultData.format) {
nextData.format = defaultData.format;
}
if (Object.keys(nextData).length > 0) {
setData((prevData) => ({
...prevData,
...nextData,
}));
}
}, [defaultData, prevDefaultData]);
useDidUpdate(() => {
urlFieldRef.current.blur();
}, [blurUrlFieldState]);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
<Form className={styles.wrapper} onSubmit={handleUpdateSubmit}>
<Input
ref={handleUrlFieldRef}
name="url"
value={data.url}
maxLength={512}
readOnly={!notificationService.isPersisted}
label={
<Dropdown
basic
compact
value={data.format}
options={[
NotificationServiceFormats.TEXT,
NotificationServiceFormats.MARKDOWN,
NotificationServiceFormats.HTML,
].map((format) => ({
text: format,
value: format,
}))}
disabled={!notificationService.isPersisted}
onChange={handleFormatChange}
/>
}
labelPosition="right"
className={styles.field}
onFocus={handleUrlFocus}
onChange={handleFieldChange}
onBlur={handleUrlBlur}
/>
<Button
type="button"
loading={notificationService.isTesting}
disabled={!notificationService.isPersisted || notificationService.isTesting}
className={styles.button}
onClick={handleTestClick}
>
<Icon fitted name="paper plane outline" />
</Button>
<ConfirmationPopup
title="common.deleteNotificationService"
content="common.areYouSureYouWantToDeleteThisNotificationService"
buttonContent="action.deleteNotificationService"
onConfirm={handleDeleteConfirm}
>
<Button type="button" disabled={!notificationService.isPersisted} className={styles.button}>
<Icon fitted name="trash alternate outline" />
</Button>
</ConfirmationPopup>
</Form>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
};
export default Item;
@@ -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) {
.button {
margin: 0 0 0 8px;
}
.field {
flex: auto;
input {
width: auto;
}
}
.wrapper {
display: flex;
}
}
@@ -0,0 +1,134 @@
/*!
* 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 } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Trans } from 'react-i18next';
import { Button, Dropdown, Form, Icon, Input, Message } from 'semantic-ui-react';
import { useDidUpdate, useToggle } from '../../../lib/hooks';
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
import { NotificationServiceFormats } from '../../../constants/Enums';
import Item from './Item';
import styles from './NotificationServices.module.scss';
const DEFAULT_DATA = {
url: '',
format: NotificationServiceFormats.MARKDOWN,
};
const NotificationServices = React.memo(({ ids, onCreate }) => {
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [focusUrlFieldState, focusUrlField] = useToggle();
const [urlFieldRef, handleUrlFieldRef] = useNestedRef('inputRef');
const handleUrlEscape = useCallback(() => {
urlFieldRef.current.blur();
}, [urlFieldRef]);
const [activateEscapeInterceptor, deactivateEscapeInterceptor] =
useEscapeInterceptor(handleUrlEscape);
const handleCreateSubmit = useCallback(() => {
const cleanData = {
...data,
url: data.url.trim(),
};
if (!cleanData.url) {
urlFieldRef.current.select();
return;
}
onCreate(cleanData);
setData(DEFAULT_DATA);
focusUrlField();
}, [onCreate, data, setData, focusUrlField, urlFieldRef]);
const handleUrlFocus = useCallback(() => {
activateEscapeInterceptor();
}, [activateEscapeInterceptor]);
const handleUrlBlur = useCallback(() => {
deactivateEscapeInterceptor();
}, [deactivateEscapeInterceptor]);
useEffect(() => {
if (urlFieldRef.current) {
urlFieldRef.current.focus();
}
}, [urlFieldRef]);
useDidUpdate(() => {
if (urlFieldRef.current) {
urlFieldRef.current.focus();
}
}, [focusUrlFieldState]);
return (
<>
<Message>
<Trans i18nKey="common.plankaUsesAppriseToSendNotificationsToOver100PopularServices">
{'PLANKA uses '}
<a href="https://github.com/caronc/apprise/wiki" target="_blank" rel="noreferrer">
<b>Apprise</b>
</a>
{' to send notifications to over 100 popular services.'}
</Trans>
</Message>
{ids.map((id) => (
<div key={id} className={styles.item}>
<Item id={id} />
</div>
))}
{ids.length < 5 && (
<Form className={classNames(styles.item, styles.addItem)} onSubmit={handleCreateSubmit}>
<Input
ref={handleUrlFieldRef}
name="url"
value={data.url}
placeholder="service://hostname/token"
maxLength={512}
label={
<Dropdown
basic
compact
name="format"
value={data.format}
options={[
NotificationServiceFormats.TEXT,
NotificationServiceFormats.MARKDOWN,
NotificationServiceFormats.HTML,
].map((format) => ({
text: format,
value: format,
}))}
onChange={handleFieldChange}
/>
}
labelPosition="right"
className={styles.field}
onFocus={handleUrlFocus}
onChange={handleFieldChange}
onBlur={handleUrlBlur}
/>
<Button positive className={styles.button}>
<Icon fitted name="plus" />
</Button>
</Form>
)}
</>
);
});
NotificationServices.propTypes = {
ids: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
onCreate: PropTypes.func.isRequired,
};
export default NotificationServices;
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.addItem {
display: flex;
}
.button {
margin: 0 0 0 8px;
}
.field {
flex: auto;
input {
width: auto;
}
}
.item:not(:last-child) {
margin-bottom: 12px;
}
}
@@ -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 NotificationServices from './NotificationServices';
export default NotificationServices;