Skip to content

Commit 1412b82

Browse files
committed
Allow creating and deleting new columns
1 parent ec5c058 commit 1412b82

9 files changed

Lines changed: 234 additions & 27 deletions

File tree

backend/src/entities/board-card.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class BoardCard extends BaseEntity {
4747
assignedBoardMember?: BoardMember;
4848

4949
@OneToOne({ mappedBy: (emailDraft: EmailDraft) => emailDraft.boardCard, nullable: true })
50-
emailDraft?: EmailDraft;
50+
emailDraft?: EmailDraft; // Email draft are deleted if state is not INBOX
5151
@OneToMany({ mappedBy: (comment: Comment) => comment.boardCard })
5252
comments = new Collection<Comment>(this);
5353
@OneToMany({ mappedBy: (readPosition: BoardCardReadPosition) => readPosition.boardCard })
@@ -130,33 +130,27 @@ export class BoardCard extends BaseEntity {
130130

131131
update({
132132
externalThreadId,
133-
state,
134133
snippet,
135134
participantsAsc,
136135
lastEventAt,
137136
hasAttachments,
138137
emailMessageCount,
139-
movedToTrashAt,
140138
participantUserIds,
141139
}: {
142140
externalThreadId: string;
143-
state: State;
144141
snippet: string;
145142
participantsAsc: Participant[];
146143
lastEventAt: Date;
147144
hasAttachments: boolean;
148145
emailMessageCount: number;
149-
movedToTrashAt?: Date;
150146
participantUserIds?: string[];
151147
}) {
152148
this.externalThreadId = externalThreadId;
153-
this.state = state;
154149
this.snippet = snippet.slice(0, MAX_SNIPPET_LENGTH);
155150
this.participantsAsc = participantsAsc.map((p) => ({ ...p, email: p.email.toLowerCase() }));
156151
this.lastEventAt = lastEventAt;
157152
this.hasAttachments = hasAttachments;
158153
this.emailMessageCount = emailMessageCount;
159-
this.movedToTrashAt = movedToTrashAt;
160154
this.participantUserIds = participantUserIds;
161155
this.validate();
162156
}
@@ -173,7 +167,7 @@ export class BoardCard extends BaseEntity {
173167
setState(state: State) {
174168
this.state = state;
175169
if (state === State.TRASH) {
176-
this.movedToTrashAt = new Date();
170+
if (!this.movedToTrashAt) this.movedToTrashAt = new Date();
177171
} else if (this.movedToTrashAt) {
178172
this.movedToTrashAt = undefined;
179173
}

backend/src/services/board-card.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -402,22 +402,22 @@ export class BoardCardService {
402402
].filter((id): id is string => !!id),
403403
);
404404

405+
boardCard.setState(state);
406+
405407
boardCard.update({
406-
state,
407408
snippet: lastEmailMessage.snippet,
408409
externalThreadId: lastEmailMessage.externalThreadId,
409410
participantsAsc: BoardCardService.participantsAsc({ emailMessagesDesc }),
410411
lastEventAt,
411412
hasAttachments: emailMessagesDesc.some((msg) => msg.gmailAttachments.length > 0),
412413
emailMessageCount: emailMessagesDesc.length,
413-
movedToTrashAt: state === State.TRASH ? boardCard.movedToTrashAt || new Date() : undefined,
414414
participantUserIds: participantUserIds.length > 0 ? participantUserIds : undefined,
415415
});
416416

417417
return boardCard;
418418
}
419419

420-
static async delete(boardCard: Loaded<BoardCard>) {
420+
static async deleteAfterDeletingEmailDrafts(boardCard: Loaded<BoardCard>) {
421421
await orm.em.transactional(async (em) => {
422422
await em.nativeDelete(Comment, { boardCard });
423423
await em.nativeDelete(BoardCardReadPosition, { boardCard });

backend/src/services/board-column.service.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import type { Populate } from '@mikro-orm/postgresql';
22

33
import type { Board } from '@/entities/board';
4+
import { BoardCard } from '@/entities/board-card';
5+
import { BoardCardReadPosition } from '@/entities/board-card-read-position';
46
import { BoardColumn } from '@/entities/board-column';
7+
import { Comment } from '@/entities/comment';
8+
import { EmailMessage } from '@/entities/email-message';
9+
import { GmailAttachment } from '@/entities/gmail-attachment';
510
import { orm } from '@/utils/orm';
11+
import { BoardCardState } from '@/utils/shared';
612

713
export class BoardColumnService {
814
static async findById<Hint extends string = never>(
@@ -12,6 +18,42 @@ export class BoardColumnService {
1218
return orm.em.findOneOrFail(BoardColumn, { id: boardColumnId, board: { id: board.id } }, { populate });
1319
}
1420

21+
static async create(board: Board, { name }: { name: string }) {
22+
const allColumns = await orm.em.find(BoardColumn, { board: { id: board.id } });
23+
const position = allColumns.length;
24+
25+
const boardColumn = new BoardColumn({ board, name, description: name, position });
26+
orm.em.persist(boardColumn);
27+
await orm.em.flush();
28+
29+
return boardColumn;
30+
}
31+
32+
static async delete(board: Board, { boardColumnId }: { boardColumnId: string }) {
33+
const boardColumn = await BoardColumnService.findById(boardColumnId, { board, populate: ['boardCards'] });
34+
const { boardCards } = boardColumn;
35+
36+
const inboxBoardCards = boardCards.filter((card) => card.state === BoardCardState.INBOX);
37+
if (inboxBoardCards.length > 0) {
38+
throw new Error('Cannot delete column with inbox board cards');
39+
}
40+
41+
await orm.em.transactional(async (em) => {
42+
const emailMessagesCondition = {
43+
externalThreadId: { $in: boardCards.map((c) => c.externalThreadId).filter((id): id is string => !!id) },
44+
};
45+
await em.nativeDelete(GmailAttachment, { emailMessage: emailMessagesCondition });
46+
await em.nativeDelete(EmailMessage, emailMessagesCondition);
47+
48+
const boardCardsCondition = { id: { $in: boardCards.map((c) => c.id) } };
49+
await em.nativeDelete(Comment, { boardCard: boardCardsCondition });
50+
await em.nativeDelete(BoardCardReadPosition, { boardCard: boardCardsCondition });
51+
await em.nativeDelete(BoardCard, boardCardsCondition);
52+
53+
await em.nativeDelete(BoardColumn, { id: boardColumn.id });
54+
});
55+
}
56+
1557
static async setName<Hint extends string = never>(
1658
board: Board,
1759
{

backend/src/services/email-draft.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export class EmailDraftService {
118118
await FileAttachmentService.deleteAllForDraft(emailDraft);
119119

120120
if (boardCard.noMessages) {
121-
await BoardCardService.delete(boardCard);
121+
await BoardCardService.deleteAfterDeletingEmailDrafts(boardCard);
122122
}
123123

124124
await orm.em.flush();

backend/src/services/email-message.service.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import { BoardAccountService } from '@/services/board-account.service';
1212
import { BoardCardService } from '@/services/board-card.service';
1313
import { BoardMemberService } from '@/services/board-member.service';
1414
import { DomainService } from '@/services/domain.service';
15+
import { EmailDraftService } from '@/services/email-draft.service';
1516
import { GmailAccountService } from '@/services/gmail-account.service';
1617
import { htmlToText } from '@/utils/email';
1718
import { ENV } from '@/utils/env';
1819
import { reportError } from '@/utils/error-tracking';
1920
import { GmailApi, LABEL } from '@/utils/gmail-api';
2021
import { groupBy, mapBy, presence, unique } from '@/utils/lists';
2122
import { orm } from '@/utils/orm';
22-
import { FALLBACK_SUBJECT, type Participant } from '@/utils/shared';
23+
import { BoardCardState, FALLBACK_SUBJECT, type Participant } from '@/utils/shared';
2324
import { renderTemplate } from '@/utils/strings';
2425
import { sleep } from '@/utils/time';
2526
import { BoardService } from './board.service';
@@ -486,7 +487,13 @@ export class EmailMessageService {
486487
...(await BoardCardService.findCardsByExternalThreadIds({
487488
gmailAccount,
488489
externalThreadIds: affectedExternalThreadIds,
489-
populate: ['domain', 'boardCardReadPositions', 'boardColumn.board.boardMembers', 'comments', 'emailDraft'],
490+
populate: [
491+
'domain',
492+
'boardCardReadPositions',
493+
'boardColumn.board.boardMembers',
494+
'comments',
495+
'emailDraft.fileAttachments',
496+
],
490497
})),
491498
],
492499
(boardCard) => boardCard.externalThreadId!,
@@ -624,6 +631,7 @@ export class EmailMessageService {
624631

625632
// Handle label changes (read / unread / trash / spam): update EmailMessages, update BoardCards
626633
console.log(`[GMAIL] Processing label changes for ${gmailAccount.email}...`);
634+
const nonInboxBoardCards = [];
627635
for (const labelChange of labelChanges) {
628636
const { externalMessageId } = labelChange;
629637
const emailMessage = affectedEmailMessageByExternalId[externalMessageId];
@@ -645,6 +653,10 @@ export class EmailMessageService {
645653

646654
let boardCard = boardCardByThreadId[threadId]!;
647655
boardCard = BoardCardService.rebuildFromEmailMessages({ boardCard, emailMessagesDesc }) as typeof boardCard;
656+
if (boardCard.state !== BoardCardState.INBOX) {
657+
nonInboxBoardCards.push(boardCard);
658+
}
659+
648660
orm.em.persist(boardCard);
649661
boardCardByThreadId[threadId] = boardCard;
650662
}
@@ -655,6 +667,10 @@ export class EmailMessageService {
655667
}
656668

657669
await orm.em.flush();
670+
671+
for (const boardCard of nonInboxBoardCards) {
672+
await EmailDraftService.delete(boardCard);
673+
}
658674
}
659675

660676
private static boardAccountToSyncWhenNoBoardCard({

backend/src/trpc-routes/board-column.routes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ import { authAsBoardMember, publicProcedure } from '@/trpc-config';
77

88
export const BOARD_COLUMN_ROUTES = {
99
boardColumn: {
10+
create: publicProcedure
11+
.input(z.object({ boardId: z.uuid(), name: z.string().min(1) }))
12+
.mutation(async ({ input, ctx }) => {
13+
const { board } = authAsBoardMember({ ctx, input });
14+
const boardColumn = await BoardColumnService.create(board, { name: input.name });
15+
return { boardColumn: BoardColumn.toJson(boardColumn) };
16+
}),
17+
delete: publicProcedure
18+
.input(z.object({ boardId: z.uuid(), boardColumnId: z.uuid() }))
19+
.mutation(async ({ input, ctx }) => {
20+
const { board } = authAsBoardMember({ ctx, input });
21+
await BoardColumnService.delete(board, { boardColumnId: input.boardColumnId });
22+
}),
1023
setName: publicProcedure
1124
.input(z.object({ boardId: z.uuid(), boardColumnId: z.uuid(), name: z.string().min(1) }))
1225
.mutation(async ({ input, ctx }) => {

frontend/src/components/board/board-column.tsx

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useDroppable } from '@dnd-kit/core';
22
import { useSortable } from '@dnd-kit/sortable';
33
import { CSS } from '@dnd-kit/utilities';
44
import { useMutation } from '@tanstack/react-query';
5+
import { Ellipsis } from 'lucide-react';
56
import { useState } from 'react';
67
import {
78
BoardCard,
@@ -10,13 +11,22 @@ import {
1011
DRAG_TYPE as DRAG_TYPE_CARD,
1112
} from '@/components/board/board-card';
1213
import { Badge } from '@/components/ui/badge';
14+
import { Button } from '@/components/ui/button';
15+
import {
16+
DropdownMenu,
17+
DropdownMenuContent,
18+
DropdownMenuItem,
19+
DropdownMenuTrigger,
20+
} from '@/components/ui/dropdown-menu';
1321
import { Input } from '@/components/ui/input';
1422
import { useOptimisticMutation } from '@/hooks/use-optimistic-mutation';
23+
import { useOptimisticMutationWithUndo } from '@/hooks/use-optimistic-mutation-with-undo';
1524
import { useRouteContext } from '@/hooks/use-route-context';
1625
import {
1726
type Board,
1827
type BoardColumn as BoardColumnType,
1928
type BoardMember,
29+
removeBoardColumnData,
2030
renameBoardColumnData,
2131
} from '@/query-helpers/board';
2232
import type { BoardCard as BoardCardType } from '@/query-helpers/board-cards';
@@ -27,12 +37,14 @@ export const DRAG_TYPE = 'board-column';
2737
export const BoardColumn = ({
2838
board,
2939
boardColumn,
40+
boardCards,
3041
unreadBoardCardCount,
3142
children,
3243
isDraggingColumn,
3344
}: {
3445
board: Board;
3546
boardColumn: BoardColumnType;
47+
boardCards: BoardCardType[];
3648
unreadBoardCardCount: number;
3749
children: React.ReactNode;
3850
isDraggingColumn: boolean;
@@ -50,15 +62,31 @@ export const BoardColumn = ({
5062
const [isEditing, setIsEditing] = useState(false);
5163
const [editedName, setEditedName] = useState(boardColumn.name);
5264

65+
const boardQueryKey = trpc.board.get.queryKey({ boardId: board.id });
66+
5367
const optimisticallySetName = useOptimisticMutation({
5468
queryClient,
55-
queryKey: trpc.board.get.queryKey({ boardId: board.id }),
69+
queryKey: boardQueryKey,
5670
onExecute: ({ name }) =>
5771
renameBoardColumnData({ trpc, queryClient, params: { boardId: board.id, boardColumnId: boardColumn.id, name } }),
5872
errorToast: 'Failed to rename column. Please try again.',
5973
mutation: useMutation(trpc.boardColumn.setName.mutationOptions()),
6074
});
6175

76+
const createMutation = useMutation(trpc.boardColumn.create.mutationOptions());
77+
const optimisticallyDeleteColumn = useOptimisticMutationWithUndo({
78+
queryClient,
79+
queryKey: boardQueryKey,
80+
onExecute: (params) => removeBoardColumnData({ trpc, queryClient, params }),
81+
successToast: 'Column deleted',
82+
errorToast: 'Failed to delete column. Please try again.',
83+
mutation: useMutation(trpc.boardColumn.delete.mutationOptions()),
84+
undoMutationConfig: () => ({
85+
mutation: createMutation,
86+
params: { boardId: board.id, name: boardColumn.name },
87+
}),
88+
});
89+
6290
const handleNameClick = () => {
6391
setIsEditing(true);
6492
setEditedName(boardColumn.name);
@@ -81,6 +109,14 @@ export const BoardColumn = ({
81109
}
82110
};
83111

112+
const hasBoardCards = boardCards.length > 0;
113+
114+
const handleDelete = () => {
115+
if (!hasBoardCards) {
116+
optimisticallyDeleteColumn({ boardId: board.id, boardColumnId: boardColumn.id });
117+
}
118+
};
119+
84120
const style = {
85121
transform: CSS.Transform.toString(transform),
86122
transition,
@@ -113,14 +149,30 @@ export const BoardColumn = ({
113149
autoFocus
114150
/>
115151
) : (
116-
<h2 className="text-sm font-semibold cursor-pointer hover:text-primary w-full" onClick={handleNameClick}>
117-
{editedName}
118-
</h2>
119-
)}
120-
{unreadBoardCardCount > 0 && (
121-
<Badge variant="default" size="sm">
122-
{unreadBoardCardCount}
123-
</Badge>
152+
<>
153+
<div className="flex items-center gap-2 flex-1">
154+
<h2 className="text-sm font-semibold cursor-pointer hover:text-primary" onClick={handleNameClick}>
155+
{editedName}
156+
</h2>
157+
{unreadBoardCardCount > 0 && (
158+
<Badge variant="default" size="sm">
159+
{unreadBoardCardCount}
160+
</Badge>
161+
)}
162+
</div>
163+
<DropdownMenu>
164+
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
165+
<Button variant="ghost" size="icon-sm" className="size-5 p-0 focus-visible:ring-0">
166+
<Ellipsis className="size-4 text-muted-foreground" />
167+
</Button>
168+
</DropdownMenuTrigger>
169+
<DropdownMenuContent align="end">
170+
<DropdownMenuItem onClick={handleDelete} disabled={hasBoardCards} className="text-sm">
171+
Delete column
172+
</DropdownMenuItem>
173+
</DropdownMenuContent>
174+
</DropdownMenu>
175+
</>
124176
)}
125177
</div>
126178
<div

0 commit comments

Comments
 (0)