From 230f50e3d9115322accf932eddbcf9a39e4c030b Mon Sep 17 00:00:00 2001 From: Maksim Eltyshev Date: Fri, 11 Jul 2025 01:04:02 +0200 Subject: [PATCH] feat: Add ability to link tasks to cards --- client/patches/semantic-ui-react+2.1.5.patch | 42 +++++- .../components/cards/Card/TaskList/Task.jsx | 48 +++--- .../cards/Card/TaskList/Task.module.scss | 20 ++- .../cards/Card/TaskList/TaskList.jsx | 24 +-- .../common/{Linkify.jsx => Linkify/Link.jsx} | 57 +++---- .../src/components/common/Linkify/Linkify.jsx | 43 ++++++ client/src/components/common/Linkify/index.js | 8 + .../task-lists/TaskList/AddTask.jsx | 139 +++++++++++++----- .../task-lists/TaskList/AddTask.module.scss | 36 ++++- .../task-lists/TaskList/Task/ActionsStep.jsx | 21 ++- .../task-lists/TaskList/Task/Task.jsx | 111 ++++++++------ .../task-lists/TaskList/Task/Task.module.scss | 34 ++++- .../task-lists/TaskList/TaskList.jsx | 30 +--- client/src/locales/en-GB/core.js | 2 + client/src/locales/en-US/core.js | 2 + client/src/models/Card.js | 25 +++- client/src/models/List.js | 8 + client/src/models/Task.js | 5 + client/src/sagas/core/services/tasks.js | 8 + client/src/selectors/boards.js | 23 +++ server/api/controllers/tasks/create.js | 39 ++++- server/api/controllers/tasks/update.js | 8 + server/api/helpers/cards/delete-related.js | 9 ++ server/api/helpers/cards/duplicate-one.js | 6 +- server/api/helpers/cards/update-one.js | 31 +++- server/api/helpers/lists/delete-one.js | 2 +- server/api/helpers/lists/move-cards.js | 2 +- server/api/helpers/lists/sort-one.js | 10 +- server/api/helpers/lists/update-one.js | 50 ++++--- server/api/helpers/tasks/create-one.js | 11 ++ server/api/hooks/query-methods/models/Card.js | 64 +++++++- server/api/hooks/query-methods/models/List.js | 56 ++++++- server/api/hooks/query-methods/models/Task.js | 6 + server/api/models/Task.js | 4 + ...0208_add_ability_to_link_tasks_to_cards.js | 20 +++ 35 files changed, 761 insertions(+), 243 deletions(-) rename client/src/components/common/{Linkify.jsx => Linkify/Link.jsx} (50%) create mode 100644 client/src/components/common/Linkify/Linkify.jsx create mode 100644 client/src/components/common/Linkify/index.js create mode 100644 server/db/migrations/20250709160208_add_ability_to_link_tasks_to_cards.js diff --git a/client/patches/semantic-ui-react+2.1.5.patch b/client/patches/semantic-ui-react+2.1.5.patch index 90b6c91a..23448e40 100644 --- a/client/patches/semantic-ui-react+2.1.5.patch +++ b/client/patches/semantic-ui-react+2.1.5.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js -index 6d06078..fb7534d 100644 +index 6d06078..e22d4f0 100644 --- a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js +++ b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js @@ -17,13 +17,7 @@ var doesNodeContainClick = function doesNodeContainClick(node, e) { @@ -17,6 +17,46 @@ index 6d06078..fb7534d 100644 } // Below logic handles cases where the e.target is no longer in the document. // The result of the click likely has removed the e.target node. // Instead of node.contains(), we'll identify the click by X/Y position. +diff --git a/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js b/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js +index 1cc1bab..7abb016 100644 +--- a/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js ++++ b/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js +@@ -342,7 +342,7 @@ var Dropdown = /*#__PURE__*/function (_Component) { + return; + } + +- if (searchQuery.length >= minCharacters || minCharacters === 1) { ++ if (searchQuery.length >= minCharacters || minCharacters === 0) { + _this.open(e); + + return; +@@ -480,7 +480,7 @@ var Dropdown = /*#__PURE__*/function (_Component) { + } // close search dropdown if search query is too small + + +- if (open && minCharacters !== 1 && newQuery.length < minCharacters) _this.close(); ++ if (open && minCharacters !== 0 && newQuery.length < minCharacters) _this.close(); + }; + + _this.handleKeyDown = function (e) { +@@ -1048,7 +1048,7 @@ var Dropdown = /*#__PURE__*/function (_Component) { + + if (!prevState.focus && this.state.focus) { + if (!this.isMouseDown) { +- var openable = !search || search && minCharacters === 1 && !this.state.open; ++ var openable = !search || search && minCharacters === 0 && !this.state.open; + if (openOnFocus && openable) this.open(); + } + } else if (prevState.focus && !this.state.focus) { +@@ -1436,7 +1436,7 @@ Dropdown.defaultProps = { + closeOnEscape: true, + deburr: false, + icon: 'dropdown', +- minCharacters: 1, ++ minCharacters: 0, + noResultsMessage: 'No results found.', + openOnFocus: true, + renderLabel: renderItemContent, diff --git a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js index d1ae271..43e1170 100644 --- a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js diff --git a/client/src/components/cards/Card/TaskList/Task.jsx b/client/src/components/cards/Card/TaskList/Task.jsx index f2cb52bd..c42542c2 100644 --- a/client/src/components/cards/Card/TaskList/Task.jsx +++ b/client/src/components/cards/Card/TaskList/Task.jsx @@ -3,45 +3,49 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { Icon } from 'semantic-ui-react'; import selectors from '../../../../selectors'; +import Paths from '../../../../constants/Paths'; import Linkify from '../../../common/Linkify'; import styles from './Task.module.scss'; const Task = React.memo(({ id }) => { const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []); - const selectCardById = useMemo(() => selectors.makeSelectCardById(), []); + const selectLinkedCardById = useMemo(() => selectors.makeSelectCardById(), []); const task = useSelector((state) => selectTaskById(state, id)); - const isCompleted = useSelector((state) => { - if (task.isCompleted) { - return true; - } + const linkedCard = useSelector( + (state) => task.linkedCardId && selectLinkedCardById(state, task.linkedCardId), + ); - const regex = /\/cards\/([^/]+)/g; - const matches = task.name.matchAll(regex); - - // eslint-disable-next-line no-restricted-syntax - for (const [, cardId] of matches) { - const card = selectCardById(state, cardId); - - if (card && card.isClosed) { - return true; - } - } - - return false; - }); + const handleLinkClick = useCallback((event) => { + event.stopPropagation(); + }, []); return ( -
  • - {task.name} +
  • + {task.linkedCardId ? ( + <> + + + + {linkedCard ? linkedCard.name : task.name} + + + + ) : ( + + {task.name} + + )}
  • ); }); diff --git a/client/src/components/cards/Card/TaskList/Task.module.scss b/client/src/components/cards/Card/TaskList/Task.module.scss index a50c0b7d..be41249b 100644 --- a/client/src/components/cards/Card/TaskList/Task.module.scss +++ b/client/src/components/cards/Card/TaskList/Task.module.scss @@ -4,6 +4,21 @@ */ :global(#app) { + .icon { + color: rgba(9, 30, 66, 0.24); + } + + .name { + a:hover { + text-decoration: underline; + } + } + + .nameCompleted { + color: #aaa; + text-decoration: line-through; + } + .wrapper { display: block; font-size: 12px; @@ -18,9 +33,4 @@ left: 10px; } } - - .wrapperCompleted { - color: #aaa; - text-decoration: line-through; - } } diff --git a/client/src/components/cards/Card/TaskList/TaskList.jsx b/client/src/components/cards/Card/TaskList/TaskList.jsx index 078aca60..7eda3773 100644 --- a/client/src/components/cards/Card/TaskList/TaskList.jsx +++ b/client/src/components/cards/Card/TaskList/TaskList.jsx @@ -16,32 +16,14 @@ import Task from './Task'; import styles from './TaskList.module.scss'; const TaskList = React.memo(({ id }) => { - const selectCardById = useMemo(() => selectors.makeSelectCardById(), []); const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []); const tasks = useSelector((state) => selectTasksByTaskListId(state, id)); // TODO: move to selector? - const completedTasksTotal = useSelector((state) => - tasks.reduce((result, task) => { - if (task.isCompleted) { - return result + 1; - } - - const regex = /\/cards\/([^/]+)/g; - const matches = task.name.matchAll(regex); - - // eslint-disable-next-line no-restricted-syntax - for (const [, cardId] of matches) { - const card = selectCardById(state, cardId); - - if (card && card.isClosed) { - return result + 1; - } - } - - return result; - }, 0), + const completedTasksTotal = useMemo( + () => tasks.reduce((result, task) => (task.isCompleted ? result + 1 : result), 0), + [tasks], ); const [isOpened, toggleOpened] = useToggle(); diff --git a/client/src/components/common/Linkify.jsx b/client/src/components/common/Linkify/Link.jsx similarity index 50% rename from client/src/components/common/Linkify.jsx rename to client/src/components/common/Linkify/Link.jsx index e0304891..c083fe18 100644 --- a/client/src/components/common/Linkify.jsx +++ b/client/src/components/common/Linkify/Link.jsx @@ -6,23 +6,22 @@ import React, { useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import LinkifyReact from 'linkify-react'; -import history from '../../history'; -import selectors from '../../selectors'; -import matchPaths from '../../utils/match-paths'; -import Paths from '../../constants/Paths'; +import history from '../../../history'; +import selectors from '../../../selectors'; +import matchPaths from '../../../utils/match-paths'; +import Paths from '../../../constants/Paths'; -const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => { +const Linkify = React.memo(({ href, content, stopPropagation, ...props }) => { const selectCardById = useMemo(() => selectors.makeSelectCardById(), []); const url = useMemo(() => { try { - return new URL(children, window.location); + return new URL(href, window.location); } catch { return null; } - }, [children]); + }, [href]); const isSameSite = !!url && url.origin === window.location.origin; @@ -42,9 +41,9 @@ const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => { return selectCardById(state, cardsPathMatch.params.id); }); - const handleLinkClick = useCallback( + const handleClick = useCallback( (event) => { - if (linkStopPropagation) { + if (stopPropagation) { event.stopPropagation(); } @@ -53,44 +52,30 @@ const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => { history.push(event.target.href); } }, - [linkStopPropagation, isSameSite], - ); - - const linkRenderer = useCallback( - ({ attributes: { href, ...linkProps }, content }) => ( - - {card ? card.name : content} - - ), - [isSameSite, card, handleLinkClick], + [stopPropagation, isSameSite], ); return ( - - {children} - + {card ? card.name : content} + ); }); Linkify.propTypes = { - children: PropTypes.string.isRequired, - linkStopPropagation: PropTypes.bool, + href: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + stopPropagation: PropTypes.bool, }; Linkify.defaultProps = { - linkStopPropagation: false, + stopPropagation: false, }; export default Linkify; diff --git a/client/src/components/common/Linkify/Linkify.jsx b/client/src/components/common/Linkify/Linkify.jsx new file mode 100644 index 00000000..139fa92d --- /dev/null +++ b/client/src/components/common/Linkify/Linkify.jsx @@ -0,0 +1,43 @@ +/*! + * 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 PropTypes from 'prop-types'; +import LinkifyReact from 'linkify-react'; + +import Link from './Link'; + +const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => { + const linkRenderer = useCallback( + ({ attributes: { href, ...linkProps }, content }) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ), + [linkStopPropagation], + ); + + return ( + + {children} + + ); +}); + +Linkify.propTypes = { + children: PropTypes.string.isRequired, + linkStopPropagation: PropTypes.bool, +}; + +Linkify.defaultProps = { + linkStopPropagation: undefined, +}; + +export default Linkify; diff --git a/client/src/components/common/Linkify/index.js b/client/src/components/common/Linkify/index.js new file mode 100644 index 00000000..d2226df1 --- /dev/null +++ b/client/src/components/common/Linkify/index.js @@ -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 Linkify from './Linkify'; + +export default Linkify; diff --git a/client/src/components/task-lists/TaskList/AddTask.jsx b/client/src/components/task-lists/TaskList/AddTask.jsx index b5941e02..79921551 100755 --- a/client/src/components/task-lists/TaskList/AddTask.jsx +++ b/client/src/components/task-lists/TaskList/AddTask.jsx @@ -5,12 +5,13 @@ import React, { useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import TextareaAutosize from 'react-textarea-autosize'; -import { Button, Form, TextArea } from 'semantic-ui-react'; +import { Button, Dropdown, Form, Icon, TextArea } from 'semantic-ui-react'; import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks'; +import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useForm, useNestedRef } from '../../../hooks'; import { focusEnd } from '../../../utils/element-helpers'; @@ -20,18 +21,23 @@ import styles from './AddTask.module.scss'; const DEFAULT_DATA = { name: '', + linkedCardId: null, }; const MULTIPLE_REGEX = /\s*\r?\n\s*/; const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => { + const cards = useSelector(selectors.selectCardsExceptCurrentForCurrentBoard); + const dispatch = useDispatch(); const [t] = useTranslation(); const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA); - const [focusNameFieldState, focusNameField] = useToggle(); + const [isLinkingToCard, toggleLinkingToCard] = useToggle(); + const [focusFieldState, focusField] = useToggle(); - const [nameFieldRef, handleNameFieldRef] = useNestedRef(); - const [buttonRef, handleButtonRef] = useNestedRef(); + const [fieldRef, handleFieldRef] = useNestedRef(); + const [submitButtonRef, handleSubmitButtonRef] = useNestedRef(); + const [toggleLinkingButtonRef, handleToggleLinkingButtonRef] = useNestedRef(); const submit = useCallback( (isMultiple = false) => { @@ -40,12 +46,23 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => { name: data.name.trim(), }; - if (!cleanData.name) { - nameFieldRef.current.select(); - return; + if (isLinkingToCard) { + if (!cleanData.linkedCardId) { + fieldRef.current.querySelector('.search').focus(); + return; + } + + delete cleanData.name; + } else { + if (!cleanData.name) { + fieldRef.current.select(); + return; + } + + delete cleanData.linkedCardId; } - if (isMultiple) { + if (!isLinkingToCard && isMultiple) { cleanData.name.split(MULTIPLE_REGEX).forEach((name) => { dispatch( entryActions.createTask(taskListId, { @@ -59,9 +76,9 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => { } setData(DEFAULT_DATA); - focusNameField(); + focusField(); }, - [taskListId, dispatch, data, setData, focusNameField, nameFieldRef], + [taskListId, dispatch, data, setData, isLinkingToCard, focusField, fieldRef], ); const handleSubmit = useCallback(() => { @@ -71,34 +88,48 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => { const handleFieldKeyDown = useCallback( (event) => { if (event.key === 'Enter') { - event.preventDefault(); - submit(isModifierKeyPressed(event)); + if (!isLinkingToCard) { + event.preventDefault(); + submit(isModifierKeyPressed(event)); + } } else if (event.key === 'Escape') { onClose(); } }, - [onClose, submit], + [onClose, isLinkingToCard, submit], ); + const handleToggleLinkingClick = useCallback(() => { + toggleLinkingToCard(); + }, [toggleLinkingToCard]); + const handleClickAwayCancel = useCallback(() => { - nameFieldRef.current.focus(); - }, [nameFieldRef]); + if (isLinkingToCard) { + fieldRef.current.querySelector('.search').focus(); + } else { + focusEnd(fieldRef.current); + } + }, [isLinkingToCard, fieldRef]); const clickAwayProps = useClickAwayListener( - [nameFieldRef, buttonRef], + [fieldRef, submitButtonRef, toggleLinkingButtonRef], onClose, handleClickAwayCancel, ); useEffect(() => { if (isOpened) { - focusEnd(nameFieldRef.current); + if (isLinkingToCard) { + fieldRef.current.querySelector('.search').focus(); + } else { + focusEnd(fieldRef.current); + } } - }, [isOpened, nameFieldRef]); + }, [isOpened, isLinkingToCard, fieldRef]); useDidUpdate(() => { - nameFieldRef.current.focus(); - }, [focusNameFieldState]); + fieldRef.current.focus(); + }, [focusFieldState]); if (!isOpened) { return children; @@ -106,27 +137,63 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => { return (
    -