mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
feat: Display card name when linking in task (#1234)
This commit is contained in:
parent
b76ca9547f
commit
4d40af9c8a
5 changed files with 158 additions and 45 deletions
|
@ -9,17 +9,43 @@ import classNames from 'classnames';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import selectors from '../../../../selectors';
|
import selectors from '../../../../selectors';
|
||||||
|
import { ListTypes } from '../../../../constants/Enums';
|
||||||
import Linkify from '../../../common/Linkify';
|
import Linkify from '../../../common/Linkify';
|
||||||
|
|
||||||
import styles from './Task.module.scss';
|
import styles from './Task.module.scss';
|
||||||
|
|
||||||
const Task = React.memo(({ id }) => {
|
const Task = React.memo(({ id }) => {
|
||||||
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
||||||
|
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||||
|
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||||
|
|
||||||
const task = useSelector((state) => selectTaskById(state, id));
|
const task = useSelector((state) => selectTaskById(state, id));
|
||||||
|
|
||||||
|
const isCompleted = useSelector((state) => {
|
||||||
|
if (task.isCompleted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const list = selectListById(state, card.listId);
|
||||||
|
|
||||||
|
if (list && list.type === ListTypes.CLOSED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={classNames(styles.wrapper, task.isCompleted && styles.wrapperCompleted)}>
|
<li className={classNames(styles.wrapper, isCompleted && styles.wrapperCompleted)}>
|
||||||
<Linkify linkStopPropagation>{task.name}</Linkify>
|
<Linkify linkStopPropagation>{task.name}</Linkify>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
@ -9,6 +9,7 @@ import classNames from 'classnames';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Progress } from 'semantic-ui-react';
|
import { Progress } from 'semantic-ui-react';
|
||||||
import { useToggle } from '../../../../lib/hooks';
|
import { useToggle } from '../../../../lib/hooks';
|
||||||
|
import { ListTypes } from '../../../../constants/Enums';
|
||||||
|
|
||||||
import selectors from '../../../../selectors';
|
import selectors from '../../../../selectors';
|
||||||
import Task from './Task';
|
import Task from './Task';
|
||||||
|
@ -16,18 +17,41 @@ import Task from './Task';
|
||||||
import styles from './TaskList.module.scss';
|
import styles from './TaskList.module.scss';
|
||||||
|
|
||||||
const TaskList = React.memo(({ id }) => {
|
const TaskList = React.memo(({ id }) => {
|
||||||
|
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||||
|
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||||
const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []);
|
const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []);
|
||||||
|
|
||||||
const tasks = useSelector((state) => selectTasksByTaskListId(state, id));
|
const tasks = useSelector((state) => selectTasksByTaskListId(state, id));
|
||||||
|
|
||||||
const [isOpened, toggleOpened] = useToggle();
|
|
||||||
|
|
||||||
// TODO: move to selector?
|
// TODO: move to selector?
|
||||||
const completedTasksTotal = useMemo(
|
const completedTasksTotal = useSelector((state) =>
|
||||||
() => tasks.reduce((result, task) => (task.isCompleted ? result + 1 : result), 0),
|
tasks.reduce((result, task) => {
|
||||||
[tasks],
|
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) {
|
||||||
|
const list = selectListById(state, card.listId);
|
||||||
|
|
||||||
|
if (list && list.type === ListTypes.CLOSED) {
|
||||||
|
return result + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isOpened, toggleOpened] = useToggle();
|
||||||
|
|
||||||
const handleToggleClick = useCallback(
|
const handleToggleClick = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
|
@ -3,51 +3,72 @@
|
||||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
* 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 PropTypes from 'prop-types';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import LinkifyReact from 'linkify-react';
|
import LinkifyReact from 'linkify-react';
|
||||||
|
|
||||||
import history from '../../history';
|
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(({ children, linkStopPropagation, ...props }) => {
|
||||||
|
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new URL(children, window.location);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [children]);
|
||||||
|
|
||||||
|
const isSameSite = !!url && url.origin === window.location.origin;
|
||||||
|
|
||||||
|
const cardsPathMatch = useMemo(() => {
|
||||||
|
if (!isSameSite) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchPaths(url.pathname, [Paths.CARDS]);
|
||||||
|
}, [url.pathname, isSameSite]);
|
||||||
|
|
||||||
|
const card = useSelector((state) => {
|
||||||
|
if (!cardsPathMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectCardById(state, cardsPathMatch.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
const handleLinkClick = useCallback(
|
const handleLinkClick = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (linkStopPropagation) {
|
if (linkStopPropagation) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.target.getAttribute('target')) {
|
if (isSameSite) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
history.push(event.target.href);
|
history.push(event.target.href);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[linkStopPropagation],
|
[linkStopPropagation, isSameSite],
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkRenderer = useCallback(
|
const linkRenderer = useCallback(
|
||||||
({ attributes: { href, ...linkProps }, content }) => {
|
({ attributes: { href, ...linkProps }, content }) => (
|
||||||
let url;
|
<a
|
||||||
try {
|
{...linkProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||||
url = new URL(href, window.location);
|
href={href}
|
||||||
} catch {
|
target={isSameSite ? undefined : '_blank'}
|
||||||
/* empty */
|
rel={isSameSite ? undefined : 'noreferrer'}
|
||||||
}
|
onClick={handleLinkClick}
|
||||||
|
>
|
||||||
const isSameSite = !!url && url.origin === window.location.origin;
|
{card ? card.name : content}
|
||||||
|
</a>
|
||||||
return (
|
),
|
||||||
<a
|
[isSameSite, card, handleLinkClick],
|
||||||
{...linkProps} // eslint-disable-line react/jsx-props-no-spreading
|
|
||||||
href={href}
|
|
||||||
target={isSameSite ? undefined : '_blank'}
|
|
||||||
rel={isSameSite ? undefined : 'noreferrer'}
|
|
||||||
onClick={handleLinkClick}
|
|
||||||
>
|
|
||||||
{isSameSite ? url.pathname : content}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[handleLinkClick],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -16,7 +16,7 @@ import selectors from '../../../../selectors';
|
||||||
import entryActions from '../../../../entry-actions';
|
import entryActions from '../../../../entry-actions';
|
||||||
import { usePopupInClosableContext } from '../../../../hooks';
|
import { usePopupInClosableContext } from '../../../../hooks';
|
||||||
import { isListArchiveOrTrash } from '../../../../utils/record-helpers';
|
import { isListArchiveOrTrash } from '../../../../utils/record-helpers';
|
||||||
import { BoardMembershipRoles } from '../../../../constants/Enums';
|
import { BoardMembershipRoles, ListTypes } from '../../../../constants/Enums';
|
||||||
import { ClosableContext } from '../../../../contexts';
|
import { ClosableContext } from '../../../../contexts';
|
||||||
import EditName from './EditName';
|
import EditName from './EditName';
|
||||||
import SelectAssigneeStep from './SelectAssigneeStep';
|
import SelectAssigneeStep from './SelectAssigneeStep';
|
||||||
|
@ -28,10 +28,30 @@ import styles from './Task.module.scss';
|
||||||
|
|
||||||
const Task = React.memo(({ id, index }) => {
|
const Task = React.memo(({ id, index }) => {
|
||||||
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
||||||
|
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||||
|
|
||||||
const task = useSelector((state) => selectTaskById(state, id));
|
const task = useSelector((state) => selectTaskById(state, id));
|
||||||
|
|
||||||
|
const isLinkedCardCompleted = useSelector((state) => {
|
||||||
|
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) {
|
||||||
|
const list = selectListById(state, card.listId);
|
||||||
|
|
||||||
|
if (list && list.type === ListTypes.CLOSED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
const { canEdit, canToggle } = useSelector((state) => {
|
const { canEdit, canToggle } = useSelector((state) => {
|
||||||
const { listId } = selectors.selectCurrentCard(state);
|
const { listId } = selectors.selectCurrentCard(state);
|
||||||
const list = selectListById(state, listId);
|
const list = selectListById(state, listId);
|
||||||
|
@ -84,6 +104,8 @@ const Task = React.memo(({ id, index }) => {
|
||||||
}, [id, dispatch]);
|
}, [id, dispatch]);
|
||||||
|
|
||||||
const isEditable = task.isPersisted && canEdit;
|
const isEditable = task.isPersisted && canEdit;
|
||||||
|
const isCompleted = task.isCompleted || isLinkedCardCompleted;
|
||||||
|
const isToggleDisabled = !task.isPersisted || !canToggle || isLinkedCardCompleted;
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (isEditable) {
|
if (isEditable) {
|
||||||
|
@ -122,8 +144,8 @@ const Task = React.memo(({ id, index }) => {
|
||||||
>
|
>
|
||||||
<span className={styles.checkboxWrapper}>
|
<span className={styles.checkboxWrapper}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={task.isCompleted}
|
checked={isCompleted}
|
||||||
disabled={!task.isPersisted || !canToggle}
|
disabled={isToggleDisabled}
|
||||||
className={styles.checkbox}
|
className={styles.checkbox}
|
||||||
onChange={handleToggleChange}
|
onChange={handleToggleChange}
|
||||||
/>
|
/>
|
||||||
|
@ -138,9 +160,7 @@ const Task = React.memo(({ id, index }) => {
|
||||||
className={classNames(styles.text, canEdit && styles.textEditable)}
|
className={classNames(styles.text, canEdit && styles.textEditable)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<span
|
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
|
||||||
className={classNames(styles.task, task.isCompleted && styles.taskCompleted)}
|
|
||||||
>
|
|
||||||
<Linkify linkStopPropagation>{task.name}</Linkify>
|
<Linkify linkStopPropagation>{task.name}</Linkify>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { useDidUpdate } from '../../../lib/hooks';
|
||||||
import selectors from '../../../selectors';
|
import selectors from '../../../selectors';
|
||||||
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
|
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
|
||||||
import DroppableTypes from '../../../constants/DroppableTypes';
|
import DroppableTypes from '../../../constants/DroppableTypes';
|
||||||
import { BoardMembershipRoles } from '../../../constants/Enums';
|
import { BoardMembershipRoles, ListTypes } from '../../../constants/Enums';
|
||||||
import { ClosableContext } from '../../../contexts';
|
import { ClosableContext } from '../../../contexts';
|
||||||
import Task from './Task';
|
import Task from './Task';
|
||||||
import AddTask from './AddTask';
|
import AddTask from './AddTask';
|
||||||
|
@ -23,12 +23,40 @@ import styles from './TaskList.module.scss';
|
||||||
|
|
||||||
const TaskList = React.memo(({ id }) => {
|
const TaskList = React.memo(({ id }) => {
|
||||||
const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []);
|
const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []);
|
||||||
|
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||||
const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []);
|
const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []);
|
||||||
|
|
||||||
const taskList = useSelector((state) => selectTaskListById(state, id));
|
const taskList = useSelector((state) => selectTaskListById(state, id));
|
||||||
const tasks = useSelector((state) => selectTasksByTaskListId(state, id));
|
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) {
|
||||||
|
const list = selectListById(state, card.listId);
|
||||||
|
|
||||||
|
if (list && list.type === ListTypes.CLOSED) {
|
||||||
|
return result + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, 0),
|
||||||
|
);
|
||||||
|
|
||||||
const canEdit = useSelector((state) => {
|
const canEdit = useSelector((state) => {
|
||||||
const { listId } = selectors.selectCurrentCard(state);
|
const { listId } = selectors.selectCurrentCard(state);
|
||||||
const list = selectListById(state, listId);
|
const list = selectListById(state, listId);
|
||||||
|
@ -45,12 +73,6 @@ const TaskList = React.memo(({ id }) => {
|
||||||
const [isAddOpened, setIsAddOpened] = useState(false);
|
const [isAddOpened, setIsAddOpened] = useState(false);
|
||||||
const [, , setIsClosableActive] = useContext(ClosableContext);
|
const [, , setIsClosableActive] = useContext(ClosableContext);
|
||||||
|
|
||||||
// TODO: move to selector?
|
|
||||||
const completedTasksTotal = useMemo(
|
|
||||||
() => tasks.reduce((result, task) => (task.isCompleted ? result + 1 : result), 0),
|
|
||||||
[tasks],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddClick = useCallback(() => {
|
const handleAddClick = useCallback(() => {
|
||||||
setIsAddOpened(true);
|
setIsAddOpened(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue