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

feat: add page to view all cards assigned to current user #264

This commit is contained in:
joakimbaynaud 2025-05-30 12:29:40 +02:00
parent e6ab4e2370
commit 8c2de439d4
15 changed files with 329 additions and 3 deletions

View file

@ -77,6 +77,13 @@ const ProjectContent = React.memo(({ cardId }) => {
const { listName, withCreator } = useSelector((state) => {
const board = selectors.selectCurrentBoard(state);
if (!board) {
return {
listName: null,
withCreator: false,
};
}
return {
listName: list.name && (board.view === BoardViews.KANBAN ? null : list.name),

View file

@ -45,7 +45,7 @@ const CardModal = React.memo(() => {
const dispatch = useDispatch();
const handleClose = useCallback(() => {
dispatch(push(Paths.BOARDS.replace(':id', card.boardId)));
window.history.back();
}, [card.boardId, dispatch]);
const [ClosableModal, isClosableActiveRef] = useClosableModal();

View file

@ -16,6 +16,7 @@ import Paths from '../../constants/Paths';
import Login from './Login';
import Core from './Core';
import NotFound from './NotFound';
import UserCardsPage from "../users/UserCardsPage";
import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css';
@ -37,6 +38,7 @@ function Root({ store, history }) {
<Route path={Paths.PROJECTS} element={<Core />} />
<Route path={Paths.BOARDS} element={<Core />} />
<Route path={Paths.CARDS} element={<Core />} />
<Route path={Paths.USERCARDS} element={<UserCardsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ToasterProvider>

View file

@ -0,0 +1,136 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Loader } from 'semantic-ui-react';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import DroppableTypes from '../../../constants/DroppableTypes';
import DraggableCard from '../../cards/DraggableCard';
import Header from '../../common/Header';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import styles from './UserCardsPage.module.scss';
function UserCardsPage() {
const [t] = useTranslation();
const dispatch = useDispatch();
const loading = useRef(false);
const currentUserId = useSelector(selectors.selectCurrentUserId);
const user = useSelector(selectors.selectCurrentUser);
const projectsToCard = useSelector(selectors.selectProjectsToCardsWithEditorRightsForCurrentUser);
console.log('Projects to Cards Data:', projectsToCard);
const isDataReady = currentUserId && user && projectsToCard !== null;
const handleDragEnd = (result) => {
if (!result.destination) {
return;
}
const { source, destination, draggableId } = result;
};
useEffect(() => {
if (!currentUserId) {
return;
}
if (loading.current || !isDataReady) return;
if (projectsToCard && projectsToCard.length > 0) {
projectsToCard.forEach((project) => {
project.boards?.forEach((board) => {
dispatch(entryActions.fetchBoard(board.id));
});
});
}
loading.current = true;
}, [currentUserId, projectsToCard, dispatch, isDataReady]);
if (!isDataReady) {
return (
<>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Loader active size="massive" />
</div>
</>
);
}
return (
<>
<Header />
<div className={styles.container}>
<h1 className={styles.title}>{t('common.myCards')}</h1>
{projectsToCard && projectsToCard.length > 0 ? (
<DragDropContext onDragEnd={handleDragEnd}>
<div className={styles.projectsContainer}>
{projectsToCard.map((project) => (
<div key={project.id} className={styles.projectSection}>
<h2 className={styles.projectTitle}>{project.name}</h2>
{project.boards && project.boards.length > 0 ? (
project.boards.map((board) => (
<div key={board.id} className={styles.boardSection}>
<h3 className={styles.boardTitle}>{board.name}</h3>
{board.cards && board.cards.length > 0 ? (
<Droppable droppableId={`board:${board.id}`} type={DroppableTypes.CARD}>
{(provided) => (
<div
ref={provided.innerRef}
data-rbd-droppable-id={`board:${board.id}`}
data-rbd-droppable-context-id={
provided.droppableProps['data-rbd-droppable-context-id']
}
className={styles.cardsGrid}
>
{board.cards.map((card, index) => {
const cardId = card.id || card._fields?.id;
if (!cardId) {
console.warn('Card missing ID:', card);
return null;
}
return (
<div key={cardId} className={styles.card}>
<DraggableCard id={cardId} index={index} />
</div>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
) : (
<p className={styles.emptyMessage}>{t('common.noCards')}</p>
)}
</div>
))
) : (
<p className={styles.emptyMessage}>{t('common.noBoards')}</p>
)}
</div>
))}
</div>
</DragDropContext>
) : (
<div className={styles.emptyState}>
<p>{t('common.noProjects')}</p>
</div>
)}
</div>
</>
);
}
export default UserCardsPage;

View file

@ -0,0 +1,96 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
.container {
padding: 2rem;
}
.title {
color: #fff;
margin-left: 1rem;
}
.projectsContainer {
display: flex;
flex-wrap: wrap;
}
.projectSection {
background-color: #333333;
border-radius: 4px;
padding: 2rem;
margin: 1rem;
font-family: "Nunitoga", "Helvetica Neue", Arial, Helvetica, sans-serif;
}
.projectTitle {
color: #fff;
}
.boardSection {
width: 300px;
color: #000;
background-color: #dfe3e6;
border-radius: 4px;
padding: 1rem;
margin-top: 1rem;
}
.boardTitle {
color: #000;
}
.cardsGrid {
display: grid;
}
.card {
margin-top: 1rem;
align-items: center;
border-radius: 4px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-8px);
box-shadow: 0 8px 16px rgba(255, 255, 255, 0.562);
}
.cardName {
margin-right: 10px;
}
.emptyMessage {
width: 300px;
padding: 1rem;
margin-top: 1rem;
color: #9e0000;
font-weight:bold;
font-size:large;
}
.emptyState {
width: 300px;
padding: 1rem;
margin-top: 1rem;
color: #9e0000;
font-weight:bold;
font-size: xx-large;
}
/* Media Queries for Responsiveness */
/* tablet */
@media (max-width: 768px) {
.projectsContainer{
justify-content: center;
}
}
/* phone */
@media (max-width: 480px) {
}

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 UserCards from './UserCardsPage';
export default UserCards;

View file

@ -7,6 +7,7 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Button, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
@ -40,6 +41,10 @@ const UserStep = React.memo(({ onClose }) => {
onClose();
}, [onClose, dispatch]);
const handleCardsClick = useCallback(() => {
onClose();
}, [onClose]);
let logoutMenuItemProps;
if (isLogouting) {
logoutMenuItemProps = {
@ -60,6 +65,16 @@ const UserStep = React.memo(({ onClose }) => {
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item
as={Link}
to="/user-cards"
className={styles.menuItem}
onClick={handleCardsClick}
>
{t('common.myCards', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleSettingsClick}>
{t('common.settings', {
context: 'title',

View file

@ -9,6 +9,7 @@ const OIDC_CALLBACK = '/oidc-callback';
const PROJECTS = '/projects/:id';
const BOARDS = '/boards/:id';
const CARDS = '/cards/:id';
const USERCARDS = '/user-cards';
export default {
ROOT,
@ -17,4 +18,5 @@ export default {
PROJECTS,
BOARDS,
CARDS,
USERCARDS,
};

View file

@ -209,6 +209,7 @@ export default {
memberActions_title: 'Member Actions',
minutes: 'Minutes',
moveCard_title: 'Move Card',
myCards: 'My cards',
myOwn_title: 'My Own',
name: 'Name',
newestFirst: 'Newest first',
@ -218,6 +219,7 @@ export default {
newVersionAvailable: 'New version available',
noConnectionToServer: 'No connection to server',
noBoards: 'No boards',
noCards: 'No cards',
noLists: 'No lists',
noProjects: 'No projects',
notifications: 'Notifications',

View file

@ -204,6 +204,7 @@ export default {
memberActions_title: 'Member Actions',
minutes: 'Minutes',
moveCard_title: 'Move Card',
myCards: 'My cards',
myOwn_title: 'My Own',
name: 'Name',
newestFirst: 'Newest first',
@ -213,6 +214,7 @@ export default {
newVersionAvailable: 'New version available',
noConnectionToServer: 'No connection to server',
noBoards: 'No boards',
noCards: 'No cards',
noLists: 'No lists',
noProjects: 'No projects',
notifications: 'Notifications',

View file

@ -111,6 +111,7 @@ export default {
memberActions_title: 'Actions des membres',
minutes: 'Minutes',
moveCard_title: 'Déplacer la carte',
myCards: 'Mes cartes',
name: 'Nom',
newestFirst: 'Le plus récent en premier',
newEmail: 'Nouvel e-mail',
@ -118,6 +119,7 @@ export default {
newUsername: "Nouveau nom d'utilisateur",
noConnectionToServer: 'Pas de connexion au serveur',
noBoards: 'Pas de tableau',
noCards: 'Pas de cartes',
noLists: 'Pas de liste',
noProjects: 'Pas de projet',
notifications: 'Notifications',

View file

@ -296,6 +296,10 @@ export default class extends BaseModel {
return this.activities.orderBy(['id.length', 'id'], ['desc', 'desc']);
}
getOrderedCardsQuerySet() {
return this.cards.orderBy(['position', 'id.length', 'id']);
}
getUnreadNotificationsQuerySet() {
return this.notifications.filter({
isRead: false,
@ -313,6 +317,11 @@ export default class extends BaseModel {
})
.first();
}
getOrderedCardsModelArrayForUser(userId) {
return this.getOrderedCardsQuerySet()
.toModelArray()
.filter((cardModel) => cardModel.hasMembershipForUser(userId));
}
getCardsModelArray() {
return this.getFiniteListsQuerySet()

View file

@ -167,7 +167,9 @@ export default class extends BaseModel {
});
payload.cardMemberships.forEach(({ cardId, userId }) => {
Card.withId(cardId).users.add(userId);
try {
Card.withId(cardId).users.add(userId);
} catch {} // eslint-disable-line no-empty
});
payload.cardLabels.forEach(({ cardId, labelId }) => {
@ -510,6 +512,14 @@ export default class extends BaseModel {
.exists();
}
hasMembershipForUser(userId) {
return this.users
.filter({
id: userId,
})
.exists();
}
isAvailableForUser(userModel) {
return !!this.list && this.list.isAvailableForUser(userModel);
}

View file

@ -235,6 +235,41 @@ export const selectProjectsToListsWithEditorRightsForCurrentUser = createSelecto
},
);
export const selectProjectsToCardsWithEditorRightsForCurrentUser = createSelector(
orm,
(state) => selectCurrentUserId(state),
({ User }, id) => {
if (!id) {
return id;
}
const userModel = User.withId(id);
if (!userModel) {
return userModel;
}
return userModel.getMembershipProjectsModelArray().map((projectModel) => ({
...projectModel.ref,
boards: projectModel.getBoardsModelArrayForUserWithId(id).flatMap((boardModel) => {
const boardMembersipModel = boardModel.getMembershipModelByUserId(id);
if (boardMembersipModel.role !== BoardMembershipRoles.EDITOR) {
return [];
}
return {
...boardModel.ref,
cards: boardModel.getOrderedCardsModelArrayForUser(id).map((card) => ({
...card,
isPersisted: !isLocalId(card.id),
})),
};
}),
}));
},
);
export const selectBoardIdsForCurrentUser = createSelector(
orm,
(state) => selectCurrentUserId(state),
@ -335,6 +370,7 @@ export default {
selectFilteredProjctIdsByGroupForCurrentUser,
selectFavoriteProjectIdsForCurrentUser,
selectProjectsToListsWithEditorRightsForCurrentUser,
selectProjectsToCardsWithEditorRightsForCurrentUser,
selectBoardIdsForCurrentUser,
selectNotificationIdsForCurrentUser,
selectNotificationServiceIdsForCurrentUser,

View file

@ -9455,7 +9455,6 @@
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",