mirror of
https://github.com/plankanban/planka.git
synced 2025-12-17 01:11:23 +03:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ea10df97 | ||
|
|
d9e8c24c3f | ||
|
|
f75b0237d3 | ||
|
|
38bc4cb0a0 | ||
|
|
cc95032e74 | ||
|
|
1d2193c381 | ||
|
|
70f40e26af | ||
|
|
814b5810ac | ||
|
|
f372113def | ||
|
|
14dff96434 |
@@ -15,13 +15,13 @@ type: application
|
|||||||
# This is the chart version. This version number should be incremented each time you make changes
|
# This is the chart version. This version number should be incremented each time you make changes
|
||||||
# to the chart and its templates, including the app version.
|
# to the chart and its templates, including the app version.
|
||||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||||
version: 0.2.12
|
version: 0.2.14
|
||||||
|
|
||||||
# This is the version number of the application being deployed. This version number should be
|
# This is the version number of the application being deployed. This version number should be
|
||||||
# incremented each time you make changes to the application. Versions are not expected to
|
# incremented each time you make changes to the application. Versions are not expected to
|
||||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||||
# It is recommended to use it with quotes.
|
# It is recommended to use it with quotes.
|
||||||
appVersion: "1.23.3"
|
appVersion: "1.23.5"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- alias: postgresql
|
- alias: postgresql
|
||||||
|
|||||||
6
client/package-lock.json
generated
6
client/package-lock.json
generated
@@ -10956,9 +10956,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/http-proxy-middleware": {
|
"node_modules/http-proxy-middleware": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
|
||||||
"integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==",
|
"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-proxy": "^1.17.8",
|
"@types/http-proxy": "^1.17.8",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { focusEnd } from '../../../utils/element-helpers';
|
|||||||
|
|
||||||
import styles from './CommentEdit.module.scss';
|
import styles from './CommentEdit.module.scss';
|
||||||
|
|
||||||
const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref) => {
|
const CommentEdit = React.forwardRef(({ defaultData, onUpdate, text, actions }, ref) => {
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [isOpened, setIsOpened] = useState(false);
|
const [isOpened, setIsOpened] = useState(false);
|
||||||
const [data, handleFieldChange, setData] = useForm(null);
|
const [data, handleFieldChange, setData] = useForm(null);
|
||||||
@@ -76,7 +76,12 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
|
|||||||
}, [isOpened]);
|
}, [isOpened]);
|
||||||
|
|
||||||
if (!isOpened) {
|
if (!isOpened) {
|
||||||
return children;
|
return (
|
||||||
|
<>
|
||||||
|
{actions}
|
||||||
|
{text}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,9 +106,10 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
|
|||||||
});
|
});
|
||||||
|
|
||||||
CommentEdit.propTypes = {
|
CommentEdit.propTypes = {
|
||||||
children: PropTypes.element.isRequired,
|
|
||||||
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||||
onUpdate: PropTypes.func.isRequired,
|
onUpdate: PropTypes.func.isRequired,
|
||||||
|
text: PropTypes.element.isRequired,
|
||||||
|
actions: PropTypes.element.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default React.memo(CommentEdit);
|
export default React.memo(CommentEdit);
|
||||||
|
|||||||
@@ -31,44 +31,51 @@ const ItemComment = React.memo(
|
|||||||
<User name={user.name} avatarUrl={user.avatarUrl} />
|
<User name={user.name} avatarUrl={user.avatarUrl} />
|
||||||
</span>
|
</span>
|
||||||
<div className={classNames(styles.content)}>
|
<div className={classNames(styles.content)}>
|
||||||
<div className={styles.title}>
|
<CommentEdit
|
||||||
<span className={styles.author}>{user.name}</span>
|
ref={commentEdit}
|
||||||
<span className={styles.date}>
|
defaultData={data}
|
||||||
{t(`format:${getDateFormat(createdAt)}`, {
|
onUpdate={onUpdate}
|
||||||
postProcess: 'formatDate',
|
text={
|
||||||
value: createdAt,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<CommentEdit ref={commentEdit} defaultData={data} onUpdate={onUpdate}>
|
|
||||||
<>
|
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<Markdown linkTarget="_blank">{data.text}</Markdown>
|
<Markdown linkTarget="_blank">{data.text}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
}
|
||||||
<Comment.Actions>
|
actions={
|
||||||
<Comment.Action
|
<div className={styles.title}>
|
||||||
as="button"
|
<span>
|
||||||
content={t('action.edit')}
|
<span className={styles.author}>{user.name}</span>
|
||||||
disabled={!isPersisted}
|
<span className={styles.date}>
|
||||||
onClick={handleEditClick}
|
{t(`format:${getDateFormat(createdAt)}`, {
|
||||||
/>
|
postProcess: 'formatDate',
|
||||||
<DeletePopup
|
value: createdAt,
|
||||||
title="common.deleteComment"
|
})}
|
||||||
content="common.areYouSureYouWantToDeleteThisComment"
|
</span>
|
||||||
buttonContent="action.deleteComment"
|
</span>
|
||||||
onConfirm={onDelete}
|
{canEdit && (
|
||||||
>
|
<Comment.Actions>
|
||||||
<Comment.Action
|
<Comment.Action
|
||||||
as="button"
|
as="button"
|
||||||
content={t('action.delete')}
|
content={t('action.edit')}
|
||||||
disabled={!isPersisted}
|
disabled={!isPersisted}
|
||||||
|
onClick={handleEditClick}
|
||||||
/>
|
/>
|
||||||
</DeletePopup>
|
<DeletePopup
|
||||||
</Comment.Actions>
|
title="common.deleteComment"
|
||||||
)}
|
content="common.areYouSureYouWantToDeleteThisComment"
|
||||||
</>
|
buttonContent="action.deleteComment"
|
||||||
</CommentEdit>
|
onConfirm={onDelete}
|
||||||
|
>
|
||||||
|
<Comment.Action
|
||||||
|
as="button"
|
||||||
|
content={t('action.delete')}
|
||||||
|
disabled={!isPersisted}
|
||||||
|
/>
|
||||||
|
</DeletePopup>
|
||||||
|
</Comment.Actions>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Comment>
|
</Comment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: -1em;
|
||||||
|
background: #f5f6f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user {
|
.user {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { useCallback, useImperativeHandle, useMemo, useState } from 'react';
|
import React, { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form } from 'semantic-ui-react';
|
import { Button, Form } from 'semantic-ui-react';
|
||||||
import SimpleMDE from 'react-simplemde-editor';
|
import SimpleMDE from 'react-simplemde-editor';
|
||||||
|
import { useClickAwayListener } from '../../lib/hooks';
|
||||||
|
|
||||||
|
import { useNestedRef } from '../../hooks';
|
||||||
|
|
||||||
import styles from './DescriptionEdit.module.scss';
|
import styles from './DescriptionEdit.module.scss';
|
||||||
|
|
||||||
@@ -11,6 +14,10 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
|
|||||||
const [isOpened, setIsOpened] = useState(false);
|
const [isOpened, setIsOpened] = useState(false);
|
||||||
const [value, setValue] = useState(null);
|
const [value, setValue] = useState(null);
|
||||||
|
|
||||||
|
const editorWrapperRef = useRef(null);
|
||||||
|
const codemirrorRef = useRef(null);
|
||||||
|
const [buttonRef, handleButtonRef] = useNestedRef();
|
||||||
|
|
||||||
const open = useCallback(() => {
|
const open = useCallback(() => {
|
||||||
setIsOpened(true);
|
setIsOpened(true);
|
||||||
setValue(defaultValue || '');
|
setValue(defaultValue || '');
|
||||||
@@ -55,6 +62,28 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
|
|||||||
close();
|
close();
|
||||||
}, [close]);
|
}, [close]);
|
||||||
|
|
||||||
|
const handleAwayClick = useCallback(() => {
|
||||||
|
if (!isOpened) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
close();
|
||||||
|
}, [isOpened, close]);
|
||||||
|
|
||||||
|
const handleClickAwayCancel = useCallback(() => {
|
||||||
|
codemirrorRef.current.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clickAwayProps = useClickAwayListener(
|
||||||
|
[editorWrapperRef, buttonRef],
|
||||||
|
handleAwayClick,
|
||||||
|
handleClickAwayCancel,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGetCodemirrorInstance = useCallback((codemirror) => {
|
||||||
|
codemirrorRef.current = codemirror;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const mdEditorOptions = useMemo(
|
const mdEditorOptions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
autoDownloadFontAwesome: false,
|
autoDownloadFontAwesome: false,
|
||||||
@@ -92,16 +121,20 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<SimpleMDE
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
value={value}
|
<div {...clickAwayProps} ref={editorWrapperRef}>
|
||||||
options={mdEditorOptions}
|
<SimpleMDE
|
||||||
placeholder={t('common.enterDescription')}
|
value={value}
|
||||||
className={styles.field}
|
options={mdEditorOptions}
|
||||||
onKeyDown={handleFieldKeyDown}
|
placeholder={t('common.enterDescription')}
|
||||||
onChange={setValue}
|
className={styles.field}
|
||||||
/>
|
getCodemirrorInstance={handleGetCodemirrorInstance}
|
||||||
|
onKeyDown={handleFieldKeyDown}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
<Button positive content={t('action.save')} />
|
<Button positive ref={handleButtonRef} content={t('action.save')} />
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const DEFAULT_DATA = {
|
|||||||
name: '',
|
name: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MULTIPLE_REGEX = /\s*\r?\n\s*/;
|
||||||
|
|
||||||
const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [isOpened, setIsOpened] = useState(false);
|
const [isOpened, setIsOpened] = useState(false);
|
||||||
@@ -29,22 +31,34 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
|||||||
setIsOpened(false);
|
setIsOpened(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(
|
||||||
const cleanData = {
|
(isMultiple = false) => {
|
||||||
...data,
|
const cleanData = {
|
||||||
name: data.name.trim(),
|
...data,
|
||||||
};
|
name: data.name.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
if (!cleanData.name) {
|
if (!cleanData.name) {
|
||||||
nameField.current.ref.current.select();
|
nameField.current.ref.current.select();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onCreate(cleanData);
|
if (isMultiple) {
|
||||||
|
cleanData.name.split(MULTIPLE_REGEX).forEach((name) => {
|
||||||
|
onCreate({
|
||||||
|
...cleanData,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onCreate(cleanData);
|
||||||
|
}
|
||||||
|
|
||||||
setData(DEFAULT_DATA);
|
setData(DEFAULT_DATA);
|
||||||
focusNameField();
|
focusNameField();
|
||||||
}, [onCreate, data, setData, focusNameField]);
|
},
|
||||||
|
[onCreate, data, setData, focusNameField],
|
||||||
|
);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -63,8 +77,7 @@ const Add = React.forwardRef(({ children, onCreate }, ref) => {
|
|||||||
(event) => {
|
(event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
submit(event.ctrlKey);
|
||||||
submit();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submit],
|
[submit],
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import useNestedRef from './use-nested-ref';
|
||||||
import useField from './use-field';
|
import useField from './use-field';
|
||||||
import useForm from './use-form';
|
import useForm from './use-form';
|
||||||
import useSteps from './use-steps';
|
import useSteps from './use-steps';
|
||||||
import useModal from './use-modal';
|
import useModal from './use-modal';
|
||||||
import useClosableForm from './use-closable-form';
|
import useClosableForm from './use-closable-form';
|
||||||
|
|
||||||
export { useField, useForm, useSteps, useModal, useClosableForm };
|
export { useNestedRef, useField, useForm, useSteps, useModal, useClosableForm };
|
||||||
|
|||||||
14
client/src/hooks/use-nested-ref.js
Normal file
14
client/src/hooks/use-nested-ref.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export default (nestedRefName = 'ref') => {
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
const handleRef = useCallback(
|
||||||
|
(element) => {
|
||||||
|
ref.current = element?.[nestedRefName].current;
|
||||||
|
},
|
||||||
|
[nestedRefName],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [ref, handleRef];
|
||||||
|
};
|
||||||
@@ -2,5 +2,6 @@ import usePrevious from './use-previous';
|
|||||||
import useToggle from './use-toggle';
|
import useToggle from './use-toggle';
|
||||||
import useForceUpdate from './use-force-update';
|
import useForceUpdate from './use-force-update';
|
||||||
import useDidUpdate from './use-did-update';
|
import useDidUpdate from './use-did-update';
|
||||||
|
import useClickAwayListener from './use-click-away-listener';
|
||||||
|
|
||||||
export { usePrevious, useToggle, useForceUpdate, useDidUpdate };
|
export { usePrevious, useToggle, useForceUpdate, useDidUpdate, useClickAwayListener };
|
||||||
|
|||||||
45
client/src/lib/hooks/use-click-away-listener.js
Normal file
45
client/src/lib/hooks/use-click-away-listener.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
export default (elementRefs, onAwayClick, onCancel) => {
|
||||||
|
const pressedElement = useRef(null);
|
||||||
|
|
||||||
|
const handlePress = useCallback((event) => {
|
||||||
|
pressedElement.current = event.target;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEvent = (event) => {
|
||||||
|
const element = elementRefs.find(({ current }) => current?.contains(event.target))?.current;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
if (!pressedElement.current || pressedElement.current !== element) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
} else if (pressedElement.current) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
onAwayClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
pressedElement.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleEvent, true);
|
||||||
|
document.addEventListener('touchend', handleEvent, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseup', handleEvent, true);
|
||||||
|
document.removeEventListener('touchend', handleEvent, true);
|
||||||
|
};
|
||||||
|
}, [onAwayClick, onCancel]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const props = useMemo(
|
||||||
|
() => ({
|
||||||
|
onMouseDown: handlePress,
|
||||||
|
onTouchStart: handlePress,
|
||||||
|
}),
|
||||||
|
[handlePress],
|
||||||
|
);
|
||||||
|
|
||||||
|
return props;
|
||||||
|
};
|
||||||
@@ -184,6 +184,7 @@ export default {
|
|||||||
addTask: 'Přidat úkol',
|
addTask: 'Přidat úkol',
|
||||||
addToCard: 'Přidat na kartu',
|
addToCard: 'Přidat na kartu',
|
||||||
addUser: 'Přidat uživatele',
|
addUser: 'Přidat uživatele',
|
||||||
|
copyLink_title: 'Zkopírovat odkaz',
|
||||||
createBoard: 'Vytvořit tabuli',
|
createBoard: 'Vytvořit tabuli',
|
||||||
createFile: 'Vytvořit soubor',
|
createFile: 'Vytvořit soubor',
|
||||||
createLabel: 'Vytvořit štítek',
|
createLabel: 'Vytvořit štítek',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export default {
|
|||||||
common: {
|
common: {
|
||||||
emailOrUsername: 'E-mail nebo uživatelské jméno',
|
emailOrUsername: 'E-mail nebo uživatelské jméno',
|
||||||
invalidEmailOrUsername: 'Nesprávný e-mail nebo uživatelské jméno',
|
invalidEmailOrUsername: 'Nesprávný e-mail nebo uživatelské jméno',
|
||||||
|
invalidCredentials: 'Neplatné přihlašovací údaje',
|
||||||
invalidPassword: 'Nesprávné heslo',
|
invalidPassword: 'Nesprávné heslo',
|
||||||
logInToPlanka: 'Přihlásit se do Planka',
|
logInToPlanka: 'Přihlásit se do Planka',
|
||||||
noInternetConnection: 'Bez připojení k internetu',
|
noInternetConnection: 'Bez připojení k internetu',
|
||||||
@@ -11,6 +12,7 @@ export default {
|
|||||||
projectManagement: 'Správa projektu',
|
projectManagement: 'Správa projektu',
|
||||||
serverConnectionFailed: 'Připojení k serveru selhalo',
|
serverConnectionFailed: 'Připojení k serveru selhalo',
|
||||||
unknownError: 'Neznámá chyba, zkuste to později',
|
unknownError: 'Neznámá chyba, zkuste to později',
|
||||||
|
useSingleSignOn: 'Použít jednorázové přihlášení',
|
||||||
},
|
},
|
||||||
|
|
||||||
action: {
|
action: {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export default '1.23.3';
|
export default '1.23.5';
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ services:
|
|||||||
|
|
||||||
# - SLACK_BOT_TOKEN=
|
# - SLACK_BOT_TOKEN=
|
||||||
# - SLACK_CHANNEL_ID=
|
# - SLACK_CHANNEL_ID=
|
||||||
|
|
||||||
|
# - GOOGLE_CHAT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# - TELEGRAM_BOT_TOKEN=
|
||||||
|
# - TELEGRAM_CHAT_ID=
|
||||||
|
# - TELEGRAM_THREAD_ID=
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: ["sh", "-c", "npm run start"]
|
command: ["sh", "-c", "npm run start"]
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -74,7 +74,12 @@ services:
|
|||||||
|
|
||||||
# - SLACK_BOT_TOKEN=
|
# - SLACK_BOT_TOKEN=
|
||||||
# - SLACK_CHANNEL_ID=
|
# - SLACK_CHANNEL_ID=
|
||||||
|
|
||||||
# - GOOGLE_CHAT_WEBHOOK_URL=
|
# - GOOGLE_CHAT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# - TELEGRAM_BOT_TOKEN=
|
||||||
|
# - TELEGRAM_CHAT_ID=
|
||||||
|
# - TELEGRAM_THREAD_ID=
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planka",
|
"name": "planka",
|
||||||
"version": "1.23.3",
|
"version": "1.23.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planka",
|
"name": "planka",
|
||||||
"version": "1.23.3",
|
"version": "1.23.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planka",
|
"name": "planka",
|
||||||
"version": "1.23.3",
|
"version": "1.23.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://plankanban.github.io/planka",
|
"homepage": "https://plankanban.github.io/planka",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"client:test": "npm test --prefix client",
|
"client:test": "npm test --prefix client",
|
||||||
"docker:build": "docker build -t ghcr.io/plankanban/planka:local -f Dockerfile .",
|
"docker:build": "docker build -t ghcr.io/plankanban/planka:local -f Dockerfile .",
|
||||||
"docker:build:base": "docker build -t ghcr.io/plankanban/planka:base-local -f Dockerfile.base .",
|
"docker:build:base": "docker build -t ghcr.io/plankanban/planka:base-local -f Dockerfile.base .",
|
||||||
"gv": "genversion --source ./ --template client/version-template.ejs client/src/version.js",
|
"gv": "npm i --package-lock-only --ignore-scripts && genversion --source ./ --template client/version-template.ejs client/src/version.js",
|
||||||
"postinstall": "(cd server && npm i && cd ../client && npm i)",
|
"postinstall": "(cd server && npm i && cd ../client && npm i)",
|
||||||
"lint": "npm run server:lint && npm run client:lint",
|
"lint": "npm run server:lint && npm run client:lint",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
|||||||
@@ -65,8 +65,13 @@ SECRET_KEY=notsecretkey
|
|||||||
|
|
||||||
# SLACK_BOT_TOKEN=
|
# SLACK_BOT_TOKEN=
|
||||||
# SLACK_CHANNEL_ID=
|
# SLACK_CHANNEL_ID=
|
||||||
|
|
||||||
# GOOGLE_CHAT_WEBHOOK_URL=
|
# GOOGLE_CHAT_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# TELEGRAM_BOT_TOKEN=
|
||||||
|
# TELEGRAM_CHAT_ID=
|
||||||
|
# TELEGRAM_THREAD_ID=
|
||||||
|
|
||||||
## Do not edit this
|
## Do not edit this
|
||||||
|
|
||||||
TZ=UTC
|
TZ=UTC
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ const valuesValidator = (value) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildAndSendMessage = async (card, action, actorUser, send) => {
|
const truncateString = (string, maxLength = 30) =>
|
||||||
|
string.length > maxLength ? `${string.substring(0, 30)}...` : string;
|
||||||
|
|
||||||
|
const buildAndSendMarkdownMessage = async (card, action, actorUser, send) => {
|
||||||
const cardLink = `<${sails.config.custom.baseUrl}/cards/${card.id}|${card.name}>`;
|
const cardLink = `<${sails.config.custom.baseUrl}/cards/${card.id}|${card.name}>`;
|
||||||
|
|
||||||
let markdown;
|
let markdown;
|
||||||
@@ -28,6 +31,7 @@ const buildAndSendMessage = async (card, action, actorUser, send) => {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case Action.Types.COMMENT_CARD:
|
case Action.Types.COMMENT_CARD:
|
||||||
|
// TODO: truncate text?
|
||||||
markdown = `*${actorUser.name}* commented on ${cardLink}:\n>${action.data.text}`;
|
markdown = `*${actorUser.name}* commented on ${cardLink}:\n>${action.data.text}`;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@@ -38,6 +42,31 @@ const buildAndSendMessage = async (card, action, actorUser, send) => {
|
|||||||
await send(markdown);
|
await send(markdown);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildAndSendHtmlMessage = async (card, action, actorUser, send) => {
|
||||||
|
const cardLink = `<a href="${sails.config.custom.baseUrl}/cards/${card.id}">${card.name}</a>`;
|
||||||
|
|
||||||
|
let html;
|
||||||
|
switch (action.type) {
|
||||||
|
case Action.Types.CREATE_CARD:
|
||||||
|
html = `${cardLink} was created by ${actorUser.name} in <b>${action.data.list.name}</b>`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
case Action.Types.MOVE_CARD:
|
||||||
|
html = `${cardLink} was moved by ${actorUser.name} to <b>${action.data.toList.name}</b>`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
case Action.Types.COMMENT_CARD: {
|
||||||
|
html = `<b>${actorUser.name}</b> commented on ${cardLink}:\n<i>${truncateString(action.data.text)}</i>`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await send(html);
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
inputs: {
|
inputs: {
|
||||||
values: {
|
values: {
|
||||||
@@ -116,17 +145,32 @@ module.exports = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (sails.config.custom.slackBotToken) {
|
if (sails.config.custom.slackBotToken) {
|
||||||
buildAndSendMessage(values.card, action, values.user, sails.helpers.utils.sendSlackMessage);
|
buildAndSendMarkdownMessage(
|
||||||
|
values.card,
|
||||||
|
action,
|
||||||
|
values.user,
|
||||||
|
sails.helpers.utils.sendSlackMessage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sails.config.custom.googleChatWebhookUrl) {
|
if (sails.config.custom.googleChatWebhookUrl) {
|
||||||
buildAndSendMessage(
|
buildAndSendMarkdownMessage(
|
||||||
values.card,
|
values.card,
|
||||||
action,
|
action,
|
||||||
values.user,
|
values.user,
|
||||||
sails.helpers.utils.sendGoogleChatMessage,
|
sails.helpers.utils.sendGoogleChatMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.telegramBotToken) {
|
||||||
|
buildAndSendHtmlMessage(
|
||||||
|
values.card,
|
||||||
|
action,
|
||||||
|
values.user,
|
||||||
|
sails.helpers.utils.sendTelegramMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return action;
|
return action;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ module.exports = {
|
|||||||
cards: [inputs.card],
|
cards: [inputs.card],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ module.exports = {
|
|||||||
cards: [inputs.card],
|
cards: [inputs.card],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ module.exports = {
|
|||||||
boards: [inputs.board],
|
boards: [inputs.board],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ module.exports = {
|
|||||||
projects: [inputs.project],
|
projects: [inputs.project],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
const buildAndSendMessage = async (card, actorUser, send) => {
|
const buildAndSendMarkdownMessage = async (card, actorUser, send) => {
|
||||||
await send(`*${card.name}* was deleted by ${actorUser.name}`);
|
await send(`*${card.name}* was deleted by ${actorUser.name}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildAndSendHtmlMessage = async (card, actorUser, send) => {
|
||||||
|
await send(`<b>${card.name}</b> was deleted by ${actorUser.name}`);
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
inputs: {
|
inputs: {
|
||||||
record: {
|
record: {
|
||||||
@@ -56,11 +60,19 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (sails.config.custom.slackBotToken) {
|
if (sails.config.custom.slackBotToken) {
|
||||||
buildAndSendMessage(card, inputs.actorUser, sails.helpers.utils.sendSlackMessage);
|
buildAndSendMarkdownMessage(card, inputs.actorUser, sails.helpers.utils.sendSlackMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sails.config.custom.googleChatWebhookUrl) {
|
if (sails.config.custom.googleChatWebhookUrl) {
|
||||||
buildAndSendMessage(card, inputs.actorUser, sails.helpers.utils.sendGoogleChatMessage);
|
buildAndSendMarkdownMessage(
|
||||||
|
card,
|
||||||
|
inputs.actorUser,
|
||||||
|
sails.helpers.utils.sendGoogleChatMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sails.config.custom.telegramBotToken) {
|
||||||
|
buildAndSendHtmlMessage(card, inputs.actorUser, sails.helpers.utils.sendTelegramMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ module.exports = {
|
|||||||
lists: [list],
|
lists: [list],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ module.exports = {
|
|||||||
boards: [inputs.board],
|
boards: [inputs.board],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ module.exports = {
|
|||||||
boards: [inputs.board],
|
boards: [inputs.board],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ module.exports = {
|
|||||||
inputs.request,
|
inputs.request,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TODO: with prevData?
|
||||||
sails.helpers.utils.sendWebhooks.with({
|
sails.helpers.utils.sendWebhooks.with({
|
||||||
event: 'notificationUpdate',
|
event: 'notificationUpdate',
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ module.exports = {
|
|||||||
data: {
|
data: {
|
||||||
item: project,
|
item: project,
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ module.exports = {
|
|||||||
cards: [inputs.card],
|
cards: [inputs.card],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ module.exports = {
|
|||||||
data: {
|
data: {
|
||||||
item: user,
|
item: user,
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
item: inputs.record,
|
||||||
|
},
|
||||||
user: inputs.actorUser,
|
user: inputs.actorUser,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
44
server/api/helpers/utils/send-telegram-message.js
Normal file
44
server/api/helpers/utils/send-telegram-message.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const buildSendMessageApiUrl = (telegramBotToken) =>
|
||||||
|
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
html: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
chat_id: sails.config.custom.telegramChatId,
|
||||||
|
text: inputs.html,
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sails.config.custom.telegramThreadId) {
|
||||||
|
body.message_thread_id = sails.config.custom.telegramThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(buildSendMessageApiUrl(sails.config.custom.telegramBotToken), {
|
||||||
|
headers,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sails.log.error(`Error sending to Telegram: ${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseJson = await response.json();
|
||||||
|
sails.log.error(`Error sending to Telegram: ${responseJson.description}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -97,10 +97,11 @@ const jsonifyData = (data) => {
|
|||||||
* @param {*} webhook - Webhook configuration.
|
* @param {*} webhook - Webhook configuration.
|
||||||
* @param {string} event - The event (see {@link EVENT_TYPES}).
|
* @param {string} event - The event (see {@link EVENT_TYPES}).
|
||||||
* @param {Data} data - The data object containing event data and optionally included data.
|
* @param {Data} data - The data object containing event data and optionally included data.
|
||||||
|
* @param {Data} [prevData] - The data object containing previous state of data (optional).
|
||||||
* @param {ref} user - User object associated with the event.
|
* @param {ref} user - User object associated with the event.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function sendWebhook(webhook, event, data, user) {
|
async function sendWebhook(webhook, event, data, prevData, user) {
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'User-Agent': `planka (+${sails.config.custom.baseUrl})`,
|
'User-Agent': `planka (+${sails.config.custom.baseUrl})`,
|
||||||
@@ -113,6 +114,7 @@ async function sendWebhook(webhook, event, data, user) {
|
|||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
event,
|
event,
|
||||||
data: jsonifyData(data),
|
data: jsonifyData(data),
|
||||||
|
prevData: prevData && jsonifyData(prevData),
|
||||||
user: sails.helpers.utils.jsonifyRecord(user),
|
user: sails.helpers.utils.jsonifyRecord(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,6 +150,9 @@ module.exports = {
|
|||||||
type: 'ref',
|
type: 'ref',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
prevData: {
|
||||||
|
type: 'ref',
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
type: 'ref',
|
type: 'ref',
|
||||||
required: true,
|
required: true,
|
||||||
@@ -172,7 +177,7 @@ module.exports = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendWebhook(webhook, inputs.event, inputs.data, inputs.user);
|
sendWebhook(webhook, inputs.event, inputs.data, inputs.prevData, inputs.user);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,5 +82,10 @@ module.exports.custom = {
|
|||||||
|
|
||||||
slackBotToken: process.env.SLACK_BOT_TOKEN,
|
slackBotToken: process.env.SLACK_BOT_TOKEN,
|
||||||
slackChannelId: process.env.SLACK_CHANNEL_ID,
|
slackChannelId: process.env.SLACK_CHANNEL_ID,
|
||||||
|
|
||||||
googleChatWebhookUrl: process.env.GOOGLE_CHAT_WEBHOOK_URL,
|
googleChatWebhookUrl: process.env.GOOGLE_CHAT_WEBHOOK_URL,
|
||||||
|
|
||||||
|
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||||
|
telegramChatId: process.env.TELEGRAM_CHAT_ID,
|
||||||
|
telegramThreadId: process.env.TELEGRAM_THREAD_ID,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user