mirror of
https://github.com/plankanban/planka.git
synced 2025-08-10 16:05:35 +02:00
Addíng code where copying from list works but from opened card does not.
Copying content on card does not work from any place.
This commit is contained in:
parent
b273a4f68b
commit
21fc0f560a
24 changed files with 573 additions and 109 deletions
|
@ -89,6 +89,35 @@ const handleCardDelete = (card) => ({
|
|||
},
|
||||
});
|
||||
|
||||
const copyCard = (id) => ({
|
||||
type: ActionTypes.CARD_COPY,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
copyCard.success = (card) => ({
|
||||
type: ActionTypes.CARD_COPY__SUCCESS,
|
||||
payload: {
|
||||
card,
|
||||
},
|
||||
});
|
||||
|
||||
copyCard.failure = (id, error) => ({
|
||||
type: ActionTypes.CARD_COPY__FAILURE,
|
||||
payload: {
|
||||
id,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
const handleCardCopy = (card) => ({
|
||||
type: ActionTypes.CARD_COPY_HANDLE,
|
||||
payload: {
|
||||
card,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
createCard,
|
||||
handleCardCreate,
|
||||
|
@ -96,4 +125,6 @@ export default {
|
|||
handleCardUpdate,
|
||||
deleteCard,
|
||||
handleCardDelete,
|
||||
copyCard,
|
||||
handleCardCopy,
|
||||
};
|
||||
|
|
|
@ -25,6 +25,7 @@ const Filters = React.memo(
|
|||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
// onCopyCard,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
|
@ -83,6 +84,7 @@ const Filters = React.memo(
|
|||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
// onCopyCard={onCopyCard}
|
||||
>
|
||||
<button type="button" className={styles.filterButton}>
|
||||
<span className={styles.filterTitle}>{`${t('common.labels')}:`}</span>
|
||||
|
@ -121,6 +123,7 @@ Filters.propTypes = {
|
|||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
// onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
|
|
|
@ -14,115 +14,118 @@ import EditStep from './EditStep';
|
|||
|
||||
import styles from './Boards.module.scss';
|
||||
|
||||
const Boards = React.memo(({ items, currentId, canEdit, onCreate, onUpdate, onMove, onDelete }) => {
|
||||
const tabsWrapper = useRef(null);
|
||||
const Boards = React.memo(
|
||||
({ items, currentId, canEdit, onCreate, onUpdate, onMove, onDelete, onCopyCard }) => {
|
||||
const tabsWrapper = useRef(null);
|
||||
|
||||
const handleWheel = useCallback(({ deltaY }) => {
|
||||
tabsWrapper.current.scrollBy({
|
||||
left: deltaY,
|
||||
});
|
||||
}, []);
|
||||
const handleWheel = useCallback(({ deltaY }) => {
|
||||
tabsWrapper.current.scrollBy({
|
||||
left: deltaY,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
closePopup();
|
||||
}, []);
|
||||
const handleDragStart = useCallback(() => {
|
||||
closePopup();
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ draggableId, source, destination }) => {
|
||||
if (!destination || source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
const handleDragEnd = useCallback(
|
||||
({ draggableId, source, destination }) => {
|
||||
if (!destination || source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMove(draggableId, destination.index);
|
||||
},
|
||||
[onMove],
|
||||
);
|
||||
onMove(draggableId, destination.index);
|
||||
},
|
||||
[onMove],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(id, data) => {
|
||||
onUpdate(id, data);
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
const handleUpdate = useCallback(
|
||||
(id, data) => {
|
||||
onUpdate(id, data);
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id) => {
|
||||
onDelete(id);
|
||||
},
|
||||
[onDelete],
|
||||
);
|
||||
const handleDelete = useCallback(
|
||||
(id) => {
|
||||
onDelete(id);
|
||||
},
|
||||
[onDelete],
|
||||
);
|
||||
|
||||
const AddPopup = usePopup(AddStep);
|
||||
const EditPopup = usePopup(EditStep);
|
||||
const AddPopup = usePopup(AddStep);
|
||||
const EditPopup = usePopup(EditStep);
|
||||
|
||||
const itemsNode = items.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={item.id}
|
||||
index={index}
|
||||
isDragDisabled={!item.isPersisted || !canEdit}
|
||||
>
|
||||
{({ innerRef, draggableProps, dragHandleProps }) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...draggableProps} ref={innerRef} className={styles.tabWrapper}>
|
||||
<div className={classNames(styles.tab, item.id === currentId && styles.tabActive)}>
|
||||
{item.isPersisted ? (
|
||||
<>
|
||||
<Link
|
||||
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
to={Paths.BOARDS.replace(':id', item.id)}
|
||||
title={item.name}
|
||||
className={styles.link}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
{canEdit && (
|
||||
<EditPopup
|
||||
defaultData={pick(item, 'name')}
|
||||
onUpdate={(data) => handleUpdate(item.id, data)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
const itemsNode = items.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={item.id}
|
||||
index={index}
|
||||
isDragDisabled={!item.isPersisted || !canEdit}
|
||||
>
|
||||
{({ innerRef, draggableProps, dragHandleProps }) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...draggableProps} ref={innerRef} className={styles.tabWrapper}>
|
||||
<div className={classNames(styles.tab, item.id === currentId && styles.tabActive)}>
|
||||
{item.isPersisted ? (
|
||||
<>
|
||||
<Link
|
||||
{...dragHandleProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
to={Paths.BOARDS.replace(':id', item.id)}
|
||||
title={item.name}
|
||||
className={styles.link}
|
||||
>
|
||||
<Button className={classNames(styles.editButton, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</EditPopup>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<span {...dragHandleProps} className={styles.link}>
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
{item.name}
|
||||
</Link>
|
||||
{canEdit && (
|
||||
<EditPopup
|
||||
defaultData={pick(item, 'name')}
|
||||
onUpdate={(data) => handleUpdate(item.id, data)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button className={classNames(styles.editButton, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</EditPopup>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<span {...dragHandleProps} className={styles.link}>
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
));
|
||||
)}
|
||||
</Draggable>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} onWheel={handleWheel}>
|
||||
<div ref={tabsWrapper} className={styles.tabsWrapper}>
|
||||
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="boards" type={DroppableTypes.BOARD} direction="horizontal">
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...droppableProps} ref={innerRef} className={styles.tabs}>
|
||||
{itemsNode}
|
||||
{placeholder}
|
||||
{canEdit && (
|
||||
<AddPopup onCreate={onCreate}>
|
||||
<Button icon="plus" className={styles.addButton} />
|
||||
</AddPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
return (
|
||||
<div className={styles.wrapper} onWheel={handleWheel}>
|
||||
<div ref={tabsWrapper} className={styles.tabsWrapper}>
|
||||
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="boards" type={DroppableTypes.BOARD} direction="horizontal">
|
||||
{({ innerRef, droppableProps, placeholder }) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div {...droppableProps} ref={innerRef} className={styles.tabs}>
|
||||
{itemsNode}
|
||||
{placeholder}
|
||||
{canEdit && (
|
||||
<AddPopup onCreate={onCreate} onCopyCard={onCopyCard}>
|
||||
<Button icon="plus" className={styles.addButton} />
|
||||
</AddPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Boards.propTypes = {
|
||||
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
|
||||
|
@ -132,6 +135,7 @@ Boards.propTypes = {
|
|||
onUpdate: PropTypes.func.isRequired,
|
||||
onMove: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Boards.defaultProps = {
|
||||
|
|
|
@ -12,6 +12,7 @@ import DueDateEditStep from '../DueDateEditStep';
|
|||
import StopwatchEditStep from '../StopwatchEditStep';
|
||||
import CardMoveStep from '../CardMoveStep';
|
||||
import DeleteStep from '../DeleteStep';
|
||||
import CardCopyStep from '../CardCopyStep';
|
||||
|
||||
import styles from './ActionsStep.module.scss';
|
||||
|
||||
|
@ -22,6 +23,7 @@ const StepTypes = {
|
|||
EDIT_STOPWATCH: 'EDIT_STOPWATCH',
|
||||
MOVE: 'MOVE',
|
||||
DELETE: 'DELETE',
|
||||
COPY: 'COPY',
|
||||
};
|
||||
|
||||
const ActionsStep = React.memo(
|
||||
|
@ -47,6 +49,7 @@ const ActionsStep = React.memo(
|
|||
onLabelMove,
|
||||
onLabelDelete,
|
||||
onClose,
|
||||
onCopyCard,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
@ -76,6 +79,10 @@ const ActionsStep = React.memo(
|
|||
openStep(StepTypes.MOVE);
|
||||
}, [openStep]);
|
||||
|
||||
const handleCopyClick = useCallback(() => {
|
||||
openStep(StepTypes.COPY);
|
||||
}, [openStep]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
openStep(StepTypes.DELETE);
|
||||
}, [openStep]);
|
||||
|
@ -108,6 +115,7 @@ const ActionsStep = React.memo(
|
|||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
onBack={handleBack}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
);
|
||||
case StepTypes.LABELS:
|
||||
|
@ -122,6 +130,7 @@ const ActionsStep = React.memo(
|
|||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
onBack={handleBack}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
);
|
||||
case StepTypes.EDIT_DUE_DATE:
|
||||
|
@ -131,6 +140,7 @@ const ActionsStep = React.memo(
|
|||
onUpdate={handleDueDateUpdate}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
);
|
||||
case StepTypes.EDIT_STOPWATCH:
|
||||
|
@ -140,6 +150,7 @@ const ActionsStep = React.memo(
|
|||
onUpdate={handleStopwatchUpdate}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
);
|
||||
case StepTypes.MOVE:
|
||||
|
@ -152,6 +163,7 @@ const ActionsStep = React.memo(
|
|||
onBoardFetch={onBoardFetch}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
);
|
||||
case StepTypes.DELETE:
|
||||
|
@ -162,6 +174,20 @@ const ActionsStep = React.memo(
|
|||
buttonContent="action.deleteCard"
|
||||
onConfirm={onDelete}
|
||||
onBack={handleBack}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
);
|
||||
case StepTypes.COPY:
|
||||
return (
|
||||
<CardCopyStep
|
||||
projectsToLists={projectsToLists}
|
||||
defaultPath={pick(card, ['projectId', 'boardId', 'listId'])}
|
||||
onCopyCard={onCopyCard}
|
||||
onTransfer={onTransfer}
|
||||
onBoardFetch={onBoardFetch}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
onConfirm={onCopyCard}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
@ -212,6 +238,11 @@ const ActionsStep = React.memo(
|
|||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleCopyClick}>
|
||||
{t('action.copyCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Popup.Content>
|
||||
</>
|
||||
|
@ -243,6 +274,7 @@ ActionsStep.propTypes = {
|
|||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ActionsStep;
|
||||
|
|
|
@ -51,6 +51,7 @@ const Card = React.memo(
|
|||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
onCopyCard,
|
||||
}) => {
|
||||
const nameEdit = useRef(null);
|
||||
|
||||
|
@ -195,6 +196,7 @@ const Card = React.memo(
|
|||
onLabelUpdate={onLabelUpdate}
|
||||
onLabelMove={onLabelMove}
|
||||
onLabelDelete={onLabelDelete}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button className={classNames(styles.actionsButton, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
|
@ -250,6 +252,7 @@ Card.propTypes = {
|
|||
onLabelDelete: PropTypes.func.isRequired,
|
||||
// onSortTitleAsc: PropTypes.func.isRequired,
|
||||
// onSortTitleDesc: PropTypes.func.isRequired,
|
||||
onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Card.defaultProps = {
|
||||
|
|
5
client/src/components/CardCopyPopup.jsx
Normal file
5
client/src/components/CardCopyPopup.jsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { withPopup } from '../lib/popup';
|
||||
|
||||
import CardCopyStep from './CardCopyStep';
|
||||
|
||||
export default withPopup(CardCopyStep);
|
169
client/src/components/CardCopyStep/CardCopyStep.jsx
Normal file
169
client/src/components/CardCopyStep/CardCopyStep.jsx
Normal file
|
@ -0,0 +1,169 @@
|
|||
import React, { useMemo, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Form } from 'semantic-ui-react';
|
||||
import { Popup } from '../../lib/custom-ui';
|
||||
|
||||
import { useForm } from '../../hooks';
|
||||
|
||||
import styles from './CardCopyStep.module.scss';
|
||||
|
||||
const CardCopyStep = React.memo(
|
||||
({ projectsToLists, defaultPath, onTransfer, onBoardFetch, onBack, onClose, onCopyCard }) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [path, handleFieldChange] = useForm(() => ({
|
||||
projectId: null,
|
||||
boardId: null,
|
||||
listId: null,
|
||||
name: null,
|
||||
description: null,
|
||||
tasks: [],
|
||||
attachments: [],
|
||||
labels: [],
|
||||
users: [],
|
||||
...defaultPath,
|
||||
}));
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() => projectsToLists.find((project) => project.id === path.projectId) || null,
|
||||
[projectsToLists, path.projectId],
|
||||
);
|
||||
|
||||
const selectedBoard = useMemo(
|
||||
() =>
|
||||
(selectedProject && selectedProject.boards.find((board) => board.id === path.boardId)) ||
|
||||
null,
|
||||
[selectedProject, path.boardId],
|
||||
);
|
||||
|
||||
const selectedList = useMemo(
|
||||
() => (selectedBoard && selectedBoard.lists.find((list) => list.id === path.listId)) || null,
|
||||
[selectedBoard, path.listId],
|
||||
);
|
||||
|
||||
const handleBoardIdChange = useCallback(
|
||||
(event, data) => {
|
||||
if (selectedProject.boards.find((board) => board.id === data.value).isFetching === null) {
|
||||
onBoardFetch(data.value);
|
||||
}
|
||||
|
||||
handleFieldChange(event, data);
|
||||
},
|
||||
[onBoardFetch, handleFieldChange, selectedProject],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (selectedBoard.id !== defaultPath.boardId) {
|
||||
onTransfer(selectedBoard.id, selectedList.id);
|
||||
} else if (selectedList.id !== defaultPath.listId) {
|
||||
console.log('test');
|
||||
}
|
||||
|
||||
onCopyCard(selectedList.id, { name: 'test' }, false);
|
||||
onClose();
|
||||
}, [defaultPath, onTransfer, onClose, selectedBoard, selectedList, onCopyCard]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header onBack={onBack}>
|
||||
{t('action.copyCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div className={styles.text}>{t('common.project')}</div>
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
name="projectId"
|
||||
options={projectsToLists.map((project) => ({
|
||||
text: project.name,
|
||||
value: project.id,
|
||||
}))}
|
||||
value={selectedProject && selectedProject.id}
|
||||
placeholder={
|
||||
projectsToLists.length === 0 ? t('common.noProjects') : t('common.selectProject')
|
||||
}
|
||||
disabled={projectsToLists.length === 0}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{selectedProject && (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.board')}</div>
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
name="boardId"
|
||||
options={selectedProject.boards.map((board) => ({
|
||||
text: board.name,
|
||||
value: board.id,
|
||||
}))}
|
||||
value={selectedBoard && selectedBoard.id}
|
||||
placeholder={
|
||||
selectedProject.boards.length === 0
|
||||
? t('common.noBoards')
|
||||
: t('common.selectBoard')
|
||||
}
|
||||
disabled={selectedProject.boards.length === 0}
|
||||
className={styles.field}
|
||||
onChange={handleBoardIdChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selectedBoard && (
|
||||
<>
|
||||
<div className={styles.text}>{t('common.list')}</div>
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
name="listId"
|
||||
options={selectedBoard.lists.map((list) => ({
|
||||
text: list.name,
|
||||
value: list.id,
|
||||
}))}
|
||||
value={selectedList && selectedList.id}
|
||||
placeholder={
|
||||
selectedBoard.isFetching === false && selectedBoard.lists.length === 0
|
||||
? t('common.noLists')
|
||||
: t('common.selectList')
|
||||
}
|
||||
loading={selectedBoard.isFetching !== false}
|
||||
disabled={selectedBoard.isFetching !== false || selectedBoard.lists.length === 0}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.copy')} // change this action.copy
|
||||
disabled={(selectedBoard && selectedBoard.isFetching !== false) || !selectedList}
|
||||
/>
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardCopyStep.propTypes = {
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
projectsToLists: PropTypes.array.isRequired,
|
||||
defaultPath: PropTypes.object.isRequired,
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
// onMove: PropTypes.func.isRequired,
|
||||
onTransfer: PropTypes.func.isRequired,
|
||||
onBoardFetch: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
CardCopyStep.defaultProps = {
|
||||
onBack: undefined,
|
||||
};
|
||||
|
||||
export default CardCopyStep;
|
12
client/src/components/CardCopyStep/CardCopyStep.module.scss
Normal file
12
client/src/components/CardCopyStep/CardCopyStep.module.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
3
client/src/components/CardCopyStep/index.js
Normal file
3
client/src/components/CardCopyStep/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import CardCopyStep from './CardCopyStep';
|
||||
|
||||
export default CardCopyStep;
|
|
@ -24,6 +24,7 @@ import DueDateEditStep from '../DueDateEditStep';
|
|||
import StopwatchEditStep from '../StopwatchEditStep';
|
||||
import CardMoveStep from '../CardMoveStep';
|
||||
import DeleteStep from '../DeleteStep';
|
||||
import CardCopyStep from '../CardCopyStep';
|
||||
import CardCopyPopup from '../CardCopyPopup';
|
||||
|
||||
import styles from './CardModal.module.scss';
|
||||
|
@ -79,6 +80,7 @@ const CardModal = React.memo(
|
|||
onCommentActivityUpdate,
|
||||
onCommentActivityDelete,
|
||||
onClose,
|
||||
onCopyCard,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
|
@ -203,6 +205,7 @@ const CardModal = React.memo(
|
|||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<User name={user.name} avatarUrl={user.avatarUrl} />
|
||||
</BoardMembershipsPopup>
|
||||
|
@ -217,6 +220,7 @@ const CardModal = React.memo(
|
|||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -248,6 +252,7 @@ const CardModal = React.memo(
|
|||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Label name={label.name} color={label.color} />
|
||||
</LabelsPopup>
|
||||
|
@ -266,6 +271,7 @@ const CardModal = React.memo(
|
|||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -286,11 +292,15 @@ const CardModal = React.memo(
|
|||
</div>
|
||||
<span className={styles.attachment}>
|
||||
{canEdit ? (
|
||||
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
|
||||
<DueDate value={dueDate} />
|
||||
<DueDateEditPopup
|
||||
defaultValue={dueDate}
|
||||
onUpdate={handleDueDateUpdate}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<DueDate value={dueDate} onCopyCard={onCopyCard} />
|
||||
</DueDateEditPopup>
|
||||
) : (
|
||||
<DueDate value={dueDate} />
|
||||
<DueDate value={dueDate} onCopyCard={onCopyCard} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -308,10 +318,18 @@ const CardModal = React.memo(
|
|||
defaultValue={stopwatch}
|
||||
onUpdate={handleStopwatchUpdate}
|
||||
>
|
||||
<Stopwatch startedAt={stopwatch.startedAt} total={stopwatch.total} />
|
||||
<Stopwatch
|
||||
startedAt={stopwatch.startedAt}
|
||||
total={stopwatch.total}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
</StopwatchEditPopup>
|
||||
) : (
|
||||
<Stopwatch startedAt={stopwatch.startedAt} total={stopwatch.total} />
|
||||
<Stopwatch
|
||||
startedAt={stopwatch.startedAt}
|
||||
total={stopwatch.total}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{canEdit && (
|
||||
|
@ -337,7 +355,11 @@ const CardModal = React.memo(
|
|||
<Icon name="align justify" className={styles.moduleIcon} />
|
||||
<div className={styles.moduleHeader}>{t('common.description')}</div>
|
||||
{canEdit ? (
|
||||
<DescriptionEdit defaultValue={description} onUpdate={handleDescriptionUpdate}>
|
||||
<DescriptionEdit
|
||||
defaultValue={description}
|
||||
onUpdate={handleDescriptionUpdate}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
{description ? (
|
||||
<button
|
||||
type="button"
|
||||
|
@ -377,6 +399,7 @@ const CardModal = React.memo(
|
|||
onUpdate={onTaskUpdate}
|
||||
onMove={onTaskMove}
|
||||
onDelete={onTaskDelete}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -394,6 +417,7 @@ const CardModal = React.memo(
|
|||
onCoverUpdate={handleCoverUpdate}
|
||||
onGalleryOpen={handleGalleryOpen}
|
||||
onGalleryClose={handleGalleryClose}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -411,6 +435,7 @@ const CardModal = React.memo(
|
|||
onCommentCreate={onCommentActivityCreate}
|
||||
onCommentUpdate={onCommentActivityUpdate}
|
||||
onCommentDelete={onCommentActivityDelete}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
</Grid.Column>
|
||||
{canEdit && (
|
||||
|
@ -422,6 +447,7 @@ const CardModal = React.memo(
|
|||
currentUserIds={userIds}
|
||||
onUserSelect={onUserAdd}
|
||||
onUserDeselect={onUserRemove}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="user outline" className={styles.actionIcon} />
|
||||
|
@ -437,13 +463,18 @@ const CardModal = React.memo(
|
|||
onUpdate={onLabelUpdate}
|
||||
onMove={onLabelMove}
|
||||
onDelete={onLabelDelete}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="bookmark outline" className={styles.actionIcon} />
|
||||
{t('common.labels')}
|
||||
</Button>
|
||||
</LabelsPopup>
|
||||
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
|
||||
<DueDateEditPopup
|
||||
defaultValue={dueDate}
|
||||
onUpdate={handleDueDateUpdate}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="calendar check outline" className={styles.actionIcon} />
|
||||
{t('common.dueDate', {
|
||||
|
@ -451,13 +482,17 @@ const CardModal = React.memo(
|
|||
})}
|
||||
</Button>
|
||||
</DueDateEditPopup>
|
||||
<StopwatchEditPopup defaultValue={stopwatch} onUpdate={handleStopwatchUpdate}>
|
||||
<StopwatchEditPopup
|
||||
defaultValue={stopwatch}
|
||||
onUpdate={handleStopwatchUpdate}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="clock outline" className={styles.actionIcon} />
|
||||
{t('common.stopwatch')}
|
||||
</Button>
|
||||
</StopwatchEditPopup>
|
||||
<AttachmentAddPopup onCreate={onAttachmentCreate}>
|
||||
<AttachmentAddPopup onCreate={onAttachmentCreate} onCopyCard={onCopyCard}>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="attach" className={styles.actionIcon} />
|
||||
{t('common.attachment')}
|
||||
|
@ -484,6 +519,7 @@ const CardModal = React.memo(
|
|||
onMove={onMove}
|
||||
onTransfer={onTransfer}
|
||||
onBoardFetch={onBoardFetch}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button
|
||||
fluid
|
||||
|
@ -510,11 +546,12 @@ const CardModal = React.memo(
|
|||
onMove={onMove}
|
||||
onTransfer={onTransfer}
|
||||
onBoardFetch={onBoardFetch}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button
|
||||
fluid
|
||||
className={styles.actionButton}
|
||||
// onClick={handleToggleSubscriptionClick}
|
||||
onClick={handleToggleSubscriptionClick}
|
||||
>
|
||||
<Icon name="copy outline" className={styles.actionIcon} />
|
||||
{t('action.copy')}
|
||||
|
@ -602,6 +639,7 @@ CardModal.propTypes = {
|
|||
onCommentActivityUpdate: PropTypes.func.isRequired,
|
||||
onCommentActivityDelete: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
CardModal.defaultProps = {
|
||||
|
|
|
@ -9,7 +9,16 @@ import { useForm } from '../../hooks';
|
|||
import styles from './CardMoveStep.module.scss';
|
||||
|
||||
const CardMoveStep = React.memo(
|
||||
({ projectsToLists, defaultPath, onMove, onTransfer, onBoardFetch, onBack, onClose }) => {
|
||||
({
|
||||
projectsToLists,
|
||||
defaultPath,
|
||||
onMove,
|
||||
onTransfer,
|
||||
onBoardFetch,
|
||||
onBack,
|
||||
onClose,
|
||||
onCopyCard,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
|
||||
const [path, handleFieldChange] = useForm(() => ({
|
||||
|
@ -103,6 +112,7 @@ const CardMoveStep = React.memo(
|
|||
disabled={selectedProject.boards.length === 0}
|
||||
className={styles.field}
|
||||
onChange={handleBoardIdChange}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
@ -127,6 +137,7 @@ const CardMoveStep = React.memo(
|
|||
disabled={selectedBoard.isFetching !== false || selectedBoard.lists.length === 0}
|
||||
className={styles.field}
|
||||
onChange={handleFieldChange}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
@ -152,6 +163,7 @@ CardMoveStep.propTypes = {
|
|||
onBoardFetch: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
CardMoveStep.defaultProps = {
|
||||
|
|
|
@ -32,6 +32,7 @@ const LabelsStep = React.memo(
|
|||
onMove,
|
||||
onDelete,
|
||||
onBack,
|
||||
onCopyCard,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
@ -118,6 +119,7 @@ const LabelsStep = React.memo(
|
|||
}}
|
||||
onCreate={onCreate}
|
||||
onBack={handleBack}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
);
|
||||
case StepTypes.EDIT: {
|
||||
|
@ -130,6 +132,7 @@ const LabelsStep = React.memo(
|
|||
onUpdate={(data) => handleUpdate(currentItem.id, data)}
|
||||
onDelete={() => handleDelete(currentItem.id)}
|
||||
onBack={handleBack}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -180,6 +183,7 @@ const LabelsStep = React.memo(
|
|||
onSelect={() => handleSelect(item.id)}
|
||||
onDeselect={() => handleDeselect(item.id)}
|
||||
onEdit={() => handleEdit(item.id)}
|
||||
onCopyCard={onCopyCard}
|
||||
/>
|
||||
))}
|
||||
{placeholder}
|
||||
|
@ -227,6 +231,7 @@ LabelsStep.propTypes = {
|
|||
onMove: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onBack: PropTypes.func,
|
||||
onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
LabelsStep.defaultProps = {
|
||||
|
|
|
@ -12,11 +12,23 @@ import NameEdit from './NameEdit';
|
|||
import CardAdd from './CardAdd';
|
||||
import ActionsStep from './ActionsStep';
|
||||
import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-icon.svg';
|
||||
import CardCopyStep from '../CardCopyStep';
|
||||
|
||||
import styles from './List.module.scss';
|
||||
|
||||
const List = React.memo(
|
||||
({ id, index, name, isPersisted, cardIds, canEdit, onUpdate, onDelete, onCardCreate }) => {
|
||||
({
|
||||
id,
|
||||
index,
|
||||
name,
|
||||
isPersisted,
|
||||
cardIds,
|
||||
canEdit,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onCardCreate,
|
||||
onCopyCard,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState('name');
|
||||
|
@ -123,6 +135,7 @@ const List = React.memo(
|
|||
onSort={onSort}
|
||||
selectedOption={selectedOption}
|
||||
setSelectedOption={setSelectedOption}
|
||||
onCopyCard={onCopyCard}
|
||||
>
|
||||
<Button className={classNames(styles.headerButton, styles.target)}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
|
@ -170,6 +183,7 @@ List.propTypes = {
|
|||
onUpdate: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onCardCreate: PropTypes.func.isRequired,
|
||||
onCopyCard: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default List;
|
||||
|
|
|
@ -197,6 +197,8 @@ export default {
|
|||
CARD_DELETE__SUCCESS: 'CARD_DELETE__SUCCESS',
|
||||
CARD_DELETE__FAILURE: 'CARD_DELETE__FAILURE',
|
||||
CARD_DELETE_HANDLE: 'CARD_DELETE_HANDLE',
|
||||
CARD_COPY_HANDLE: 'CARD_COPY_HANDLE',
|
||||
CARD_COPY: 'CARD_COPY',
|
||||
|
||||
/* Tasks */
|
||||
|
||||
|
|
|
@ -136,6 +136,8 @@ export default {
|
|||
CARD_DELETE: `${PREFIX}/CARD_DELETE`,
|
||||
CURRENT_CARD_DELETE: `${PREFIX}/CURRENT_CARD_DELETE`,
|
||||
CARD_DELETE_HANDLE: `${PREFIX}/CARD_DELETE_HANDLE`,
|
||||
CARD_COPY: `${PREFIX}/CARD_COPY`,
|
||||
CARD_COPY_HANDLE: `${PREFIX}/CARD_COPY_HANDLE`,
|
||||
|
||||
/* Tasks */
|
||||
|
||||
|
|
|
@ -12,4 +12,5 @@ export const ActivityTypes = {
|
|||
CREATE_CARD: 'createCard',
|
||||
MOVE_CARD: 'moveCard',
|
||||
COMMENT_CARD: 'commentCard',
|
||||
COPY_CARD: 'copyCard',
|
||||
};
|
||||
|
|
|
@ -27,6 +27,7 @@ const mapDispatchToProps = (dispatch) =>
|
|||
onListCreate: entryActions.createListInCurrentBoard,
|
||||
onListMove: entryActions.moveList,
|
||||
onCardMove: entryActions.moveCard,
|
||||
onCardCopy: entryActions.copyCard,
|
||||
},
|
||||
dispatch,
|
||||
);
|
||||
|
|
|
@ -25,6 +25,7 @@ const mapDispatchToProps = (dispatch) =>
|
|||
onMove: entryActions.moveBoard,
|
||||
onDelete: entryActions.deleteBoard,
|
||||
onSort: entryActions.sortBoard,
|
||||
onCopyCard: entryActions.createCard,
|
||||
},
|
||||
dispatch,
|
||||
);
|
||||
|
|
|
@ -72,6 +72,7 @@ const mapDispatchToProps = (dispatch, { id }) =>
|
|||
onLabelUpdate: (labelId, data) => entryActions.updateLabel(labelId, data),
|
||||
onLabelMove: (labelId, index) => entryActions.moveLabel(labelId, index),
|
||||
onLabelDelete: (labelId) => entryActions.deleteLabel(labelId),
|
||||
onCopyCard: (listId, data) => entryActions.createCard(listId, data, false),
|
||||
},
|
||||
dispatch,
|
||||
);
|
||||
|
|
|
@ -100,6 +100,7 @@ const mapDispatchToProps = (dispatch) =>
|
|||
onCommentActivityCreate: entryActions.createCommentActivityInCurrentCard,
|
||||
onCommentActivityUpdate: entryActions.updateCommentActivity,
|
||||
onCommentActivityDelete: entryActions.deleteCommentActivity,
|
||||
onCopyCard: entryActions.copyCard,
|
||||
push,
|
||||
},
|
||||
dispatch,
|
||||
|
|
|
@ -47,6 +47,15 @@ const moveCard = (id, listId, index = 0) => ({
|
|||
},
|
||||
});
|
||||
|
||||
const copyCard = (id, listId, index = 0) => ({
|
||||
type: EntryActionTypes.CARD_COPY,
|
||||
payload: {
|
||||
id,
|
||||
listId,
|
||||
index,
|
||||
},
|
||||
});
|
||||
|
||||
const moveCurrentCard = (listId, index = 0) => ({
|
||||
type: EntryActionTypes.CURRENT_CARD_MOVE,
|
||||
payload: {
|
||||
|
@ -106,4 +115,5 @@ export default {
|
|||
deleteCard,
|
||||
deleteCurrentCard,
|
||||
handleCardDelete,
|
||||
copyCard,
|
||||
};
|
||||
|
|
109
client/src/lib/popup/with-popup.jsx
Normal file
109
client/src/lib/popup/with-popup.jsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { ResizeObserver } from '@juggle/resize-observer';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Popup as SemanticUIPopup } from 'semantic-ui-react';
|
||||
|
||||
import styles from './Popup.module.css';
|
||||
|
||||
export default (WrappedComponent, defaultProps) => {
|
||||
const Popup = React.memo(({ children, ...props }) => {
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
|
||||
const wrapper = useRef(null);
|
||||
const resizeObserver = useRef(null);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setIsOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpened(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleTriggerClick = useCallback(
|
||||
(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const { onClick } = children;
|
||||
|
||||
if (onClick) {
|
||||
onClick(event);
|
||||
}
|
||||
},
|
||||
[children],
|
||||
);
|
||||
|
||||
const handleContentRef = useCallback((element) => {
|
||||
if (resizeObserver.current) {
|
||||
resizeObserver.current.disconnect();
|
||||
}
|
||||
|
||||
if (!element) {
|
||||
resizeObserver.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
resizeObserver.current = new ResizeObserver(() => {
|
||||
if (resizeObserver.current.isInitial) {
|
||||
resizeObserver.current.isInitial = false;
|
||||
return;
|
||||
}
|
||||
|
||||
wrapper.current.positionUpdate();
|
||||
});
|
||||
|
||||
resizeObserver.current.isInitial = true;
|
||||
resizeObserver.current.observe(element);
|
||||
}, []);
|
||||
|
||||
const tigger = React.cloneElement(children, {
|
||||
onClick: handleTriggerClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<SemanticUIPopup
|
||||
basic
|
||||
wide
|
||||
ref={wrapper}
|
||||
trigger={tigger}
|
||||
on="click"
|
||||
open={isOpened}
|
||||
position="bottom left"
|
||||
popperModifiers={[
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
boundariesElement: 'window',
|
||||
},
|
||||
},
|
||||
]}
|
||||
className={styles.wrapper}
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
{...defaultProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
>
|
||||
<div ref={handleContentRef}>
|
||||
<Button icon="close" onClick={handleClose} className={styles.closeButton} />
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<WrappedComponent {...props} onClose={handleClose} />
|
||||
</div>
|
||||
</SemanticUIPopup>
|
||||
);
|
||||
});
|
||||
|
||||
Popup.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
return Popup;
|
||||
};
|
|
@ -37,5 +37,9 @@ export default function* cardsWatchers() {
|
|||
takeEvery(EntryActionTypes.CARD_DELETE_HANDLE, ({ payload: { card } }) =>
|
||||
services.handleCardDelete(card),
|
||||
),
|
||||
|
||||
takeEvery(EntryActionTypes.CARD_COPY, ({ payload: { listId, data, autoOpen } }) =>
|
||||
services.createCard(listId, data, autoOpen),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ const Types = {
|
|||
CREATE_CARD: 'createCard',
|
||||
MOVE_CARD: 'moveCard',
|
||||
COMMENT_CARD: 'commentCard',
|
||||
COPY_CARD: 'copyCard',
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue