From c0a3c37d958d0122af248e22c66dae5cee2005d1 Mon Sep 17 00:00:00 2001 From: exception-raised <135153991+exception-raised@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:31:57 +0200 Subject: [PATCH] feat: add tiptap markdown support, add checklist support from trello (#245) * feat: add tiptap markdown support, add checklist support from trello * feat(api): implement bulk creation of checklists and items --- apps/web/package.json | 1 + apps/web/src/components/Editor.tsx | 107 ++++++++------- packages/api/src/routers/import.ts | 129 ++++++++++++++++++- packages/db/src/repository/checklist.repo.ts | 129 ++++++++++++++++++- pnpm-lock.yaml | 41 +++++- 5 files changed, 356 insertions(+), 51 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 1b12ad6e..46e6a3f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,6 +65,7 @@ "superjson": "2.2.1", "tailwind-merge": "^2.5.2", "tippy.js": "^6.3.7", + "tiptap-markdown": "^0.8", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index 90a40c47..41c91f11 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -7,6 +7,7 @@ import type { Instance as TippyInstance } from "tippy.js"; import { Button } from "@headlessui/react"; import { t } from "@lingui/core/macro"; import Link from "@tiptap/extension-link"; +import Mention from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; import { BubbleMenu, @@ -39,9 +40,10 @@ import { } from "react-icons/hi2"; import { twMerge } from "tailwind-merge"; import tippy from "tippy.js"; -import Mention from "@tiptap/extension-mention"; -import Avatar from "./Avatar"; +import { Markdown } from "tiptap-markdown"; + import { getAvatarUrl } from "~/utils/helpers"; +import Avatar from "./Avatar"; declare module "@tiptap/core" { interface Commands { @@ -77,7 +79,7 @@ export interface RenderSuggestionsProps { command: (item: SlashCommandItem) => void; } -export type WorkspaceMember = { +export interface WorkspaceMember { publicId: string; user: { id: string; @@ -85,7 +87,7 @@ export type WorkspaceMember = { image: string | null; } | null; email: string; -}; +} const CommandsList = forwardRef< { onKeyDown: (props: SuggestionKeyDownProps) => boolean }, @@ -200,7 +202,11 @@ const RenderSuggestions = () => { }; }; -type MentionItem = { id: string; label: string; image: string | null }; +interface MentionItem { + id: string; + label: string; + image: string | null; +} const MentionList = forwardRef< { onKeyDown: (props: SuggestionKeyDownProps) => boolean }, @@ -238,30 +244,32 @@ const MentionList = forwardRef< return (
- {items.length > 0 ? items.map((item, index) => ( - - )) : ( + {items.length > 0 ? ( + items.map((item, index) => ( + + )) + ) : (
- No results + + No results +
)}
@@ -436,6 +444,7 @@ export default function Editor({ { extensions: [ StarterKit, + Markdown, Placeholder.configure({ placeholder: readOnly ? "" @@ -462,35 +471,37 @@ export default function Editor({ }), Mention.configure({ HTMLAttributes: { - class: 'mention', + class: "mention", }, suggestion: { char: "@", items: ({ query }: { query: string }) => { - const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({ - id: member.publicId, - label: member?.user?.name ?? member.email, - image: member?.user?.image ?? null, - })); + const all: MentionItem[] = workspaceMembers.map( + (member: WorkspaceMember) => ({ + id: member.publicId, + label: member?.user?.name ?? member.email, + image: member?.user?.image ?? null, + }), + ); const q = query.toLowerCase(); return all.filter((u) => u.label.toLowerCase().includes(q)); }, command: ({ editor, range, props }: any) => { - const mentionHTML = `@${props.label} `; - - editor - .chain() - .focus() - .deleteRange(range) - .insertContent(mentionHTML) - .focus() - .run(); - }, - render: renderMentionSuggestions, - }, - renderText({ options, node }) { - return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; + const mentionHTML = `@${props.label} `; + + editor + .chain() + .focus() + .deleteRange(range) + .insertContent(mentionHTML) + .focus() + .run(); }, + render: renderMentionSuggestions, + }, + renderText({ options, node }) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; + }, }), ], content, diff --git a/packages/api/src/routers/import.ts b/packages/api/src/routers/import.ts index a36cb33d..46e9f91d 100644 --- a/packages/api/src/routers/import.ts +++ b/packages/api/src/routers/import.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import * as boardRepo from "@kan/db/repository/board.repo"; import * as cardRepo from "@kan/db/repository/card.repo"; import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo"; +import * as checklistRepo from "@kan/db/repository/checklist.repo"; import * as importRepo from "@kan/db/repository/import.repo"; import * as integrationsRepo from "@kan/db/repository/integration.repo"; import * as labelRepo from "@kan/db/repository/label.repo"; @@ -22,6 +23,7 @@ export interface TrelloBoard { labels: TrelloLabel[]; lists: TrelloList[]; cards: TrelloCard[]; + checklists: TrelloChecklist[]; } interface TrelloLabel { @@ -34,12 +36,34 @@ interface TrelloList { name: string; } +interface TrelloChecklist { + id: string; + idCard: string; + name: string; + checkItems: TrelloCheckItem[]; +} + +interface TrelloCheckItem { + id: string; + name: string; + state: "complete" | "incomplete"; + pos: number; +} + interface TrelloCard { id: string; name: string; desc: string; idList: string; labels: TrelloLabel[]; + idChecklists: string[]; + checkItemStates: TrelloCheckItemState[]; +} + +interface TrelloCheckItemState { + idChecklist: string; + idCheckItem: string; + state: "complete" | "incomplete"; } export const importRouter = createTRPCRouter({ @@ -170,7 +194,7 @@ export const importRouter = createTRPCRouter({ for (const boardId of input.boardIds) { const response = await fetch( - `${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all`, + `${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all&checklists=all&checkItemStates=all`, ); const data = (await response.json()) as TrelloBoard; @@ -194,6 +218,18 @@ export const importRouter = createTRPCRouter({ sourceId: label.id, name: label.name, })), + checklists: data.checklists + .filter((checklist) => checklist.idCard === _card.id) + .map((_checklist) => ({ + sourceId: _checklist.id, + name: _checklist.name, + items: _checklist.checkItems.map((_item) => ({ + sourceId: _item.id, + title: _item.name, + completed: _item.state === "complete", + index: _item.pos, + })), + })), })), })), }; @@ -289,6 +325,97 @@ export const importRouter = createTRPCRouter({ await cardActivityRepo.bulkCreate(ctx.db, activities); } + const checklistsToCreate: { + cardId: number; + name: string; + createdBy: string; + index: number; + sourceId: string; + items: { + sourceId: string; + title: string; + completed: boolean; + index: number; + }[]; + }[] = []; + + for (const card of list.cards) { + const _card = createdCards.find( + (c) => c.sourceId === card.sourceId, + ); + + if (!_card || !card.checklists.length) continue; + + for ( + let checklistIndex = 0; + checklistIndex < card.checklists.length; + checklistIndex++ + ) { + const checklist = card.checklists[checklistIndex]; + if (!checklist) continue; + + checklistsToCreate.push({ + cardId: _card.id, + name: checklist.name, + createdBy: userId, + index: checklistIndex, + sourceId: checklist.sourceId, + items: checklist.items.map((item) => ({ + sourceId: item.sourceId, + title: item.title, + completed: item.completed, + index: item.index, + })), + }); + } + } + + if (checklistsToCreate.length > 0) { + const newChecklists = await checklistRepo.bulkCreate( + ctx.db, + checklistsToCreate.map((checklist) => ({ + cardId: checklist.cardId, + name: checklist.name, + createdBy: checklist.createdBy, + index: checklist.index, + })), + ); + + const itemsToCreate: { + checklistId: number; + title: string; + createdBy: string; + index: number; + completed: boolean; + }[] = []; + + for (let i = 0; i < checklistsToCreate.length; i++) { + const checklistData = checklistsToCreate[i]; + const newChecklist = newChecklists[i]; + + if (!newChecklist || !checklistData?.items.length) continue; + + // NOTE: Sorting here to prevent checklist items being out of order + const sortedItems = [...checklistData.items].sort( + (a, b) => a.index - b.index, + ); + + for (const item of sortedItems) { + itemsToCreate.push({ + checklistId: newChecklist.id, + title: item.title, + createdBy: userId, + index: item.index, + completed: item.completed, + }); + } + } + + if (itemsToCreate.length > 0) { + await checklistRepo.bulkCreateItems(ctx.db, itemsToCreate); + } + } + if (createdLabels.length && createdCards.length) { const cardLabelRelations: { cardId: number; diff --git a/packages/db/src/repository/checklist.repo.ts b/packages/db/src/repository/checklist.repo.ts index 18e481de..86a5e481 100644 --- a/packages/db/src/repository/checklist.repo.ts +++ b/packages/db/src/repository/checklist.repo.ts @@ -46,6 +46,7 @@ export const createItem = async ( checklistId: number; title: string; createdBy: string; + completed?: boolean; }, ) => { return db.transaction(async (tx) => { @@ -65,7 +66,7 @@ export const createItem = async ( createdBy: checklistItemInput.createdBy, checklistId: checklistItemInput.checklistId, index: lastItem ? lastItem.index + 1 : 0, - completed: false, + completed: checklistItemInput.completed ?? false, }) .returning({ id: checklistItems.id, @@ -214,3 +215,129 @@ export const updateChecklistById = async ( .returning({ publicId: checklists.publicId, name: checklists.name }); return result; }; + +export const bulkCreate = async ( + db: dbClient, + checklistInput: { + cardId: number; + name: string; + createdBy: string; + index: number; + }[], +) => { + if (checklistInput.length === 0) return []; + + return db.transaction(async (tx) => { + const byCard = groupByKey(checklistInput, "cardId"); + + const allValuesToInsert: { + publicId: string; + cardId: number; + name: string; + createdBy: string; + index: number; + }[] = []; + + for (const [cardId, items] of byCard.entries()) { + const last = await tx.query.checklists.findFirst({ + columns: { index: true }, + where: and(eq(checklists.cardId, cardId), isNull(checklists.deletedAt)), + orderBy: [desc(checklists.index)], + }); + + let nextIndex = last ? last.index + 1 : 0; + + const sorted = [...items].sort((a, b) => a.index - b.index); + + for (const item of sorted) { + allValuesToInsert.push({ + publicId: generateUID(), + ...item, + index: nextIndex++, + }); + } + } + + const inserted = await tx + .insert(checklists) + .values(allValuesToInsert) + .returning({ id: checklists.id, publicId: checklists.publicId }); + + return inserted; + }); +}; + +export const bulkCreateItems = async ( + db: dbClient, + checklistItemInput: { + checklistId: number; + title: string; + createdBy: string; + index: number; + completed: boolean; + }[], +) => { + if (checklistItemInput.length === 0) return []; + + return db.transaction(async (tx) => { + const byChecklist = groupByKey(checklistItemInput, "checklistId"); + + const allValuesToInsert: { + publicId: string; + checklistId: number; + title: string; + createdBy: string; + index: number; + completed: boolean; + }[] = []; + + for (const [checklistId, items] of byChecklist.entries()) { + const last = await tx.query.checklistItems.findFirst({ + columns: { index: true }, + where: and( + eq(checklistItems.checklistId, checklistId), + isNull(checklistItems.deletedAt), + ), + orderBy: [desc(checklistItems.index)], + }); + + let nextIndex = last ? last.index + 1 : 0; + + const sorted = [...items].sort((a, b) => a.index - b.index); + + for (const item of sorted) { + allValuesToInsert.push({ + publicId: generateUID(), + ...item, + index: nextIndex++, + }); + } + } + + const inserted = await tx + .insert(checklistItems) + .values(allValuesToInsert) + .returning({ + id: checklistItems.id, + publicId: checklistItems.publicId, + title: checklistItems.title, + completed: checklistItems.completed, + }); + + return inserted; + }); +}; + +const groupByKey = >( + items: T[], + keyField: keyof T, +): Map => { + const grouped = new Map(); + for (const item of items) { + const key = item[keyField] as number; + const arr = grouped.get(key) ?? []; + arr.push(item); + grouped.set(key, arr); + } + return grouped; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef79f132..8478c667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,9 @@ importers: tippy.js: specifier: ^6.3.7 version: 6.3.7 + tiptap-markdown: + specifier: ^0.8 + version: 0.8.10(@tiptap/core@2.26.1(@tiptap/pm@2.26.1)) zod: specifier: 'catalog:' version: 3.25.76 @@ -3575,9 +3578,15 @@ packages: '@types/katex@0.16.7': resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + '@types/linkify-it@3.0.5': + resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/markdown-it@13.0.9': + resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -3587,6 +3596,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@1.0.5': + resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==} + '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} @@ -5730,6 +5742,9 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + markdown-it-task-lists@2.1.1: + resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} + markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true @@ -7411,6 +7426,11 @@ packages: tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + tiptap-markdown@0.8.10: + resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==} + peerDependencies: + '@tiptap/core': ^2.0.3 + title-case@2.1.1: resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==} @@ -11321,8 +11341,15 @@ snapshots: '@types/katex@0.16.7': {} + '@types/linkify-it@3.0.5': {} + '@types/linkify-it@5.0.0': {} + '@types/markdown-it@13.0.9': + dependencies: + '@types/linkify-it': 3.0.5 + '@types/mdurl': 1.0.5 + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -11336,6 +11363,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mdurl@1.0.5': {} + '@types/mdurl@2.0.0': {} '@types/ms@2.1.0': {} @@ -13777,6 +13806,8 @@ snapshots: make-error@1.3.6: {} + markdown-it-task-lists@2.1.1: {} + markdown-it@14.1.0: dependencies: argparse: 2.0.1 @@ -16085,6 +16116,14 @@ snapshots: dependencies: '@popperjs/core': 2.11.8 + tiptap-markdown@0.8.10(@tiptap/core@2.26.1(@tiptap/pm@2.26.1)): + dependencies: + '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) + '@types/markdown-it': 13.0.9 + markdown-it: 14.1.0 + markdown-it-task-lists: 2.1.1 + prosemirror-markdown: 1.13.2 + title-case@2.1.1: dependencies: no-case: 2.3.2 @@ -16466,7 +16505,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.25.4 + browserslist: 4.27.0 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0