1
0
Fork 0
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:
Daniel Silvestre 2025-07-08 17:15:48 +02:00 committed by GitHub
parent b76ca9547f
commit 4d40af9c8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 158 additions and 45 deletions

View file

@ -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>
); );

View file

@ -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();

View file

@ -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 (

View file

@ -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>

View file

@ -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);
}, []); }, []);