-
Notifications
You must be signed in to change notification settings - Fork 970
feat(core): add group and sortOrder to collections for admin UI organization #292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0eee0f8
1df0256
5567d2c
4f47303
379e451
be552fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| "@emdash/core": minor | ||
| --- | ||
|
|
||
| Add collection grouping support for plugin organization | ||
|
|
||
| - Add `group` and `sortOrder` fields to collections | ||
| - Plugins can define `group` in seed.json to organize collections in admin sidebar | ||
| - Collections sorted by `sortOrder` within groups |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -39,13 +39,22 @@ import { | |||||||||||||||||||||||||||
| CodeBlock, | ||||||||||||||||||||||||||||
| Stack, | ||||||||||||||||||||||||||||
| Eye, | ||||||||||||||||||||||||||||
| Table as TableIcon, | ||||||||||||||||||||||||||||
| Plus, | ||||||||||||||||||||||||||||
| Trash, | ||||||||||||||||||||||||||||
| Rows, | ||||||||||||||||||||||||||||
| Columns, | ||||||||||||||||||||||||||||
| type Icon, | ||||||||||||||||||||||||||||
| } from "@phosphor-icons/react"; | ||||||||||||||||||||||||||||
| import { X } from "@phosphor-icons/react"; | ||||||||||||||||||||||||||||
| import { Extension, type Range } from "@tiptap/core"; | ||||||||||||||||||||||||||||
| import CharacterCount from "@tiptap/extension-character-count"; | ||||||||||||||||||||||||||||
| import Focus from "@tiptap/extension-focus"; | ||||||||||||||||||||||||||||
| import Placeholder from "@tiptap/extension-placeholder"; | ||||||||||||||||||||||||||||
| import { Table } from "@tiptap/extension-table"; | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [needs fixing] This PR is titled "add group and sortOrder to collections" but bundles an entire TipTap table editor feature. That violates AGENTS.md's scope rule:
The table feature also lacks a linked approved Discussion. Please remove all table-related changes from this branch and open a separate PR if you want to pursue tables. |
||||||||||||||||||||||||||||
| import { TableCell } from "@tiptap/extension-table-cell"; | ||||||||||||||||||||||||||||
| import { TableHeader } from "@tiptap/extension-table-header"; | ||||||||||||||||||||||||||||
| import { TableRow } from "@tiptap/extension-table-row"; | ||||||||||||||||||||||||||||
| import TextAlign from "@tiptap/extension-text-align"; | ||||||||||||||||||||||||||||
| import Typography from "@tiptap/extension-typography"; | ||||||||||||||||||||||||||||
| import { useEditor, EditorContent, useEditorState, type Editor } from "@tiptap/react"; | ||||||||||||||||||||||||||||
|
|
@@ -280,6 +289,67 @@ function convertPMNode(node: { | |||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| case "table": { | ||||||||||||||||||||||||||||
| const tableKey = generateKey(); | ||||||||||||||||||||||||||||
| const tableContent = (node.content || []) as Array<{ | ||||||||||||||||||||||||||||
| type: string; | ||||||||||||||||||||||||||||
| content?: Array<{ | ||||||||||||||||||||||||||||
| type: string; | ||||||||||||||||||||||||||||
| content?: unknown[]; | ||||||||||||||||||||||||||||
| }>; | ||||||||||||||||||||||||||||
| }>; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const rows = tableContent | ||||||||||||||||||||||||||||
| .filter((row) => row.type === "tableRow") | ||||||||||||||||||||||||||||
| .map((row, rowIndex) => { | ||||||||||||||||||||||||||||
| const cells = (row.content || []).map((cell, cellIndex) => { | ||||||||||||||||||||||||||||
| const isHeader = cell.type === "tableHeader"; | ||||||||||||||||||||||||||||
| const cellContent = (cell.content || []) as Array<{ | ||||||||||||||||||||||||||||
| type: string; | ||||||||||||||||||||||||||||
| content?: unknown[]; | ||||||||||||||||||||||||||||
| }>; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Extract text from paragraphs inside the cell | ||||||||||||||||||||||||||||
| const contentSpans: PortableTextSpan[] = []; | ||||||||||||||||||||||||||||
| for (const para of cellContent) { | ||||||||||||||||||||||||||||
| if (para.type === "paragraph") { | ||||||||||||||||||||||||||||
| const { children } = convertInlineContent(para.content || []); | ||||||||||||||||||||||||||||
| contentSpans.push(...children); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Ensure at least one span | ||||||||||||||||||||||||||||
| if (contentSpans.length === 0) { | ||||||||||||||||||||||||||||
| contentSpans.push({ | ||||||||||||||||||||||||||||
| _type: "span", | ||||||||||||||||||||||||||||
| _key: generateKey(), | ||||||||||||||||||||||||||||
| text: "", | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| _type: "tableCell" as const, | ||||||||||||||||||||||||||||
| _key: `${tableKey}_r${rowIndex}_c${cellIndex}`, | ||||||||||||||||||||||||||||
| content: contentSpans, | ||||||||||||||||||||||||||||
| isHeader, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| _type: "tableRow" as const, | ||||||||||||||||||||||||||||
| _key: `${tableKey}_r${rowIndex}`, | ||||||||||||||||||||||||||||
| cells, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| _type: "table", | ||||||||||||||||||||||||||||
| _key: tableKey, | ||||||||||||||||||||||||||||
| rows, | ||||||||||||||||||||||||||||
| hasHeaderRow: rows[0]?.cells.some((c) => c.isHeader) ?? false, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
@@ -544,6 +614,72 @@ function convertPTBlock(block: PortableTextBlock): unknown { | |||||||||||||||||||||||||||
| case "break": | ||||||||||||||||||||||||||||
| return { type: "horizontalRule" }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| case "table": { | ||||||||||||||||||||||||||||
| const tableBlock = block as { | ||||||||||||||||||||||||||||
| _type: "table"; | ||||||||||||||||||||||||||||
| _key: string; | ||||||||||||||||||||||||||||
| rows?: Array<{ | ||||||||||||||||||||||||||||
| _type: "tableRow"; | ||||||||||||||||||||||||||||
| _key: string; | ||||||||||||||||||||||||||||
| cells: Array<{ | ||||||||||||||||||||||||||||
| _type: "tableCell"; | ||||||||||||||||||||||||||||
| _key: string; | ||||||||||||||||||||||||||||
| content: PortableTextSpan[]; | ||||||||||||||||||||||||||||
| isHeader?: boolean; | ||||||||||||||||||||||||||||
| }>; | ||||||||||||||||||||||||||||
| }>; | ||||||||||||||||||||||||||||
| hasHeaderRow?: boolean; | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const rows = (tableBlock.rows || []).map((row, rowIndex) => { | ||||||||||||||||||||||||||||
| const cells = row.cells.map((cell) => { | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [needs fixing] This mark mapping is naive and breaks links (and any other mark-def-based marks) inside table cells. PortableText stores links as The correct fix is to reuse the existing
Suggested change
(You would also need to thread |
||||||||||||||||||||||||||||
| const cellType = | ||||||||||||||||||||||||||||
| cell.isHeader || (tableBlock.hasHeaderRow && rowIndex === 0) | ||||||||||||||||||||||||||||
| ? "tableHeader" | ||||||||||||||||||||||||||||
| : "tableCell"; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Map PortableText marks to ProseMirror marks | ||||||||||||||||||||||||||||
| const markNameMap: Record<string, string> = { | ||||||||||||||||||||||||||||
| strong: "bold", | ||||||||||||||||||||||||||||
| em: "italic", | ||||||||||||||||||||||||||||
| underline: "underline", | ||||||||||||||||||||||||||||
| "strike-through": "strike", | ||||||||||||||||||||||||||||
| code: "code", | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| const pmContent = cell.content.map((span) => ({ | ||||||||||||||||||||||||||||
| type: "text", | ||||||||||||||||||||||||||||
| text: span.text || "", | ||||||||||||||||||||||||||||
| marks: | ||||||||||||||||||||||||||||
| span.marks | ||||||||||||||||||||||||||||
| ?.map((mark) => ({ | ||||||||||||||||||||||||||||
| type: markNameMap[mark] || mark, | ||||||||||||||||||||||||||||
| })) | ||||||||||||||||||||||||||||
| .filter((m) => m.type) || [], | ||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| type: cellType, | ||||||||||||||||||||||||||||
| content: [ | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| type: "paragraph", | ||||||||||||||||||||||||||||
| content: pmContent.length > 0 ? pmContent : undefined, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| type: "tableRow", | ||||||||||||||||||||||||||||
| content: cells, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| type: "table", | ||||||||||||||||||||||||||||
| content: rows, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| default: { | ||||||||||||||||||||||||||||
| // Treat unknown block types as plugin blocks (embeds) | ||||||||||||||||||||||||||||
| // These have an id field (or url for backwards compat) for the embed source, | ||||||||||||||||||||||||||||
|
|
@@ -770,6 +906,21 @@ const defaultSlashCommands: SlashCommandItem[] = [ | |||||||||||||||||||||||||||
| editor.chain().focus().deleteRange(range).setHorizontalRule().run(); | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| id: "table", | ||||||||||||||||||||||||||||
| title: "Table", | ||||||||||||||||||||||||||||
| description: "Insert a table", | ||||||||||||||||||||||||||||
| icon: TableIcon, | ||||||||||||||||||||||||||||
| aliases: ["grid", "spreadsheet"], | ||||||||||||||||||||||||||||
| command: ({ editor, range }) => { | ||||||||||||||||||||||||||||
| editor | ||||||||||||||||||||||||||||
| .chain() | ||||||||||||||||||||||||||||
| .focus() | ||||||||||||||||||||||||||||
| .deleteRange(range) | ||||||||||||||||||||||||||||
| .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) | ||||||||||||||||||||||||||||
| .run(); | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
|
|
@@ -1499,6 +1650,12 @@ export function PortableTextEditor({ | |||||||||||||||||||||||||||
| ImageExtension, | ||||||||||||||||||||||||||||
| MarkdownLinkExtension, | ||||||||||||||||||||||||||||
| PluginBlockExtension, | ||||||||||||||||||||||||||||
| Table.configure({ | ||||||||||||||||||||||||||||
| resizable: true, | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| TableRow, | ||||||||||||||||||||||||||||
| TableHeader, | ||||||||||||||||||||||||||||
| TableCell, | ||||||||||||||||||||||||||||
| Placeholder.configure({ | ||||||||||||||||||||||||||||
| includeChildren: true, | ||||||||||||||||||||||||||||
| placeholder: ({ node }) => { | ||||||||||||||||||||||||||||
|
|
@@ -1736,6 +1893,7 @@ export function PortableTextEditor({ | |||||||||||||||||||||||||||
| <EditorToolbar editor={editor} focusMode={focusMode} onFocusModeChange={setFocusMode} /> | ||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||
| <EditorBubbleMenu editor={editor} /> | ||||||||||||||||||||||||||||
| <TableBubbleMenu editor={editor} /> | ||||||||||||||||||||||||||||
| <div className="relative overflow-visible"> | ||||||||||||||||||||||||||||
| <EditorContent editor={editor} /> | ||||||||||||||||||||||||||||
| {editable && <DragHandleWrapper editor={editor} />} | ||||||||||||||||||||||||||||
|
|
@@ -1927,6 +2085,95 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) { | |||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * Table Bubble Menu - appears when cursor is in a table | ||||||||||||||||||||||||||||
| * Shows table editing options: add/remove rows/columns, delete table | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
| function TableBubbleMenu({ editor }: { editor: Editor }) { | ||||||||||||||||||||||||||||
| if (!editor.isActive("table")) { | ||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||
| <BubbleMenu | ||||||||||||||||||||||||||||
| editor={editor} | ||||||||||||||||||||||||||||
| options={{ | ||||||||||||||||||||||||||||
| placement: "top", | ||||||||||||||||||||||||||||
| offset: 8, | ||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||
| shouldShow={({ editor: e }) => e.isActive("table")} | ||||||||||||||||||||||||||||
| className="z-[100] flex items-center gap-0.5 rounded-lg border bg-kumo-base p-1 shadow-lg" | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| {/* Add column before */} | ||||||||||||||||||||||||||||
| <BubbleButton | ||||||||||||||||||||||||||||
| onClick={() => editor.chain().focus().addColumnBefore().run()} | ||||||||||||||||||||||||||||
| title="Add column before" | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| <Columns className="h-4 w-4" /> | ||||||||||||||||||||||||||||
| <Plus className="h-2 w-2 absolute -left-0.5" /> | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] The The same issue affects the other |
||||||||||||||||||||||||||||
| </BubbleButton> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {/* Add column after */} | ||||||||||||||||||||||||||||
| <BubbleButton | ||||||||||||||||||||||||||||
| onClick={() => editor.chain().focus().addColumnAfter().run()} | ||||||||||||||||||||||||||||
| title="Add column after" | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| <Columns className="h-4 w-4" /> | ||||||||||||||||||||||||||||
| <Plus className="h-2 w-2 absolute -right-0.5" /> | ||||||||||||||||||||||||||||
| </BubbleButton> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {/* Delete column */} | ||||||||||||||||||||||||||||
| <BubbleButton | ||||||||||||||||||||||||||||
| onClick={() => editor.chain().focus().deleteColumn().run()} | ||||||||||||||||||||||||||||
| title="Delete column" | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| <Columns className="h-4 w-4 text-kumo-danger" /> | ||||||||||||||||||||||||||||
| </BubbleButton> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| <div className="w-px h-6 bg-kumo-line mx-1" /> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {/* Add row before */} | ||||||||||||||||||||||||||||
| <BubbleButton | ||||||||||||||||||||||||||||
| onClick={() => editor.chain().focus().addRowBefore().run()} | ||||||||||||||||||||||||||||
| title="Add row before" | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| <Rows className="h-4 w-4" /> | ||||||||||||||||||||||||||||
| <Plus className="h-2 w-2 absolute -top-0.5" /> | ||||||||||||||||||||||||||||
| </BubbleButton> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {/* Add row after */} | ||||||||||||||||||||||||||||
| <BubbleButton | ||||||||||||||||||||||||||||
| onClick={() => editor.chain().focus().addRowAfter().run()} | ||||||||||||||||||||||||||||
| title="Add row after" | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| <Rows className="h-4 w-4" /> | ||||||||||||||||||||||||||||
| <Plus className="h-2 w-2 absolute -bottom-0.5" /> | ||||||||||||||||||||||||||||
| </BubbleButton> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {/* Delete row */} | ||||||||||||||||||||||||||||
| <BubbleButton onClick={() => editor.chain().focus().deleteRow().run()} title="Delete row"> | ||||||||||||||||||||||||||||
| <Rows className="h-4 w-4 text-kumo-danger" /> | ||||||||||||||||||||||||||||
| </BubbleButton> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| <div className="w-px h-6 bg-kumo-line mx-1" /> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {/* Toggle header row */} | ||||||||||||||||||||||||||||
| <BubbleButton | ||||||||||||||||||||||||||||
| onClick={() => editor.chain().focus().toggleHeaderRow().run()} | ||||||||||||||||||||||||||||
| active={editor.isActive("tableHeader")} | ||||||||||||||||||||||||||||
| title="Toggle header row" | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| <TableIcon className="h-4 w-4" /> | ||||||||||||||||||||||||||||
| </BubbleButton> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| {/* Delete table */} | ||||||||||||||||||||||||||||
| <BubbleButton onClick={() => editor.chain().focus().deleteTable().run()} title="Delete table"> | ||||||||||||||||||||||||||||
| <Trash className="h-4 w-4 text-kumo-danger" /> | ||||||||||||||||||||||||||||
| </BubbleButton> | ||||||||||||||||||||||||||||
| </BubbleMenu> | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| function BubbleButton({ | ||||||||||||||||||||||||||||
| onClick, | ||||||||||||||||||||||||||||
| active, | ||||||||||||||||||||||||||||
|
|
@@ -2198,6 +2445,15 @@ function EditorToolbar({ | |||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| <CodeBlock className="h-4 w-4" aria-hidden="true" /> | ||||||||||||||||||||||||||||
| </ToolbarButton> | ||||||||||||||||||||||||||||
| <ToolbarButton | ||||||||||||||||||||||||||||
| onClick={() => | ||||||||||||||||||||||||||||
| editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| active={editor.isActive("table")} | ||||||||||||||||||||||||||||
| title="Insert Table" | ||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||
| <TableIcon className="h-4 w-4" aria-hidden="true" /> | ||||||||||||||||||||||||||||
| </ToolbarButton> | ||||||||||||||||||||||||||||
| </ToolbarGroup> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| <ToolbarSeparator /> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[needs fixing] These are runtime dependencies used by
PortableTextEditor.tsx, but they are placed indevDependencies. Every other TipTap extension in this package lives independencies. Move them to the correct section.Additionally,
@tiptap/extension-table@3.22.1declares peer dependencies on@tiptap/core@^3.22.1and@tiptap/pm@^3.22.1, but the workspace pins all other TipTap packages to3.20.0. That peer mismatch can cause runtime incompatibilities. Either upgrade all TipTap packages together, or pin the table extensions to the same version the rest of the monorepo uses.