1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

feat: Add board activity log

This commit is contained in:
Maksim Eltyshev 2025-05-22 23:14:46 +02:00
parent 777ff467f3
commit 86cfd155f2
72 changed files with 833 additions and 169 deletions

View file

@ -5,15 +5,39 @@
import ActionTypes from '../constants/ActionTypes';
const fetchActivities = (cardId) => ({
type: ActionTypes.ACTIVITIES_FETCH,
const fetchActivitiesInBoard = (boardId) => ({
type: ActionTypes.ACTIVITIES_IN_BOARD_FETCH,
payload: {
boardId,
},
});
fetchActivitiesInBoard.success = (boardId, activities, users) => ({
type: ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS,
payload: {
boardId,
activities,
users,
},
});
fetchActivitiesInBoard.failure = (boardId, error) => ({
type: ActionTypes.ACTIVITIES_IN_BOARD_FETCH__FAILURE,
payload: {
boardId,
error,
},
});
const fetchActivitiesInCard = (cardId) => ({
type: ActionTypes.ACTIVITIES_IN_CARD_FETCH,
payload: {
cardId,
},
});
fetchActivities.success = (cardId, activities, users) => ({
type: ActionTypes.ACTIVITIES_FETCH__SUCCESS,
fetchActivitiesInCard.success = (cardId, activities, users) => ({
type: ActionTypes.ACTIVITIES_IN_CARD_FETCH__SUCCESS,
payload: {
cardId,
activities,
@ -21,8 +45,8 @@ fetchActivities.success = (cardId, activities, users) => ({
},
});
fetchActivities.failure = (cardId, error) => ({
type: ActionTypes.ACTIVITIES_FETCH__FAILURE,
fetchActivitiesInCard.failure = (cardId, error) => ({
type: ActionTypes.ACTIVITIES_IN_CARD_FETCH__FAILURE,
payload: {
cardId,
error,
@ -37,6 +61,7 @@ const handleActivityCreate = (activity) => ({
});
export default {
fetchActivities,
fetchActivitiesInBoard,
fetchActivitiesInCard,
handleActivityCreate,
};

View file

@ -16,7 +16,13 @@ export const transformActivity = (activity) => ({
/* Actions */
const getActivities = (cardId, data, headers) =>
const getActivitiesInBoard = (boardId, data, headers) =>
socket.get(`/boards/${boardId}/actions`, data, headers).then((body) => ({
...body,
items: body.items.map(transformActivity),
}));
const getActivitiesInCard = (cardId, data, headers) =>
socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({
...body,
items: body.items.map(transformActivity),
@ -32,6 +38,7 @@ const makeHandleActivityCreate = (next) => (body) => {
};
export default {
getActivities,
getActivitiesInBoard,
getActivitiesInCard,
makeHandleActivityCreate,
};

View file

@ -0,0 +1,73 @@
/*!
* 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 { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useInView } from 'react-intersection-observer';
import { Comment, Loader } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import Item from './Item';
import styles from './BoardActivitiesModal.module.scss';
const BoardActivitiesModal = React.memo(() => {
const activityIds = useSelector(selectors.selectActivityIdsForCurrentBoard);
const { isActivitiesFetching, isAllActivitiesFetched } = useSelector(
selectors.selectCurrentBoard,
);
const dispatch = useDispatch();
const [t] = useTranslation();
const handleClose = useCallback(() => {
dispatch(entryActions.closeModal());
}, [dispatch]);
const [inViewRef] = useInView({
threshold: 1,
onChange: (inView) => {
if (inView) {
dispatch(entryActions.fetchActivitiesInCurrentBoard());
}
},
});
const [ClosableModal] = useClosableModal();
return (
<ClosableModal closeIcon size="small" centered={false} onClose={handleClose}>
<ClosableModal.Header>
{t('common.boardActions', {
context: 'title',
})}
</ClosableModal.Header>
<ClosableModal.Content>
<div className={styles.itemsWrapper}>
<Comment.Group className={styles.items}>
{activityIds.map((activityId) => (
<Item key={activityId} id={activityId} />
))}
</Comment.Group>
</div>
{isActivitiesFetching !== undefined && isAllActivitiesFetched !== undefined && (
<div className={styles.loaderWrapper}>
{isActivitiesFetching ? (
<Loader active inverted inline="centered" size="small" />
) : (
!isAllActivitiesFetched && <div ref={inViewRef} />
)}
</div>
)}
</ClosableModal.Content>
</ClosableModal>
);
});
export default BoardActivitiesModal;

View file

@ -0,0 +1,18 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.items {
max-width: none;
}
.itemsWrapper {
margin-top: 12px;
}
.loaderWrapper {
margin-top: 10px;
}
}

View file

@ -0,0 +1,220 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useTranslation, Trans } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Comment } from 'semantic-ui-react';
import selectors from '../../../selectors';
import Paths from '../../../constants/Paths';
import { StaticUserIds } from '../../../constants/StaticUsers';
import { ActivityTypes } from '../../../constants/Enums';
import TimeAgo from '../../common/TimeAgo';
import UserAvatar from '../../users/UserAvatar';
import styles from './Item.module.scss';
const Item = React.memo(({ id }) => {
const selectActivityById = useMemo(() => selectors.makeSelectActivityById(), []);
const selectUserById = useMemo(() => selectors.makeSelectUserById(), []);
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const activity = useSelector((state) => selectActivityById(state, id));
const user = useSelector((state) => selectUserById(state, activity.userId));
const card = useSelector((state) => selectCardById(state, activity.cardId));
const [t] = useTranslation();
const userName =
user.id === StaticUserIds.DELETED
? t(`common.${user.name}`, {
context: 'title',
})
: user.name;
const cardName = card ? card.name : activity.data.card.name;
let contentNode;
switch (activity.type) {
case ActivityTypes.CREATE_CARD: {
const { list } = activity.data;
const listName = list.name || t(`common.${list.type}`);
contentNode = (
<Trans
i18nKey="common.userAddedCardToList"
values={{
user: userName,
card: cardName,
list: listName,
}}
>
<span className={styles.author}>{userName}</span>
{' added '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
{' to '}
{listName}
</Trans>
);
break;
}
case ActivityTypes.MOVE_CARD: {
const { fromList, toList } = activity.data;
const fromListName = fromList.name || t(`common.${fromList.type}`);
const toListName = toList.name || t(`common.${toList.type}`);
contentNode = (
<Trans
i18nKey="common.userMovedCardFromListToList"
values={{
user: userName,
card: cardName,
fromList: fromListName,
toList: toListName,
}}
>
<span className={styles.author}>{userName}</span>
{' moved '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
{' from '}
{fromListName}
{' to '}
{toListName}
</Trans>
);
break;
}
case ActivityTypes.ADD_MEMBER_TO_CARD:
contentNode =
user.id === activity.data.user.id ? (
<Trans
i18nKey="common.userJoinedCard"
values={{
user: userName,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' joined '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
) : (
<Trans
i18nKey="common.userAddedUserToCard"
values={{
actorUser: userName,
addedUser: activity.data.user.name,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' added '}
{activity.data.user.name}
{' to '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
);
break;
case ActivityTypes.REMOVE_MEMBER_FROM_CARD:
contentNode =
user.id === activity.data.user.id ? (
<Trans
i18nKey="common.userLeftCard"
values={{
user: userName,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' left '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
) : (
<Trans
i18nKey="common.userRemovedUserFromCard"
values={{
actorUser: userName,
removedUser: activity.data.user.name,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' removed '}
{activity.data.user.name}
{' from '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
);
break;
case ActivityTypes.COMPLETE_TASK:
contentNode = (
<Trans
i18nKey="common.userCompletedTaskOnCard"
values={{
user: userName,
task: activity.data.task.name,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' completed '}
{activity.data.task.name}
{' on '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
);
break;
case ActivityTypes.UNCOMPLETE_TASK:
contentNode = (
<Trans
i18nKey="common.userMarkedTaskIncompleteOnCard"
values={{
user: userName,
task: activity.data.task.name,
card: cardName,
}}
>
<span className={styles.author}>{userName}</span>
{' marked '}
{activity.data.task.name}
{' incomplete on '}
<Link to={Paths.CARDS.replace(':id', activity.cardId)}>{cardName}</Link>
</Trans>
);
break;
default:
contentNode = null;
}
return (
<Comment>
<span className={styles.user}>
<UserAvatar id={activity.userId} />
</span>
<div className={styles.content}>
<div>{contentNode}</div>
<span className={styles.date}>
<TimeAgo date={activity.createdAt} />
</span>
</div>
</Comment>
);
});
Item.propTypes = {
id: PropTypes.string.isRequired,
};
export default Item;

View file

@ -7,12 +7,12 @@
.author {
color: #17394d;
font-weight: bold;
line-height: 20px;
}
.content {
border-bottom: 1px solid #092d4221;
display: inline-block;
line-height: 20px;
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
@ -21,11 +21,6 @@
.date {
color: #6b808c;
font-size: 12px;
line-height: 20px;
}
.text {
line-height: 20px;
}
.user {

View file

@ -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 BoardActivitiesModal from './BoardActivitiesModal';
export default BoardActivitiesModal;

View file

@ -12,9 +12,9 @@ import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import Item from './Item';
import styles from './Activities.module.scss';
import styles from './CardActivities.module.scss';
const Activities = React.memo(() => {
const CardActivities = React.memo(() => {
const activityIds = useSelector(selectors.selectActivityIdsForCurrentCard);
const { isActivitiesFetching, isAllActivitiesFetched } = useSelector(selectors.selectCurrentCard);
@ -51,4 +51,4 @@ const Activities = React.memo(() => {
);
});
export default Activities;
export default CardActivities;

View file

@ -48,10 +48,8 @@ const Item = React.memo(({ id }) => {
}}
>
<span className={styles.author}>{userName}</span>
<span className={styles.text}>
{' added this card to '}
{listName}
</span>
{' added this card to '}
{listName}
</Trans>
);
@ -73,12 +71,10 @@ const Item = React.memo(({ id }) => {
}}
>
<span className={styles.author}>{userName}</span>
<span className={styles.text}>
{' moved this card from '}
{fromListName}
{' to '}
{toListName}
</span>
{' moved this card from '}
{fromListName}
{' to '}
{toListName}
</Trans>
);
@ -94,7 +90,7 @@ const Item = React.memo(({ id }) => {
}}
>
<span className={styles.author}>{userName}</span>
<span className={styles.text}>{' joined this card'}</span>
{' joined this card'}
</Trans>
) : (
<Trans
@ -105,11 +101,9 @@ const Item = React.memo(({ id }) => {
}}
>
<span className={styles.author}>{userName}</span>
<span className={styles.text}>
{' added '}
{activity.data.user.name}
{' to this card'}
</span>
{' added '}
{activity.data.user.name}
{' to this card'}
</Trans>
);
@ -124,7 +118,7 @@ const Item = React.memo(({ id }) => {
}}
>
<span className={styles.author}>{userName}</span>
<span className={styles.text}>{' left this card'}</span>
{' left this card'}
</Trans>
) : (
<Trans
@ -135,11 +129,9 @@ const Item = React.memo(({ id }) => {
}}
>
<span className={styles.author}>{userName}</span>
<span className={styles.text}>
{' removed '}
{activity.data.user.name}
{' from this card'}
</span>
{' removed '}
{activity.data.user.name}
{' from this card'}
</Trans>
);
@ -154,11 +146,9 @@ const Item = React.memo(({ id }) => {
}}
>
<span className={styles.author}>{userName}</span>
<span className={styles.text}>
{' completed '}
{activity.data.task.name}
{' on this card'}
</span>
{' completed '}
{activity.data.task.name}
{' on this card'}
</Trans>
);
@ -173,11 +163,9 @@ const Item = React.memo(({ id }) => {
}}
>
<span className={styles.author}>{userName}</span>
<span className={styles.text}>
{' marked '}
{activity.data.task.name}
{' incomplete on this card'}
</span>
{' marked '}
{activity.data.task.name}
{' incomplete on this card'}
</Trans>
);

View file

@ -0,0 +1,31 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.author {
color: #17394d;
font-weight: bold;
}
.content {
border-bottom: 1px solid #092d4221;
display: inline-block;
line-height: 20px;
padding-bottom: 14px;
vertical-align: top;
width: calc(100% - 40px);
}
.date {
color: #6b808c;
font-size: 12px;
}
.user {
display: inline-block;
padding: 4px 8px 0 0;
vertical-align: top;
}
}

View file

@ -3,6 +3,6 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import Activities from './Activities';
import CardActivities from './CardActivities';
export default Activities;
export default CardActivities;

View file

@ -7,14 +7,17 @@ import React from 'react';
import { useSelector } from 'react-redux';
import selectors from '../../../selectors';
import ModalTypes from '../../../constants/ModalTypes';
import { BoardContexts, BoardViews } from '../../../constants/Enums';
import KanbanContent from './KanbanContent';
import FiniteContent from './FiniteContent';
import EndlessContent from './EndlessContent';
import CardModal from '../../cards/CardModal';
import BoardActivitiesModal from '../../activities/BoardActivitiesModal';
const Board = React.memo(() => {
const board = useSelector(selectors.selectCurrentBoard);
const modal = useSelector(selectors.selectCurrentModal);
const isCardModalOpened = useSelector((state) => !!selectors.selectPath(state).cardId);
let Content;
@ -35,10 +38,23 @@ const Board = React.memo(() => {
}
}
let modalNode = null;
if (isCardModalOpened) {
modalNode = <CardModal />;
} else if (modal) {
switch (modal.type) {
case ModalTypes.BOARD_ACTIVITIES:
modalNode = <BoardActivitiesModal />;
break;
default:
}
}
return (
<>
<Content />
{isCardModalOpened && <CardModal />}
{modalNode}
</>
);
});

View file

@ -21,14 +21,14 @@ import CustomFieldGroupsStep from '../../../custom-field-groups/CustomFieldGroup
import styles from './ActionsStep.module.scss';
const StepTypes = {
EMPTY_TRASH: 'EMPTY_TRASH',
CUSTOM_FIELD_GROUPS: 'CUSTOM_FIELD_GROUPS',
EMPTY_TRASH: 'EMPTY_TRASH',
};
const ActionsStep = React.memo(({ onClose }) => {
const board = useSelector(selectors.selectCurrentBoard);
const { withSubscribe, withTrashEmptier, withCustomFieldGroups } = useSelector((state) => {
const { withSubscribe, withCustomFieldGroups, withTrashEmptier } = useSelector((state) => {
const isManager = selectors.selectIsCurrentUserManagerForCurrentProject(state);
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
@ -42,8 +42,8 @@ const ActionsStep = React.memo(({ onClose }) => {
return {
withSubscribe: isMember, // TODO: rename?
withTrashEmptier: board.context === BoardContexts.TRASH && (isManager || isEditor),
withCustomFieldGroups: isEditor,
withTrashEmptier: board.context === BoardContexts.TRASH && (isManager || isEditor),
};
}, shallowEqual);
@ -69,21 +69,28 @@ const ActionsStep = React.memo(({ onClose }) => {
[onClose, dispatch],
);
const handleActivitiesClick = useCallback(() => {
dispatch(entryActions.openBoardActivitiesModal());
onClose();
}, [onClose, dispatch]);
const handleEmptyTrashConfirm = useCallback(() => {
dispatch(entryActions.clearTrashListInCurrentBoard());
onClose();
}, [onClose, dispatch]);
const handleEmptyTrashClick = useCallback(() => {
openStep(StepTypes.EMPTY_TRASH);
}, [openStep]);
const handleCustomFieldsClick = useCallback(() => {
openStep(StepTypes.CUSTOM_FIELD_GROUPS);
}, [openStep]);
const handleEmptyTrashClick = useCallback(() => {
openStep(StepTypes.EMPTY_TRASH);
}, [openStep]);
if (step) {
switch (step.type) {
case StepTypes.CUSTOM_FIELD_GROUPS:
return <CustomFieldGroupsStep onBack={handleBack} onClose={onClose} />;
case StepTypes.EMPTY_TRASH:
return (
<ConfirmationStep
@ -94,8 +101,6 @@ const ActionsStep = React.memo(({ onClose }) => {
onBack={handleBack}
/>
);
case StepTypes.CUSTOM_FIELD_GROUPS:
return <CustomFieldGroupsStep onBack={handleBack} onClose={onClose} />;
default:
}
}
@ -128,6 +133,12 @@ const ActionsStep = React.memo(({ onClose }) => {
})}
</Menu.Item>
)}
<Menu.Item className={styles.menuItem} onClick={handleActivitiesClick}>
<Icon name="list ul" className={styles.menuItemIcon} />
{t('common.actions', {
context: 'title',
})}
</Menu.Item>
{withTrashEmptier && (
<>
{(withSubscribe || withCustomFieldGroups) && <hr className={styles.divider} />}

View file

@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Menu, Tab } from 'semantic-ui-react';
import Comments from '../../comments/Comments';
import Activities from '../../activities/Activities';
import CardActivities from '../../activities/CardActivities';
import styles from './Communication.module.scss';
@ -34,7 +34,7 @@ const Communication = React.memo(() => {
})}
</Menu.Item>
),
render: () => <Activities />,
render: () => <CardActivities />,
},
];

View file

@ -29,6 +29,7 @@
.content {
display: inline-block;
font-size: 13px;
line-height: 20px;
min-height: 36px;
overflow: hidden;
padding: 0 4px 0 8px;
@ -40,7 +41,6 @@
.date {
color: #6b808c;
font-size: 12px;
line-height: 20px;
}
.wrapper {

View file

@ -360,9 +360,12 @@ export default {
/* Activities */
ACTIVITIES_FETCH: 'ACTIVITIES_FETCH',
ACTIVITIES_FETCH__SUCCESS: 'ACTIVITIES_FETCH__SUCCESS',
ACTIVITIES_FETCH__FAILURE: 'ACTIVITIES_FETCH__FAILURE',
ACTIVITIES_IN_BOARD_FETCH: 'ACTIVITIES_IN_BOARD_FETCH',
ACTIVITIES_IN_BOARD_FETCH__SUCCESS: 'ACTIVITIES_IN_BOARD_FETCH__SUCCESS',
ACTIVITIES_IN_BOARD_FETCH__FAILURE: 'ACTIVITIES_IN_BOARD_FETCH__FAILURE',
ACTIVITIES_IN_CARD_FETCH: 'ACTIVITIES_IN_CARD_FETCH',
ACTIVITIES_IN_CARD_FETCH__SUCCESS: 'ACTIVITIES_IN_CARD_FETCH__SUCCESS',
ACTIVITIES_IN_CARD_FETCH__FAILURE: 'ACTIVITIES_IN_CARD_FETCH__FAILURE',
ACTIVITY_CREATE_HANDLE: 'ACTIVITY_CREATE_HANDLE',
/* Notifications */

View file

@ -255,6 +255,7 @@ export default {
/* Activities */
ACTIVITIES_IN_CURRENT_BOARD_FETCH: `${PREFIX}/ACTIVITIES_IN_CURRENT_BOARD_FETCH`,
ACTIVITIES_IN_CURRENT_CARD_FETCH: `${PREFIX}/ACTIVITIES_IN_CURRENT_CARD_FETCH`,
ACTIVITY_CREATE_HANDLE: `${PREFIX}/ACTIVITY_CREATE_HANDLE`,

View file

@ -8,6 +8,7 @@ const USER_SETTINGS = 'USER_SETTINGS';
const ADD_PROJECT = 'ADD_PROJECT';
const PROJECT_SETTINGS = 'PROJECT_SETTINGS';
const BOARD_SETTINGS = 'BOARD_SETTINGS';
const BOARD_ACTIVITIES = 'BOARD_ACTIVITIES';
export default {
ADMINISTRATION,
@ -15,4 +16,5 @@ export default {
ADD_PROJECT,
PROJECT_SETTINGS,
BOARD_SETTINGS,
BOARD_ACTIVITIES,
};

View file

@ -5,6 +5,11 @@
import EntryActionTypes from '../constants/EntryActionTypes';
const fetchActivitiesInCurrentBoard = () => ({
type: EntryActionTypes.ACTIVITIES_IN_CURRENT_BOARD_FETCH,
payload: {},
});
const fetchActivitiesInCurrentCard = () => ({
type: EntryActionTypes.ACTIVITIES_IN_CURRENT_CARD_FETCH,
payload: {},
@ -18,6 +23,7 @@ const handleActivityCreate = (activity) => ({
});
export default {
fetchActivitiesInCurrentBoard,
fetchActivitiesInCurrentCard,
handleActivityCreate,
};

View file

@ -47,6 +47,13 @@ const openBoardSettingsModal = (boardId) => ({
},
});
const openBoardActivitiesModal = () => ({
type: EntryActionTypes.MODAL_OPEN,
payload: {
type: ModalTypes.BOARD_ACTIVITIES,
},
});
const closeModal = () => ({
type: EntryActionTypes.MODAL_CLOSE,
payload: {},
@ -58,5 +65,6 @@ export default {
openAddProjectModal,
openProjectSettingsModal,
openBoardSettingsModal,
openBoardActivitiesModal,
closeModal,
};

View file

@ -59,6 +59,7 @@ export default (initialClosableValue) => {
onClose: undefined,
};
ClosableModal.Header = Modal.Header;
ClosableModal.Content = Modal.Content;
ClosableModal.Actions = Modal.Actions;

View file

@ -152,12 +152,12 @@ export default {
time: 'الوقت',
title: 'العنوان',
userActions_title: 'إجراءات المستخدم',
userAddedThisCardToList: '<0>{{user}}</0><1> تمت إضافة هذه البطاقة إلى {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> تمت إضافة هذه البطاقة إلى {{list}}',
userLeftNewCommentToCard: '<0>{{user}}</0> ترك تعليق جديد «{{comment}}» إلى <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> انتقل <2>{{card}}</2> من {{fromList}} إلى {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> نُقلت هذه البطاقة من {{fromList}} إلى {{toList}}</1>',
'<0>{{user}}</0> نُقلت هذه البطاقة من {{fromList}} إلى {{toList}}',
username: 'اسم المستخدم',
users: 'المستخدمين',
viewer: 'مشاهد',

View file

@ -153,13 +153,13 @@ export default {
time: 'Време',
title: 'Заглавие',
userActions_title: 'Потребителски действия',
userAddedThisCardToList: '<0>{{user}}</0><1> добави тази карта в {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> добави тази карта в {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> остави нов коментар «{{comment}}» в <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> премести <2>{{card}}</2> от {{fromList}} към {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> премести тази карта от {{fromList}} към {{toList}}</1>',
'<0>{{user}}</0> премести тази карта от {{fromList}} към {{toList}}',
username: 'Потребителско име',
users: 'Потребители',
viewer: 'Зрител',

View file

@ -276,13 +276,13 @@ export default {
unsavedChanges: 'Neuložené změny',
uploadedImages: 'Nahrané obrázky',
userActions_title: 'Akce uživatele',
userAddedThisCardToList: '<0>{{user}}</0><1> přidal kartu do {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> přidal kartu do {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> zanechal nový komentář «{{comment}}» k <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> přesunul <2>{{card}}</2> z {{fromList}} do {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> přesunul tuto kartu z {{fromList}} do {{toList}}</1>',
'<0>{{user}}</0> přesunul tuto kartu z {{fromList}} do {{toList}}',
username: 'Uživatelské jméno',
users: 'Uživatelé',
viewer: 'Prohlížeč',

View file

@ -154,13 +154,13 @@ export default {
time: 'Tid',
title: 'Overskrift',
userActions_title: 'Brugerhandlinger',
userAddedThisCardToList: '<0>{{user}}</0><1> tilføjede kortet til {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> tilføjede kortet til {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> skrevet en ny kommentar «{{comment}}» på <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> flyttede <2>{{card}}</2> fra {{fromList}} til {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> flyttede kortet fra {{fromList}} til {{toList}}</1>',
'<0>{{user}}</0> flyttede kortet fra {{fromList}} til {{toList}}',
username: 'Brugernavn',
users: 'Brugere',
viewer: 'Læser',

View file

@ -296,13 +296,13 @@ export default {
unsavedChanges: 'Ungespeicherte Änderungen',
uploadedImages: 'Hochgeladene Bilder',
userActions_title: 'Benutzeraktionen',
userAddedThisCardToList: '<0>{{user}}</0><1> hat diese Karte hinzugefügt zu {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> hat diese Karte hinzugefügt zu {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> hat einen neuen Kommentar verfasst: «{{comment}}» in <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> bewegte <2>{{card}}</2> von {{fromList}} nach {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> bewegte diese Karte von {{fromList}} nach {{toList}}</1>',
'<0>{{user}}</0> bewegte diese Karte von {{fromList}} nach {{toList}}',
username: 'Benutzername',
users: 'Benutzer',
viewer: 'Betrachter',

View file

@ -286,22 +286,28 @@ export default {
unsavedChanges: 'Unsaved changes',
uploadedImages: 'Uploaded images',
userActions_title: 'User Actions',
userAddedThisCardToList: '<0>{{user}}</0><1> added this card to {{list}}</1>',
userAddedUserToThisCard: '<0>{{actorUser}}</0><1> added {{addedUser}} to this card</1>',
userAddedCardToList: '<0>{{user}}</0> added <2>{{card}}</2> to {{list}}',
userAddedThisCardToList: '<0>{{user}}</0> added this card to {{list}}',
userAddedUserToCard: '<0>{{actorUser}}</0> added {{addedUser}} to <4>{{card}}</4>',
userAddedUserToThisCard: '<0>{{actorUser}}</0> added {{addedUser}} to this card',
userAddedYouToCard: '<0>{{user}}</0> added you to <2>{{card}}</2>',
userCompletedTaskOnThisCard: '<0>{{user}}</0><1> completed {{task}} on this card</1>',
userJoinedThisCard: `<0>{{user}}</0><1> joined this card</1>`,
userCompletedTaskOnCard: '<0>{{user}}</0> completed {{task}} on <4>{{card}}</4>',
userCompletedTaskOnThisCard: '<0>{{user}}</0> completed {{task}} on this card',
userJoinedCard: `<0>{{user}}</0> joined <2>{{card}}</2>`,
userJoinedThisCard: `<0>{{user}}</0> joined this card`,
userLeftNewCommentToCard:
'<0>{{user}}</0> left a new comment «{{comment}}» to <2>{{card}}</2>',
userLeftThisCard: '<0>{{user}}</0><1> left this card</1>',
userMarkedTaskIncompleteOnThisCard:
'<0>{{user}}</0><1> marked {{task}} incomplete on this card</1>',
userLeftCard: '<0>{{user}}</0> left <2>{{card}}</2>',
userLeftThisCard: '<0>{{user}}</0> left this card',
userMarkedTaskIncompleteOnCard:
'<0>{{user}}</0> marked {{task}} incomplete on <4>{{card}}</4>',
userMarkedTaskIncompleteOnThisCard: '<0>{{user}}</0> marked {{task}} incomplete on this card',
userMovedCardFromListToList:
'<0>{{user}}</0> moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> moved this card from {{fromList}} to {{toList}}</1>',
userRemovedUserFromThisCard:
'<0>{{actorUser}}</0><1> removed {{removedUser}} from this card</1>',
'<0>{{user}}</0> moved this card from {{fromList}} to {{toList}}',
userRemovedUserFromCard: '<0>{{actorUser}}</0> removed {{removedUser}} from <4>{{card}}</4>',
userRemovedUserFromThisCard: '<0>{{actorUser}}</0> removed {{removedUser}} from this card',
username: 'Username',
users: 'Users',
viewer: 'Viewer',

View file

@ -281,22 +281,28 @@ export default {
unsavedChanges: 'Unsaved changes',
uploadedImages: 'Uploaded images',
userActions_title: 'User Actions',
userAddedThisCardToList: '<0>{{user}}</0><1> added this card to {{list}}</1>',
userAddedUserToThisCard: '<0>{{actorUser}}</0><1> added {{addedUser}} to this card</1>',
userAddedCardToList: '<0>{{user}}</0> added <2>{{card}}</2> to {{list}}',
userAddedThisCardToList: '<0>{{user}}</0> added this card to {{list}}',
userAddedUserToCard: '<0>{{actorUser}}</0> added {{addedUser}} to <4>{{card}}</4>',
userAddedUserToThisCard: '<0>{{actorUser}}</0> added {{addedUser}} to this card',
userAddedYouToCard: '<0>{{user}}</0> added you to <2>{{card}}</2>',
userCompletedTaskOnThisCard: '<0>{{user}}</0><1> completed {{task}} on this card</1>',
userJoinedThisCard: `<0>{{user}}</0><1> joined this card</1>`,
userCompletedTaskOnCard: '<0>{{user}}</0> completed {{task}} on <4>{{card}}</4>',
userCompletedTaskOnThisCard: '<0>{{user}}</0> completed {{task}} on this card',
userJoinedCard: `<0>{{user}}</0> joined <2>{{card}}</2>`,
userJoinedThisCard: `<0>{{user}}</0> joined this card`,
userLeftNewCommentToCard:
'<0>{{user}}</0> left a new comment «{{comment}}» to <2>{{card}}</2>',
userLeftThisCard: '<0>{{user}}</0><1> left this card</1>',
userMarkedTaskIncompleteOnThisCard:
'<0>{{user}}</0><1> marked {{task}} incomplete on this card</1>',
userLeftCard: '<0>{{user}}</0> left <2>{{card}}</2>',
userLeftThisCard: '<0>{{user}}</0> left this card',
userMarkedTaskIncompleteOnCard:
'<0>{{user}}</0> marked {{task}} incomplete on <4>{{card}}</4>',
userMarkedTaskIncompleteOnThisCard: '<0>{{user}}</0> marked {{task}} incomplete on this card',
userMovedCardFromListToList:
'<0>{{user}}</0> moved <2>{{card}}</2> from {{fromList}} to {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> moved this card from {{fromList}} to {{toList}}</1>',
userRemovedUserFromThisCard:
'<0>{{actorUser}}</0><1> removed {{removedUser}} from this card</1>',
'<0>{{user}}</0> moved this card from {{fromList}} to {{toList}}',
userRemovedUserFromCard: '<0>{{actorUser}}</0> removed {{removedUser}} from <4>{{card}}</4>',
userRemovedUserFromThisCard: '<0>{{actorUser}}</0> removed {{removedUser}} from this card',
username: 'Username',
users: 'Users',
viewer: 'Viewer',

View file

@ -117,13 +117,13 @@ export default {
time: 'Tiempo',
title: 'Título',
userActions_title: 'Acciones de Usuario',
userAddedThisCardToList: '<0>{{user}}</0><1> añadido a esta tarjeta en {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> añadido a esta tarjeta en {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> dejó un nuevo comentario «{{comment}}» en <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> movió <2>{{card}}</2> de {{fromList}} a {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> movió esta tarjeta de {{fromList}} a {{toList}}</1>',
'<0>{{user}}</0> movió esta tarjeta de {{fromList}} a {{toList}}',
username: 'Nombre de usuario',
users: 'Usuarios',
writeComment: 'Escribir un comentario...',

View file

@ -153,13 +153,13 @@ export default {
time: 'زمان',
title: 'عنوان',
userActions_title: 'اقدامات کاربر',
userAddedThisCardToList: '<0>{{user}}</0><1> این کارت را به {{list}} اضافه کرد</1>',
userAddedThisCardToList: '<0>{{user}}</0> این کارت را به {{list}} اضافه کرد',
userLeftNewCommentToCard:
'<0>{{user}}</0> نظر جدید «{{comment}}» را به <2>{{card}}</2> اضافه کرد',
userMovedCardFromListToList:
'<0>{{user}}</0> <2>{{card}}</2> را از {{fromList}} به {{toList}} منتقل کرد',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> این کارت را از {{fromList}} به {{toList}} منتقل کرد</1>',
'<0>{{user}}</0> این کارت را از {{fromList}} به {{toList}} منتقل کرد',
username: 'نام کاربری',
users: 'کاربران',
viewer: 'بیننده',

View file

@ -153,13 +153,13 @@ export default {
time: 'Temps',
title: 'Titre',
userActions_title: "Actions de l'utilisateur",
userAddedThisCardToList: '<0>{{user}}</0><1> a ajouté cette carte à {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> a ajouté cette carte à {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> a laissé un nouveau commentaire {{comment}} à <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> a déplacé <2>{{card}}</2> de {{fromList}} vers {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> a déplacé cette carte de {{fromList}} vers {{toList}}</1>',
'<0>{{user}}</0> a déplacé cette carte de {{fromList}} vers {{toList}}',
username: "Nom d'utilisateur",
users: 'Utilisateurs',
viewer: 'Spectateur',

View file

@ -151,14 +151,13 @@ export default {
time: 'Idő',
title: 'Cím',
userActions_title: 'Felhasználói műveletek',
userAddedThisCardToList:
'<0>{{user}}</0><1> hozzáadta ezt a kártyát a következőhöz: {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> hozzáadta ezt a kártyát a következőhöz: {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> új kommentet hagyott itt: «{{comment}}» a következő kártyán: <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> áthelyezte ezt a kártyát innen: {{fromList}} ide: {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> áthelyezte ezt a kártyát innen: {{fromList}} ide: {{toList}}</1>',
'<0>{{user}}</0> áthelyezte ezt a kártyát innen: {{fromList}} ide: {{toList}}',
username: 'Felhasználónév',
users: 'Felhasználók',
viewer: 'Néző',

View file

@ -147,12 +147,12 @@ export default {
time: 'Waktu',
title: 'Judul',
userActions_title: 'Aksi Pengguna',
userAddedThisCardToList: '<0>{{user}}</0><1> menambahkan kartu ini ke {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> menambahkan kartu ini ke {{list}}',
userLeftNewCommentToCard: '<0>{{user}}</0> mengomentari «{{comment}}» di <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> memindahkan <2>{{card}}</2> dari {{fromList}} ke {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> memindahkan kartu ini dari {{fromList}} ke {{toList}}</1>',
'<0>{{user}}</0> memindahkan kartu ini dari {{fromList}} ke {{toList}}',
username: 'Username',
users: 'Pengguna',
viewer: 'Penglihat',

View file

@ -284,13 +284,13 @@ export default {
unsavedChanges: 'Modifiche non salvate',
uploadedImages: 'Immagini caricate',
userActions_title: 'Azioni utente',
userAddedThisCardToList: '<0>{{user}}</0><1> ha aggiunto questa scheda a {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> ha aggiunto questa scheda a {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> ha lasciato un commento «{{comment}}» a <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> ha spostato <2>{{card}}</2> da {{fromList}} a {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> ha spostato questa scheda da {{fromList}} a {{toList}}</1>',
'<0>{{user}}</0> ha spostato questa scheda da {{fromList}} a {{toList}}',
username: 'Username',
users: 'Utenti',
viewer: 'Visualizzatore',

View file

@ -146,13 +146,13 @@ export default {
time: '時間',
title: 'タイトル',
userActions_title: 'ユーザーのアクション',
userAddedThisCardToList: '<0>{{user}}</0> 様が <1>{{list}} をこのカードに追加しました</1>',
userAddedThisCardToList: '<0>{{user}}</0> 様が {{list}} をこのカードに追加しました',
userLeftNewCommentToCard:
'<0>{{user}}</0> 様が <2>{{card}}</2> に新しいコメント «{{comment}}» を残しました',
userMovedCardFromListToList:
'<0>{{user}}</0> 様が <2>{{card}}</2> を {{fromList}} から {{toList}} に移動しました',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> 様がこのカードを {{fromList}} から {{toList}} に移動しました</1>',
'<0>{{user}}</0> 様がこのカードを {{fromList}} から {{toList}} に移動しました',
username: 'ユーザー名',
users: 'ユーザー',
viewer: 'ビューア',

View file

@ -151,13 +151,13 @@ export default {
time: '시간',
title: '제목',
userActions_title: '사용자 작업',
userAddedThisCardToList: '<0>{{user}}</0><1>님이 이 카드를 {{list}}에 추가했습니다</1>',
userAddedThisCardToList: '<0>{{user}}</0>님이 이 카드를 {{list}}에 추가했습니다',
userLeftNewCommentToCard:
'<0>{{user}}</0>님이 <2>{{card}}</2>에 새 댓글 «{{comment}}»을 남겼습니다',
userMovedCardFromListToList:
'<0>{{user}}</0>님이 <2>{{card}}</2>를 {{fromList}}에서 {{toList}}로 이동했습니다',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> 님이 {{fromList}}에서 {{toList}}로 이 카드를 옮겼습니다</1>',
'<0>{{user}}</0> 님이 {{fromList}}에서 {{toList}}로 이 카드를 옮겼습니다',
username: '사용자 이름',
users: '사용자들',
viewer: '뷰어',

View file

@ -147,13 +147,13 @@ export default {
time: 'Tijd',
title: 'Titel',
userActions_title: 'Gebruikersacties',
userAddedThisCardToList: '<0>{{user}}</0><1> heeft deze kaart toegevoegd aan {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> heeft deze kaart toegevoegd aan {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> heeft een nieuwe opmerking achtergelaten «{{comment}}» bij <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> heeft <2>{{card}}</2> verplaatst van {{fromList}} naar {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> heeft deze kaart verplaatst van {{fromList}} naar {{toList}}</1>',
'<0>{{user}}</0> heeft deze kaart verplaatst van {{fromList}} naar {{toList}}',
username: 'Gebruikersnaam',
users: 'Gebruikers',
viewer: 'Kijker',

View file

@ -148,13 +148,13 @@ export default {
time: 'Czas',
title: 'Tytuł',
userActions_title: 'Akcje użytkownika',
userAddedThisCardToList: '<0>{{user}}</0><1> dodał tę kartę w {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> dodał tę kartę w {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> zamieścił nowy komentarz «{{comment}}» w <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> przeniósł <2>{{card}}</2> z {{fromList}} do {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> przeniósł tę kartę z {{fromList}} do {{toList}}</1>',
'<0>{{user}}</0> przeniósł tę kartę z {{fromList}} do {{toList}}',
username: 'Nazwa Użytkownika',
users: 'Użytkownicy',
viewer: 'Odwiedzający',

View file

@ -147,13 +147,13 @@ export default {
time: 'Tempo',
title: 'Título',
userActions_title: 'Ações do Usuário',
userAddedThisCardToList: '<0>{{user}}</0><1> adicionou este cartão a {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> adicionou este cartão a {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> deixou um novo comentário «{{comment}}» em <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> moveu <2>{{card}}</2> de {{fromList}} para {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> moveu este cartão de {{fromList}} para {{toList}}</1>',
'<0>{{user}}</0> moveu este cartão de {{fromList}} para {{toList}}',
username: 'Nome de usuário',
users: 'Usuários',
viewer: 'Visualizador',

View file

@ -147,13 +147,13 @@ export default {
time: 'Timp',
title: 'Titlu',
userActions_title: 'Acțiunile utilizatorului',
userAddedThisCardToList: '<0>{{user}}</0><1> a adăugat acest card în {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> a adăugat acest card în {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> a lăsat un nou comentariu «{{comment}}» în <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> a mutat <2>{{card}}</2> din {{fromList}} în {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> a mutat aceast card din {{fromList}} în {{toList}}</1>',
'<0>{{user}}</0> a mutat aceast card din {{fromList}} în {{toList}}',
username: 'Nume utilizator',
users: 'Utilizatori',
viewer: 'Vizualizator',

View file

@ -284,13 +284,13 @@ export default {
unsavedChanges: 'Несохранённые изменения',
uploadedImages: 'Загруженные изображения',
userActions_title: 'Действия с пользователем',
userAddedThisCardToList: '<0>{{user}}</0><1> добавил(а) эту карточку в {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> добавил(а) эту карточку в {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> оставил(а) комментарий «{{comment}}» к <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> переместил(а) <2>{{card}}</2> из {{fromList}} в {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> переместил(а) эту карточку из {{fromList}} в {{toList}}</1>',
'<0>{{user}}</0> переместил(а) эту карточку из {{fromList}} в {{toList}}',
username: 'Имя пользователя',
users: 'Пользователи',
viewer: 'Читатель',

View file

@ -132,13 +132,13 @@ export default {
time: 'Čas',
title: 'Názov',
userActions_title: 'Akcie na používateľovi',
userAddedThisCardToList: '<0>{{user}}</0><1> pridal kartu do {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> pridal kartu do {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> zanechal nový komentár «{{comment}}» k <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> presunul <2>{{card}}</2> z {{fromList}} do {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> presunul túto kartu z {{fromList}} do {{toList}}</1>',
'<0>{{user}}</0> presunul túto kartu z {{fromList}} do {{toList}}',
username: 'Používateľské meno',
users: 'Používatelia',
writeComment: 'Napísať komentár...',

View file

@ -152,13 +152,13 @@ export default {
time: 'Време',
title: 'Наслов',
userActions_title: 'Корисничке радње',
userAddedThisCardToList: '<0>{{user}}</0><1> је додао ову картицу на {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> је додао ову картицу на {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> је оставио нови коментар «{{comment}}» у <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> је преместио <2>{{card}}</2> са {{fromList}} у {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> је преместио ову картицу са {{fromList}} на {{toList}}</1>',
'<0>{{user}}</0> је преместио ову картицу са {{fromList}} на {{toList}}',
username: 'Корисничко име',
users: 'Корисници',
viewer: 'Прегледач',

View file

@ -152,13 +152,13 @@ export default {
time: 'Vreme',
title: 'Naslov',
userActions_title: 'Korisničke radnje',
userAddedThisCardToList: '<0>{{user}}</0><1> je dodao ovu karticu na {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> je dodao ovu karticu na {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> je ostavio novi komentar «{{comment}}» u <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> je premestio <2>{{card}}</2> sa {{fromList}} u {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> je premestio ovu karticu sa {{fromList}} na {{toList}}</1>',
'<0>{{user}}</0> je premestio ovu karticu sa {{fromList}} na {{toList}}',
username: 'Korisničko ime',
users: 'Korisnici',
viewer: 'Pregledač',

View file

@ -133,13 +133,13 @@ export default {
time: 'Tid',
title: 'Titel',
userActions_title: 'Användaråtgärder',
userAddedThisCardToList: '<0>{{user}}</0><1> lade till detta kort i {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> lade till detta kort i {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> lämnade en ny kommentar «{{comment}}» på <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> flyttade <2>{{card}}</2> från {{fromList}} till {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> flyttade detta kort från {{fromList}} till {{toList}}</1>',
'<0>{{user}}</0> flyttade detta kort från {{fromList}} till {{toList}}',
username: 'Användarnamn',
users: 'Användare',
writeComment: 'Skriv en kommentar...',

View file

@ -134,13 +134,13 @@ export default {
time: 'zaman',
title: 'başlık',
userActions_title: 'Kullanıcı İşlemleri',
userAddedThisCardToList: '<0>{{user}}</0><1> bu kartı {{list}</1> listesine ekledi',
userAddedThisCardToList: '<0>{{user}}</0> bu kartı {{list}} listesine ekledi',
userLeftNewCommentToCard:
'<0>{{user}}</0> yeni bir yorum yazdı: <2>{{card}</2> kartına «{{comment}}»',
userMovedCardFromListToList:
'<0>{{user}}</0>, <2>{{card}></2> kartını {{fromList}} listesinden {{toList}} listesine taşıdı',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> bu kartı {{fromList}} konumundan {{toList}}</1> konumuna taşıdı',
'<0>{{user}}</0> bu kartı {{fromList}} konumundan {{toList}} konumuna taşıdı',
username: 'kullanıcı adı',
users: 'kullanıcı',
writeComment: 'Yorum yazın...',

View file

@ -281,13 +281,13 @@ export default {
unsavedChanges: 'Незбережені зміни',
uploadedImages: 'Завантажені зображення',
userActions_title: 'Дії користувача',
userAddedThisCardToList: '<0>{{user}}</0><1> додав(ла) цю картку до {{list}}</1>',
userAddedThisCardToList: '<0>{{user}}</0> додав(ла) цю картку до {{list}}',
userLeftNewCommentToCard:
'<0>{{user}}</0> залишив(ла) новий коментар «{{comment}}» до <2>{{card}}</2>',
userMovedCardFromListToList:
'<0>{{user}}</0> перемістив(ла) <2>{{card}}</2> з {{fromList}} в {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> перемістив(ла) цю картку з {{fromList}} в {{toList}}</1>',
'<0>{{user}}</0> перемістив(ла) цю картку з {{fromList}} в {{toList}}',
username: "Ім'я користувача",
users: 'Користувачі',
viewer: 'Переглядач',

View file

@ -130,13 +130,13 @@ export default {
time: 'Vaqt',
title: 'Sarlavha',
userActions_title: 'Foydalanuvchi Amallari',
userAddedThisCardToList: "<1>Ushbu kartani {{list}} ga</1><0>{{user}}</0> qo'shdi",
userAddedThisCardToList: "Ushbu kartani {{list}} ga<0>{{user}}</0> qo'shdi",
userLeftNewCommentToCard:
'<0>{{user}}</0> <2>{{card}}</2> ga yangi izoh qoldirdi «{{comment}}»',
userMovedCardFromListToList:
"<0>{{user}}</0> <2>{{card}}</2> ni {{fromList}} dan {{toList}} ga ko'chirdi",
userMovedThisCardFromListToList:
"<0>{{user}}</0><1> ushbu kartani {{fromList}} dan {{toList}}</1> ga ko'chirdi",
"<0>{{user}}</0> ushbu kartani {{fromList}} dan {{toList}} ga ko'chirdi",
username: 'Foydalanuvchi nomi',
users: 'Foydalanuvchilar',
writeComment: 'Izoh yozish...',

View file

@ -143,12 +143,11 @@ export default {
time: '时间',
title: '标题',
userActions_title: '用户操作',
userAddedThisCardToList: '<0>{{user}}</0><1> 向列表 {{list}} 添加了该卡片</1>',
userLeftNewCommentToCard: '<0>{{user}}</0> 给 {{card}} 添加了一个新评论 «{{comment}}»',
userAddedThisCardToList: '<0>{{user}}</0> 向列表 {{list}} 添加了该卡片',
userLeftNewCommentToCard: '<0>{{user}}</0> 给 <2>{{card}}</2> 添加了一个新评论 «{{comment}}»',
userMovedCardFromListToList:
'<0>{{user}}</0> 将卡片 <2>{{card}}</2> 从 {{fromList}} 移动到 {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> 将该卡片从 {{fromList}} 移动到 {{toList}}</1>',
userMovedThisCardFromListToList: '<0>{{user}}</0> 将该卡片从 {{fromList}} 移动到 {{toList}}',
username: '用户名',
users: '用户',
viewer: '视图',

View file

@ -143,12 +143,12 @@ export default {
time: '時間',
title: '標題',
userActions_title: '使用者操作',
userAddedThisCardToList: '<0>{{user}}</0><1> 向列表 {{list}} 添加了該卡片</1>',
userLeftNewCommentToCard: '<0>{{user}}</0> 給 {{card}} 添加了一條新評論 「{{comment}}」',
userAddedThisCardToList: '<0>{{user}}</0> 向列表 {{list}} 添加了該卡片',
userLeftNewCommentToCard:
'<0>{{user}}</0> 給 <2>{{card}}</2> 添加了一條新評論 「{{comment}}」',
userMovedCardFromListToList:
'<0>{{user}}</0> 將卡片 <2>{{card}}</2> 從 {{fromList}} 移動到 {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> 將該卡片從 {{fromList}} 移動到 {{toList}}</1>',
userMovedThisCardFromListToList: '<0>{{user}}</0> 將該卡片從 {{fromList}} 移動到 {{toList}}',
username: '使用者名稱',
users: '使用者',
viewer: '檢視',

View file

@ -18,6 +18,11 @@ export default class extends BaseModel {
createdAt: attr({
getDefault: () => new Date(),
}),
boardId: fk({
to: 'Board',
as: 'board',
relatedName: 'activities',
}),
cardId: fk({
to: 'Card',
as: 'card',
@ -37,7 +42,8 @@ export default class extends BaseModel {
break;
case ActionTypes.LIST_CARDS_MOVE__SUCCESS:
case ActionTypes.ACTIVITIES_FETCH__SUCCESS:
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS:
case ActionTypes.ACTIVITIES_IN_CARD_FETCH__SUCCESS:
payload.activities.forEach((activity) => {
Activity.upsert(activity);
});

View file

@ -9,6 +9,7 @@ import BaseModel from './BaseModel';
import buildSearchParts from '../utils/build-search-parts';
import { isListFinite } from '../utils/record-helpers';
import ActionTypes from '../constants/ActionTypes';
import Config from '../constants/Config';
import { BoardContexts, BoardViews } from '../constants/Enums';
const prepareFetchedBoard = (board) => ({
@ -39,6 +40,15 @@ export default class extends BaseModel {
isFetching: attr({
getDefault: () => null,
}),
lastActivityId: attr({
getDefault: () => null,
}),
isActivitiesFetching: attr({
getDefault: () => false,
}),
isAllActivitiesFetched: attr({
getDefault: () => null,
}),
projectId: fk({
to: 'Project',
as: 'project',
@ -241,6 +251,22 @@ export default class extends BaseModel {
case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE:
Board.withId(payload.boardId).filterLabels.remove(payload.id);
break;
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH:
Board.withId(payload.boardId).update({
isActivitiesFetching: true,
});
break;
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS:
Board.withId(payload.boardId).update({
isActivitiesFetching: false,
isAllActivitiesFetched: payload.activities.length < Config.ACTIVITIES_LIMIT,
...(payload.activities.length > 0 && {
lastActivityId: payload.activities[payload.activities.length - 1].id,
}),
});
break;
default:
}
@ -266,6 +292,10 @@ export default class extends BaseModel {
return this.customFieldGroups.orderBy(['position', 'id.length', 'id']);
}
getActivitiesQuerySet() {
return this.activities.orderBy(['id.length', 'id'], ['desc', 'desc']);
}
getUnreadNotificationsQuerySet() {
return this.notifications.filter({
isRead: false,
@ -347,6 +377,30 @@ export default class extends BaseModel {
return cardModels;
}
getActivitiesModelArray() {
if (this.isAllActivitiesFetched === null) {
return [];
}
const activityModels = this.getActivitiesQuerySet().toModelArray();
if (this.lastActivityId && this.isAllActivitiesFetched === false) {
return activityModels.filter((activityModel) => {
if (activityModel.id.length > this.lastActivityId.length) {
return true;
}
if (activityModel.id.length < this.lastActivityId.length) {
return false;
}
return activityModel.id >= this.lastActivityId;
});
}
return activityModels;
}
hasMembershipWithUserId(userId) {
return this.memberships
.filter({

View file

@ -402,13 +402,13 @@ export default class extends BaseModel {
});
break;
case ActionTypes.ACTIVITIES_FETCH:
case ActionTypes.ACTIVITIES_IN_CARD_FETCH:
Card.withId(payload.cardId).update({
isActivitiesFetching: true,
});
break;
case ActionTypes.ACTIVITIES_FETCH__SUCCESS:
case ActionTypes.ACTIVITIES_IN_CARD_FETCH__SUCCESS:
Card.withId(payload.cardId).update({
isActivitiesFetching: false,
isAllActivitiesFetched: payload.activities.length < Config.ACTIVITIES_LIMIT,
@ -607,7 +607,6 @@ export default class extends BaseModel {
this.customFieldValues.delete();
this.comments.delete();
this.activities.delete();
}
deleteWithClearable() {

View file

@ -295,7 +295,8 @@ export default class extends BaseModel {
case ActionTypes.CARD_CREATE_HANDLE:
case ActionTypes.COMMENTS_FETCH__SUCCESS:
case ActionTypes.COMMENT_CREATE_HANDLE:
case ActionTypes.ACTIVITIES_FETCH__SUCCESS:
case ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS:
case ActionTypes.ACTIVITIES_IN_CARD_FETCH__SUCCESS:
case ActionTypes.NOTIFICATION_CREATE_HANDLE:
payload.users.forEach((user) => {
User.upsert(user);

View file

@ -10,10 +10,10 @@ import selectors from '../../../selectors';
import actions from '../../../actions';
import api from '../../../api';
export function* fetchActivities(cardId) {
const { lastActivityId } = yield select(selectors.selectCardById, cardId);
export function* fetchActivitiesInBoard(boardId) {
const { lastActivityId } = yield select(selectors.selectBoardById, boardId);
yield put(actions.fetchActivities(cardId));
yield put(actions.fetchActivitiesInBoard(boardId));
let activities;
let users;
@ -22,21 +22,50 @@ export function* fetchActivities(cardId) {
({
items: activities,
included: { users },
} = yield call(request, api.getActivities, cardId, {
} = yield call(request, api.getActivitiesInBoard, boardId, {
beforeId: lastActivityId || undefined,
}));
} catch (error) {
yield put(actions.fetchActivities.failure(cardId, error));
yield put(actions.fetchActivitiesInBoard.failure(boardId, error));
return;
}
yield put(actions.fetchActivities.success(cardId, activities, users));
yield put(actions.fetchActivitiesInBoard.success(boardId, activities, users));
}
export function* fetchActivitiesInCurrentBoard() {
const { boardId } = yield select(selectors.selectPath);
yield call(fetchActivitiesInBoard, boardId);
}
export function* fetchActivitiesInCard(cardId) {
const { lastActivityId } = yield select(selectors.selectCardById, cardId);
yield put(actions.fetchActivitiesInCard(cardId));
let activities;
let users;
try {
({
items: activities,
included: { users },
} = yield call(request, api.getActivitiesInCard, cardId, {
beforeId: lastActivityId || undefined,
}));
} catch (error) {
yield put(actions.fetchActivitiesInCard.failure(cardId, error));
return;
}
yield put(actions.fetchActivitiesInCard.success(cardId, activities, users));
}
export function* fetchActivitiesInCurrentCard() {
const { cardId } = yield select(selectors.selectPath);
yield call(fetchActivities, cardId);
yield call(fetchActivitiesInCard, cardId);
}
export function* handleActivityCreate(activity) {
@ -44,7 +73,9 @@ export function* handleActivityCreate(activity) {
}
export default {
fetchActivities,
fetchActivitiesInBoard,
fetchActivitiesInCurrentBoard,
fetchActivitiesInCard,
fetchActivitiesInCurrentCard,
handleActivityCreate,
};

View file

@ -10,6 +10,9 @@ import EntryActionTypes from '../../../constants/EntryActionTypes';
export default function* activitiesWatchers() {
yield all([
takeEvery(EntryActionTypes.ACTIVITIES_IN_CURRENT_BOARD_FETCH, () =>
services.fetchActivitiesInCurrentBoard(),
),
takeEvery(EntryActionTypes.ACTIVITIES_IN_CURRENT_CARD_FETCH, () =>
services.fetchActivitiesInCurrentCard(),
),

View file

@ -383,6 +383,24 @@ export const selectCustomFieldGroupsForCurrentBoard = createSelector(
},
);
export const selectActivityIdsForCurrentBoard = createSelector(
orm,
(state) => selectPath(state).boardId,
({ Board }, id) => {
if (!id) {
return id;
}
const boardModel = Board.withId(id);
if (!boardModel) {
return boardModel;
}
return boardModel.getActivitiesModelArray().map((activity) => activity.id);
},
);
export const selectFilterUserIdsForCurrentBoard = createSelector(
orm,
(state) => selectPath(state).boardId,
@ -447,6 +465,7 @@ export default {
selectFilteredCardIdsForCurrentBoard,
selectCustomFieldGroupIdsForCurrentBoard,
selectCustomFieldGroupsForCurrentBoard,
selectActivityIdsForCurrentBoard,
selectFilterUserIdsForCurrentBoard,
selectFilterLabelIdsForCurrentBoard,
selectIsBoardWithIdExists,

View file

@ -0,0 +1,68 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const { idInput } = require('../../../utils/inputs');
const Errors = {
BOARD_NOT_FOUND: {
boardNotFound: 'Board not found',
},
};
module.exports = {
inputs: {
boardId: {
...idInput,
required: true,
},
beforeId: idInput,
},
exits: {
boardNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentUser } = this.req;
const { board, project } = await sails.helpers.boards
.getPathToProjectById(inputs.boardId)
.intercept('pathNotFound', () => Errors.BOARD_NOT_FOUND);
const boardMembership = await BoardMembership.qm.getOneByBoardIdAndUserId(
board.id,
currentUser.id,
);
if (!boardMembership) {
if (currentUser.role !== User.Roles.ADMIN || project.ownerProjectManagerId) {
const isProjectManager = await sails.helpers.users.isProjectManager(
currentUser.id,
project.id,
);
if (!isProjectManager) {
throw Errors.BOARD_NOT_FOUND; // Forbidden
}
}
}
const actions = await Action.qm.getByBoardId(board.id, {
beforeId: inputs.beforeId,
});
const userIds = sails.helpers.utils.mapRecords(actions, 'userId', true, true);
const users = await User.qm.getByIds(userIds);
return {
items: actions,
included: {
users: sails.helpers.users.presentMany(users, currentUser),
},
};
},
};

View file

@ -115,6 +115,7 @@ module.exports = {
const action = await Action.qm.createOne({
...values,
boardId: values.card.boardId,
cardId: values.card.id,
userId: values.user.id,
});
@ -149,10 +150,7 @@ module.exports = {
values: {
action,
type: action.type,
data: {
...action.data,
card: _.pick(values.card, ['name']),
},
data: action.data,
userId: action.data.user.id,
creatorUser: values.user,
card: values.card,
@ -182,10 +180,7 @@ module.exports = {
userId,
action,
type: action.type,
data: {
...action.data,
card: _.pick(values.card, ['name']),
},
data: action.data,
creatorUser: values.user,
card: values.card,
},

View file

@ -35,6 +35,15 @@ module.exports = {
await sails.helpers.lists.deleteRelated(lists);
await Action.qm.update(
{
boardId: boardIdOrIds,
},
{
boardId: null,
},
);
await NotificationService.qm.delete({
boardId: boardIdOrIds,
});

View file

@ -110,6 +110,7 @@ module.exports = {
type: Action.Types.ADD_MEMBER_TO_CARD,
data: {
user: _.pick(values.user, ['id', 'name']),
card: _.pick(values.card, ['name']),
},
user: inputs.actorUser,
card: values.card,

View file

@ -86,6 +86,7 @@ module.exports = {
type: Action.Types.REMOVE_MEMBER_FROM_CARD,
data: {
user: _.pick(inputs.user, ['id', 'name']),
card: _.pick(inputs.card, ['name']),
},
user: inputs.actorUser,
card: inputs.card,

View file

@ -124,6 +124,7 @@ module.exports = {
card,
type: Action.Types.CREATE_CARD,
data: {
card: _.pick(card, ['name']),
list: _.pick(values.list, ['id', 'type', 'name']),
},
user: values.creatorUser,

View file

@ -276,6 +276,7 @@ module.exports = {
card,
type: Action.Types.CREATE_CARD, // TODO: introduce separate type?
data: {
card: _.pick(card, ['name']),
list: _.pick(inputs.list, ['id', 'type', 'name']),
},
user: values.creatorUser,

View file

@ -463,6 +463,7 @@ module.exports = {
card,
type: Action.Types.MOVE_CARD,
data: {
card: _.pick(card, ['name']),
fromList: _.pick(inputs.list, ['id', 'type', 'name']),
toList: _.pick(values.list, ['id', 'type', 'name']),
},

View file

@ -138,6 +138,7 @@ module.exports = {
values: {
type: task.isCompleted ? Action.Types.COMPLETE_TASK : Action.Types.UNCOMPLETE_TASK,
data: {
card: _.pick(inputs.card, ['name']),
task: _.pick(task, ['id', 'name']),
},
user: inputs.actorUser,

View file

@ -11,6 +11,20 @@ const create = (arrayOfValues) => Action.createEach(arrayOfValues).fetch();
const createOne = (values) => Action.create({ ...values }).fetch();
const getByBoardId = (boardId, { beforeId } = {}) => {
const criteria = {
boardId,
};
if (beforeId) {
criteria.id = {
'<': beforeId,
};
}
return Action.find(criteria).sort('id DESC').limit(LIMIT);
};
const getByCardId = (cardId, { beforeId } = {}) => {
const criteria = {
cardId,
@ -33,6 +47,7 @@ const delete_ = (criteria) => Action.destroy(criteria).fetch();
module.exports = {
create,
createOne,
getByBoardId,
getByCardId,
update,
delete: delete_,

View file

@ -52,6 +52,10 @@ module.exports = {
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
boardId: {
model: 'Board',
columnName: 'board_id',
},
cardId: {
model: 'Card',
required: true,

View file

@ -163,7 +163,8 @@ module.exports.routes = {
'PATCH /api/comments/:id': 'comments/update',
'DELETE /api/comments/:id': 'comments/delete',
'GET /api/cards/:cardId/actions': 'actions/index',
'GET /api/boards/:boardId/actions': 'actions/index-in-board',
'GET /api/cards/:cardId/actions': 'actions/index-in-card',
'GET /api/notifications': 'notifications/index',
'GET /api/notifications/:id': 'notifications/show',

View file

@ -0,0 +1,30 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
exports.up = async (knex) => {
await knex.schema.alterTable('action', (table) => {
/* Columns */
table.bigInteger('board_id');
/* Indexes */
table.index('board_id');
});
return knex.raw(`
UPDATE action
SET
board_id = card.board_id,
data = data || jsonb_build_object('card', jsonb_build_object('name', card.name))
FROM card
WHERE action.card_id = card.id;
`);
};
exports.down = (knex) =>
knex.schema.table('action', (table) => {
table.dropColumn('board_id');
});