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 (
);
diff --git a/client/src/components/task-lists/TaskList/AddTask.module.scss b/client/src/components/task-lists/TaskList/AddTask.module.scss
index f7830568..a55176b8 100644
--- a/client/src/components/task-lists/TaskList/AddTask.module.scss
+++ b/client/src/components/task-lists/TaskList/AddTask.module.scss
@@ -5,7 +5,7 @@
:global(#app) {
.controls {
- clear: both;
+ display: flex;
margin-top: 6px;
}
@@ -18,9 +18,41 @@
line-height: 1.5;
font-size: 14px;
margin-bottom: 4px;
- overflow: hidden;
padding: 8px 12px;
resize: none;
+
+ :global {
+ .menu {
+ border: 1px solid rgba(9, 30, 66, 0.08);
+ }
+
+ .search {
+ left: 0;
+ }
+ }
+ }
+
+ .toggleLinkingButton {
+ background: transparent;
+ box-shadow: none;
+ color: #6b808c;
+ font-weight: normal;
+ margin-left: auto;
+ margin-right: 0;
+ overflow: hidden;
+ text-align: left;
+ text-decoration: underline;
+ text-overflow: ellipsis;
+ transition: none;
+
+ &:hover {
+ background: rgba(9, 30, 66, 0.08);
+ color: #092d42;
+ }
+ }
+
+ .toggleLinkingButtonIcon {
+ text-decoration: none;
}
.wrapper {
diff --git a/client/src/components/task-lists/TaskList/Task/ActionsStep.jsx b/client/src/components/task-lists/TaskList/Task/ActionsStep.jsx
index 86288313..d391398d 100755
--- a/client/src/components/task-lists/TaskList/Task/ActionsStep.jsx
+++ b/client/src/components/task-lists/TaskList/Task/ActionsStep.jsx
@@ -3,13 +3,14 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../../../lib/custom-ui';
+import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions';
import { useSteps } from '../../../../hooks';
import ConfirmationStep from '../../../common/ConfirmationStep';
@@ -21,6 +22,10 @@ const StepTypes = {
};
const ActionsStep = React.memo(({ taskId, onNameEdit, onClose }) => {
+ const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
+
+ const task = useSelector((state) => selectTaskById(state, taskId));
+
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
@@ -59,11 +64,13 @@ const ActionsStep = React.memo(({ taskId, onNameEdit, onClose }) => {