From 2eb7ece577c2938f86f4ade198464bcac76a729c Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Thu, 10 Sep 2026 12:40:58 +0300 Subject: [PATCH] [BNE-148] Create work package link block via hash command https://community.openproject.org/wp/BNE-148 --- README.md | 14 +- lib/components/HashMenu/HashWpMenu.tsx | 2 +- .../HashMenu/OpenProjectHashMenu.tsx | 19 ++ lib/components/HashMenu/editorUtils.ts | 132 +++++----- lib/components/HashMenu/hashTrigger.ts | 67 +++++ lib/components/HashMenu/index.ts | 3 +- lib/components/HashMenu/useHashWpMenu.ts | 15 +- lib/index.ts | 2 +- lib/utils/inlineChipActions.ts | 8 +- lib/utils/suggestionMenu.ts | 7 + src/App.tsx | 10 +- test/helpers/editorHelpers.ts | 32 ++- test/helpers/headlessEditor.ts | 91 +++++++ test/helpers/renderEditor.tsx | 10 +- .../components/HashMenu/editorUtils.test.ts | 153 ++++++++++++ .../components/HashMenu/hashTrigger.test.ts | 80 ++++++ test/lib/components/hashMenu.test.ts | 234 ------------------ ...ockWorkPackageHashTrigger.browser.test.tsx | 83 +++++++ .../integration/editor.undo.browser.test.tsx | 11 + test/lib/utils/inlineChipActions.test.ts | 129 +++------- 20 files changed, 665 insertions(+), 437 deletions(-) create mode 100644 lib/components/HashMenu/OpenProjectHashMenu.tsx create mode 100644 lib/components/HashMenu/hashTrigger.ts create mode 100644 lib/utils/suggestionMenu.ts create mode 100644 test/helpers/headlessEditor.ts create mode 100644 test/lib/components/HashMenu/editorUtils.test.ts create mode 100644 test/lib/components/HashMenu/hashTrigger.test.ts delete mode 100644 test/lib/components/hashMenu.test.ts create mode 100644 test/lib/components/integration/blockWorkPackageHashTrigger.browser.test.tsx diff --git a/README.md b/README.md index 9eaa4f3a..46cf76d5 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Create the editor: const editor = useCreateBlockNote({ schema }); ``` -Build the slash and hash menus: +Build the slash menu: ```tsx const getSlashItems = useCallback( @@ -96,8 +96,6 @@ const getSlashItems = useCallback( ), [editor] ); - -const { getHashItems, HashWpMenu } = useHashWpMenu(editor); ``` `OpenProjectFormattingToolbar` is BlockNote's formatting toolbar with everything this library adds to it - currently a "Create work package" button on a text selection: the selected text names the work package, and the rich link for it takes the text's place in the document once it exists - a card where the selection hands over whole paragraphs, an inline chip within a line of text. It is offered for a selection that reads as a subject, so not for one that already holds a work package. Render it beside the editor and turn off the toolbar BlockNote brings itself, with `formattingToolbar={false}`. @@ -106,6 +104,10 @@ Where the host has toolbar items of its own to place, compose the toolbar by han `getOpenProjectSlashMenuItems` returns every item this library offers: linking an existing work package, and creating a new one through a form and linking it. Both insert a card on an empty line and an inline chip within a line of text. +`OpenProjectHashMenu` is the `#` command for linking a work package: typing anything after the hash searches, and how many hashes stand before the query decides what the picked work package becomes - `#` a chip carrying its ID, `##` one that adds the type and the subject, `###` one that adds the status as well, and `####` a card of its own. Invoked within a line, `####` breaks the line around the card, the way resizing an inline link into a card does. A longer run of hashes is not a command and stays on screen as typed. Render it beside the editor. + +Where the host builds the `#` menu itself, `useHashWpMenu(editor)` hands back the pieces instead: `getHashItems`, `HashWpMenu` and `shouldOpenHashMenu`. All three belong on the `SuggestionMenuController` - the last one is what keeps a run longer than `####` from searching. + The create form is built from the work package form endpoint of the API, so the attributes it asks for - and their labels - come from the OpenProject instance: subject, project, type, assignee, plus every other attribute the selected type requires. Attributes the API already has a default for (status and priority, for instance) are left to it and are not shown, required or not. Include everything in a `BlockNoteView`: @@ -118,11 +120,7 @@ return ( triggerCharacter="/" getItems={getSlashItems} /> - + ); ``` diff --git a/lib/components/HashMenu/HashWpMenu.tsx b/lib/components/HashMenu/HashWpMenu.tsx index dfecc1ed..6c3b48a1 100644 --- a/lib/components/HashMenu/HashWpMenu.tsx +++ b/lib/components/HashMenu/HashWpMenu.tsx @@ -25,7 +25,7 @@ import { useTranslation } from 'react-i18next'; * a flex item won't actually clip / scroll unless its min-height is * explicitly zero. (Harmless on non-flex layouts.) */ -const Menu = styled.div.attrs({ className: 'op-bn-hash-menu' })` +const Menu = styled.div.attrs({ className: 'op-bn-hash-menu', 'data-testid': 'hash-menu' })` ${defaultWpVariables} ${menuSurfaceStyles} box-shadow: var(--bn-shadow-medium); diff --git a/lib/components/HashMenu/OpenProjectHashMenu.tsx b/lib/components/HashMenu/OpenProjectHashMenu.tsx new file mode 100644 index 00000000..1941f85e --- /dev/null +++ b/lib/components/HashMenu/OpenProjectHashMenu.tsx @@ -0,0 +1,19 @@ +import { SuggestionMenuController, useBlockNoteEditor } from '@blocknote/react'; +import { canOpenHashMenu } from './hashTrigger'; +import { useHashWpMenu } from './useHashWpMenu'; + +const HASH_TRIGGER_CHARACTER = '#'; + +export const OpenProjectHashMenu = () => { + const editor = useBlockNoteEditor(); + const { getHashItems, HashWpMenu } = useHashWpMenu(editor); + + return ( + + ); +}; diff --git a/lib/components/HashMenu/editorUtils.ts b/lib/components/HashMenu/editorUtils.ts index 19990343..3fe990d5 100644 --- a/lib/components/HashMenu/editorUtils.ts +++ b/lib/components/HashMenu/editorUtils.ts @@ -1,70 +1,70 @@ import type { AnyEditor } from '../../editorTypes'; -import { chipContentOf } from '../../utils/inlineChipActions'; -import type { InlineWpSize } from '../WorkPackage/types'; import type { WorkPackage } from '../../openProjectTypes'; +import { chipContentOf, promoteInlineChipToBlockAt } from '../../utils/inlineChipActions'; +import { INLINE_WP_TYPE } from '../../utils/nodeTypes'; +import type { BlockWpSize, InlineWpSize } from '../WorkPackage/types'; +import type { HashTarget } from './hashTrigger'; -interface RawNode { - type:string; - text?:string; - props?:Record; - [key:string]:unknown; -} - -/** - * Determines the inline chip size based on how many `#` characters - * the user typed before the search query in the current block. - * - * #query -> "xxs" (ID only) - * ##query -> "xs" (ID + Type + Subject) - * ###query -> "s" (ID + Type + Status + Subject) - */ -export function getSizeFromCurrentBlock(editor:AnyEditor):InlineWpSize { - const block = editor.getTextCursorPosition()?.block; - if (!block) return 'xxs'; +// Discarded right away, so the cheapest chip to render will do. +const SPLIT_MARKER_SIZE:InlineWpSize = 'xxs'; - const content = (block.content ?? []) as RawNode[]; - let lastHashCount:number | null = null; - - for (const node of content) { - if (node.type !== 'text') continue; - const text = node.text ?? ''; - const matches = [...text.matchAll(/#+/g)]; - if (matches.length > 0) { - lastHashCount = matches[matches.length - 1][0].length; - } - } - - if (lastHashCount === null) return 'xxs'; - if (lastHashCount >= 3) return 's'; - if (lastHashCount === 2) return 'xs'; - return 'xxs'; +export function insertWpForTarget( + editor:AnyEditor, + workPackage:WorkPackage, + target:HashTarget +):void { + if (target.kind === 'inline') insertWpChip(editor, workPackage, target.size); + else if (target.kind === 'block') insertWpBlockCard(editor, workPackage, target.size); } /** * Inserts a chip followed by a trailing space at the cursor, then removes any * leftover trigger hashes immediately before it. * - * The chip and its trailing space are inserted together; `insertInlineContent` - * leaves the cursor directly after the space, which is exactly where we want it. - * We deliberately avoid placing the cursor with a separate selection - * transaction — under real-time collaboration (Yjs/Hocuspocus) any selection - * change dispatched after the menu insertion leaves the editor in a state where - * the following native keyboard input (e.g. Backspace) is silently dropped. - * Relying on the natural post-insertion cursor sidesteps that entirely. + * `insertInlineContent` leaves the cursor directly after the space, which is + * exactly where we want it. We deliberately avoid placing the cursor with a + * separate selection transaction — under real-time collaboration + * (Yjs/Hocuspocus) any selection change dispatched after the menu insertion + * leaves the editor in a state where the following native keyboard input + * (e.g. Backspace) is silently dropped. */ -export function insertWpChip(editor:AnyEditor, wp:WorkPackage, size:InlineWpSize):void { - (editor.insertInlineContent as (content:unknown[]) => void)([ - chipContentOf(wp, size), - { type: 'text', text: ' ', styles: {} }, - ]); - - // The chip (nodeSize 1) and its trailing space (length 1) were just inserted; - // insertInlineContent leaves the cursor right after the space. - const chipPosition = editor.prosemirrorState.selection.from - 2; - removeTriggerBeforeChip(editor, chipPosition); +export function insertWpChip( + editor:AnyEditor, + workPackage:WorkPackage, + size:InlineWpSize +):void { + insertChipAtCursor(editor, workPackage, size, { withTrailingSpace: true }); editor.focus(); } +function insertWpBlockCard( + editor:AnyEditor, + workPackage:WorkPackage, + size:BlockWpSize +):void { + editor.transact(() => { + const chipPosition = insertChipAtCursor(editor, workPackage, SPLIT_MARKER_SIZE, { + withTrailingSpace: false, + }); + promoteInlineChipToBlockAt(editor, chipPosition, size); + }); +} + +function insertChipAtCursor( + editor:AnyEditor, + workPackage:WorkPackage, + size:InlineWpSize, + { withTrailingSpace }:{ withTrailingSpace:boolean } +):number { + const content:unknown[] = [chipContentOf(workPackage, size)]; + if (withTrailingSpace) content.push({ type: 'text', text: ' ', styles: {} }); + + const chipPosition = editor.transact((tr) => tr.selection.from); + (editor.insertInlineContent as (content:unknown[]) => void)(content); + + return removeTriggerBeforeChip(editor, chipPosition); +} + export function restoreHashQuery(editor:AnyEditor, query:string):void { (editor.insertInlineContent as (content:unknown[]) => void)([ { type: 'text', text: `#${query}`, styles: {} }, @@ -72,23 +72,23 @@ export function restoreHashQuery(editor:AnyEditor, query:string):void { } /** - * Removes the leftover trigger hashes (`#`/`##`) that BlockNote's suggestion menu - * leaves directly before the chip for `##`/`###` triggers. + * Removes the leftover trigger hashes (`#`/`##`/`###`) that BlockNote's + * suggestion menu leaves directly before the chip for `##`-and-longer triggers, + * and returns the chip's position afterwards. */ -export function removeTriggerBeforeChip(editor:AnyEditor, chipPosition:number):void { - const { doc } = editor.prosemirrorState; - - const chipNode = doc.nodeAt(chipPosition); - if (chipNode?.type.name !== 'openProjectWorkPackageInline') return; +export function removeTriggerBeforeChip(editor:AnyEditor, chipPosition:number):number { + return editor.transact((tr) => { + const chipNode = tr.doc.nodeAt(chipPosition); + if (chipNode?.type.name !== INLINE_WP_TYPE) return chipPosition; - const nodeBeforeChip = doc.resolve(chipPosition).nodeBefore; - if (!nodeBeforeChip?.isText || nodeBeforeChip.text == null) return; + const nodeBeforeChip = tr.doc.resolve(chipPosition).nodeBefore; + if (!nodeBeforeChip?.isText || nodeBeforeChip.text == null) return chipPosition; - const match = /#+$/.exec(nodeBeforeChip.text); - if (!match) return; + const match = /#+$/.exec(nodeBeforeChip.text); + if (!match) return chipPosition; - const triggerStart = chipPosition - (nodeBeforeChip.text.length - match.index); - editor.transact((tr) => { + const triggerStart = chipPosition - (nodeBeforeChip.text.length - match.index); tr.delete(triggerStart, chipPosition); + return triggerStart; }); } diff --git a/lib/components/HashMenu/hashTrigger.ts b/lib/components/HashMenu/hashTrigger.ts new file mode 100644 index 00000000..a4cbcdbd --- /dev/null +++ b/lib/components/HashMenu/hashTrigger.ts @@ -0,0 +1,67 @@ +import type { Node as ProsemirrorNode } from 'prosemirror-model'; +import type { Transaction } from 'prosemirror-state'; +import type { AnyEditor } from '../../editorTypes'; +import type { BlockWpSize, InlineWpSize } from '../WorkPackage/types'; + +/** + * What the hash menu inserts for the work package the user picks. How many `#` + * characters precede the search query decides it: + * + * #query -> inline chip, "xxs" (ID only) + * ##query -> inline chip, "xs" (ID + type + subject) + * ###query -> inline chip, "s" (ID + type + status + subject) + * ####query -> block card, "m" + * #####... -> nothing; the hashes stay on screen as typed + */ +export type HashTarget = + | { kind:'inline'; size:InlineWpSize } + | { kind:'block'; size:BlockWpSize } + | { kind:'none' }; + +const TARGET_BY_HASH_COUNT:readonly HashTarget[] = [ + { kind: 'inline', size: 'xxs' }, + { kind: 'inline', size: 'xs' }, + { kind: 'inline', size: 's' }, + { kind: 'block', size: 'm' }, +]; + +const NO_TARGET:HashTarget = { kind: 'none' }; + +export const MAX_TRIGGER_HASHES = TARGET_BY_HASH_COUNT.length; + +export function hashTargetFor(editor:AnyEditor, query:string):HashTarget { + const { doc, selection } = editor.prosemirrorState; + const queryStart = Math.max(0, selection.from - query.length); + const hashCount = trailingHashes(doc, queryStart) + leadingHashes(query); + + return TARGET_BY_HASH_COUNT[hashCount - 1] ?? NO_TARGET; +} + +/** + * Runs before the typed `#` is inserted, so the run standing in the document is + * one shorter than the one the user ends up with: at `###` this opens the menu + * that `####` needs, at `####` it refuses the fifth hash. + */ +export function canOpenHashMenu(transaction:Transaction):boolean { + return trailingHashes(transaction.doc, transaction.selection.from) < MAX_TRIGGER_HASHES; +} + +// Keeps hash runs on either side of an atom node (an inline chip) apart. +const LEAF_PLACEHOLDER = '\uFFFC'; + +function trailingHashes(doc:ProsemirrorNode, position:number):number { + const resolved = doc.resolve(position); + if (!resolved.parent.isTextblock) return 0; + + const textBefore = resolved.parent.textBetween( + 0, + resolved.parentOffset, + undefined, + LEAF_PLACEHOLDER + ); + return /#*$/.exec(textBefore)?.[0].length ?? 0; +} + +function leadingHashes(query:string):number { + return /^#*/.exec(query)?.[0].length ?? 0; +} diff --git a/lib/components/HashMenu/index.ts b/lib/components/HashMenu/index.ts index b946ae0c..bc02f640 100644 --- a/lib/components/HashMenu/index.ts +++ b/lib/components/HashMenu/index.ts @@ -1,4 +1,5 @@ +export { OpenProjectHashMenu } from './OpenProjectHashMenu'; export { createHashWpMenuComponent } from './HashWpMenu'; export { useHashWpMenu } from './useHashWpMenu'; export { isHashWpQuery } from './types'; -export type { HashMenuItem, HashSearchState } from './types'; \ No newline at end of file +export type { HashMenuItem, HashSearchState } from './types'; diff --git a/lib/components/HashMenu/useHashWpMenu.ts b/lib/components/HashMenu/useHashWpMenu.ts index 8988b6bd..44b71c2e 100644 --- a/lib/components/HashMenu/useHashWpMenu.ts +++ b/lib/components/HashMenu/useHashWpMenu.ts @@ -2,10 +2,12 @@ import { useCallback, useMemo, useRef } from 'react'; import { MAX_SEARCH_RESULTS, useWorkPackageSearch } from '../../hooks/useWorkPackageSearch'; import { createHashWpMenuComponent } from './HashWpMenu'; import { isHashWpQuery } from './types'; -import { getSizeFromCurrentBlock, insertWpChip, restoreHashQuery } from './editorUtils'; +import { canOpenHashMenu, hashTargetFor } from './hashTrigger'; +import { insertWpForTarget, restoreHashQuery } from './editorUtils'; import type { HashMenuItem, HashSearchState } from './types'; import type { AnyEditor } from '../../editorTypes'; import { cacheColors } from '../../services/colors'; +import { closeSuggestionMenu } from '../../utils/suggestionMenu'; export function useHashWpMenu(editor:AnyEditor) { const { search } = useWorkPackageSearch(); @@ -26,6 +28,12 @@ export function useHashWpMenu(editor:AnyEditor) { async (query:string):Promise => { latestQueryRef.current = query; + const target = hashTargetFor(editor, query); + if (target.kind === 'none') { + closeSuggestionMenu(editor); + return []; + } + if (!isHashWpQuery(query)) { searchStateRef.current = { query, results: [], error: null }; return placeholderItems(query); @@ -41,11 +49,10 @@ export function useHashWpMenu(editor:AnyEditor) { if (results.length === 0) return placeholderItems(query); - const size = getSizeFromCurrentBlock(editor); return results.map((wp) => ({ title: query, onItemClick: () => { - insertWpChip(editor, wp, size); + insertWpForTarget(editor, wp, target); }, })); } catch (error) { @@ -70,5 +77,5 @@ export function useHashWpMenu(editor:AnyEditor) { ); /* eslint-enable react-hooks/refs */ - return { getHashItems, HashWpMenu }; + return { getHashItems, HashWpMenu, shouldOpenHashMenu: canOpenHashMenu }; } diff --git a/lib/index.ts b/lib/index.ts index 34bf5cc0..2d7b89ff 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -10,7 +10,7 @@ export { export type { CreateWorkPackageFromSelection } from './components'; export { initializeOpBlockNoteExtensions } from './initialize'; export type { InlineWpSize, BlockWpSize, WpSize } from './components/WorkPackage/types'; -export { createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from './components/HashMenu'; +export { OpenProjectHashMenu, createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from './components/HashMenu'; export type { HashMenuItem } from './components/HashMenu'; export { useWorkPackageSearch } from './hooks/useWorkPackageSearch'; export type { WorkPackage } from './openProjectTypes'; diff --git a/lib/utils/inlineChipActions.ts b/lib/utils/inlineChipActions.ts index 64cfe69b..6baec18e 100644 --- a/lib/utils/inlineChipActions.ts +++ b/lib/utils/inlineChipActions.ts @@ -85,8 +85,10 @@ export function chipContentOf(workPackage:WorkPackage, size:InlineWpSize):ChipCo } function chipAt(editor:AnyEditor, position:number):ProsemirrorNode | null { - const node = editor.prosemirrorState.doc.nodeAt(position); - return node?.type.name === INLINE_WP_TYPE ? node : null; + return editor.transact((tr) => { + const node = tr.doc.nodeAt(position); + return node?.type.name === INLINE_WP_TYPE ? node : null; + }); } export function selectInlineChipAt(editor:AnyEditor, position:number):void { @@ -123,7 +125,7 @@ export function promoteInlineChipToBlockAt( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const displayId = ((node.attrs.displayId || String(node.attrs.wpid)) as string); - const $position = editor.prosemirrorState.doc.resolve(position); + const $position = editor.transact((tr) => tr.doc.resolve(position)); let blockId:string | undefined; for (let depth = $position.depth; depth > 0; depth--) { diff --git a/lib/utils/suggestionMenu.ts b/lib/utils/suggestionMenu.ts new file mode 100644 index 00000000..1e57b1d0 --- /dev/null +++ b/lib/utils/suggestionMenu.ts @@ -0,0 +1,7 @@ +import { SuggestionMenu } from '@blocknote/core/extensions'; +import type { AnyEditor } from '../editorTypes'; + +/** Dismisses the open suggestion menu, leaving the typed text untouched. */ +export function closeSuggestionMenu(editor:AnyEditor):void { + editor.getExtension(SuggestionMenu)?.closeMenu(); +} diff --git a/src/App.tsx b/src/App.tsx index 1a2c8087..4e3be69b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ import { openProjectWorkPackageInlineSpec, getOpenProjectSlashMenuItems, OpenProjectFormattingToolbar, - useHashWpMenu, + OpenProjectHashMenu, } from '../lib'; import './fetchOverride'; @@ -52,8 +52,6 @@ export default function App() { [editor] ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - const { getHashItems, HashWpMenu } = useHashWpMenu(editor as any); return ( @@ -63,11 +61,7 @@ export default function App() { getItems={getSlashItems} /> - + ); } diff --git a/test/helpers/editorHelpers.ts b/test/helpers/editorHelpers.ts index 1e95a47f..56f41004 100644 --- a/test/helpers/editorHelpers.ts +++ b/test/helpers/editorHelpers.ts @@ -33,12 +33,33 @@ export async function insertInlineWorkPackageViaSlashMenu(searchTerm='Fix', resu await expect.element(page.getByText(resultTerm)).toBeVisible(); } -export async function insertInlineWorkPackageViaHash(hashes:string) { - await openEditorAndType(`${hashes}Fix`); +export const BLOCK_CARD_HASHES = '####'; + +async function pickHashMenuResult() { await expect.element(page.getByText('Fix login bug')).toBeVisible(); await userEvent.click(page.getByText('Fix login bug')); } +async function insertWorkPackageViaHash(trigger:string) { + await openEditorAndType(`${trigger}Fix`); + await pickHashMenuResult(); +} + +export async function insertInlineWorkPackageViaHash(hashes:string) { + await insertWorkPackageViaHash(hashes); +} + +export async function insertBlockWorkPackageViaHash(textBefore = '') { + await insertWorkPackageViaHash(`${textBefore}${BLOCK_CARD_HASHES}`); + await expect.element(page.getByTestId('block-card')).toBeVisible(); +} + +export async function insertBlockWorkPackageViaHashAtCursor() { + await userEvent.keyboard(`${BLOCK_CARD_HASHES}Fix`); + await pickHashMenuResult(); + await expect.element(page.getByTestId('block-card')).toBeVisible(); +} + // Inline chip - popover & size menu export async function openInlineWorkPackagePopover(displayId = '#123') { await userEvent.click(page.getByText(displayId).first()); @@ -94,11 +115,6 @@ export async function convertToCompactCard(displayId = '#123') { } export async function insertInlineWorkPackageViaHashWithTextBefore(before:string) { - const editorEl = page.getByRole('textbox'); - await expect.element(editorEl).toBeVisible(); - await userEvent.click(editorEl); - await userEvent.type(editorEl, `${before}#Fix`); - await expect.element(page.getByText('Fix login bug')).toBeVisible(); - await userEvent.click(page.getByText('Fix login bug')); + await insertWorkPackageViaHash(`${before}#`); await expect.element(page.getByText('#123')).toBeVisible(); } \ No newline at end of file diff --git a/test/helpers/headlessEditor.ts b/test/helpers/headlessEditor.ts new file mode 100644 index 00000000..bbd5506f --- /dev/null +++ b/test/helpers/headlessEditor.ts @@ -0,0 +1,91 @@ +import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core'; +import { TextSelection } from 'prosemirror-state'; +import { + openProjectWorkPackageBlockSpec, + openProjectWorkPackageInlineSpec, +} from '../../lib'; +import type { AnyEditor } from '../../lib/editorTypes'; +import { INLINE_WP_TYPE } from '../../lib/utils/nodeTypes'; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(), + }, + inlineContentSpecs: { + openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, + }, +}); + +export interface InlineNode { + type:string; + text?:string; + props?:Record; +} + +export function createHeadlessEditorWithBlocks(blocks:unknown[]) { + return BlockNoteEditor.create({ schema, initialContent: blocks as never }); +} + +export function createHeadlessEditor(content:unknown[] | string) { + return createHeadlessEditorWithBlocks([{ type: 'paragraph', content }]); +} + +export function createHeadlessEditorWithText(text:string) { + const editor = createHeadlessEditor(text); + editor.setTextCursorPosition(editor.document[0], 'end'); + return editor; +} + +export function chipContent(wpid:string, size = 's') { + return { type: INLINE_WP_TYPE, props: { wpid, size } }; +} + +export function textContent(value:string) { + return { type: 'text', text: value, styles: {} }; +} + +export function blockContent(editor:AnyEditor, blockIndex = 0):InlineNode[] { + return (editor.document[blockIndex]?.content ?? []) as InlineNode[]; +} + +export function blockTypes(editor:AnyEditor):string[] { + return editor.document.map((block) => block.type as string); +} + +export function blockText(editor:AnyEditor, blockIndex = 0):string { + return blockContent(editor, blockIndex).map((node) => node.text ?? '').join(''); +} + +export function nthChipPosition(editor:AnyEditor, ordinal = 0):number { + let seen = -1; + let found = -1; + editor.prosemirrorState.doc.descendants((node, position) => { + if (found !== -1) return false; + if (node.type.name === INLINE_WP_TYPE) { + seen += 1; + if (seen === ordinal) { + found = position; + return false; + } + } + return true; + }); + return found; +} + +export function placeCursorAfterText(editor:AnyEditor, marker:string):void { + let target = -1; + editor.prosemirrorState.doc.descendants((node, position) => { + if (target !== -1) return false; + if (node.isText && node.text?.includes(marker)) { + target = position + node.text.indexOf(marker) + marker.length; + return false; + } + return true; + }); + if (target === -1) throw new Error(`"${marker}" is not in the document`); + + editor.transact((tr) => { + tr.setSelection(TextSelection.create(tr.doc, target)); + }); +} diff --git a/test/helpers/renderEditor.tsx b/test/helpers/renderEditor.tsx index bb2ade9e..620ad597 100644 --- a/test/helpers/renderEditor.tsx +++ b/test/helpers/renderEditor.tsx @@ -11,7 +11,7 @@ import { openProjectWorkPackageInlineSpec, getOpenProjectSlashMenuItems, OpenProjectFormattingToolbar, - useHashWpMenu, + OpenProjectHashMenu, ShadowDomWrapper, } from '../../lib'; @@ -42,8 +42,6 @@ function Editor({ onEditor, schema, editable = true, initialContent }:EditorOpti }); onEditor?.(editor); - const { getHashItems, HashWpMenu } = useHashWpMenu(editor as any); - const getSlashItems = useCallback( async (query:string) => filterSuggestionItems( @@ -61,11 +59,7 @@ function Editor({ onEditor, schema, editable = true, initialContent }:EditorOpti - + ); diff --git a/test/lib/components/HashMenu/editorUtils.test.ts b/test/lib/components/HashMenu/editorUtils.test.ts new file mode 100644 index 00000000..8057e71c --- /dev/null +++ b/test/lib/components/HashMenu/editorUtils.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { + insertWpChip, + insertWpForTarget, + removeTriggerBeforeChip, +} from '../../../../lib/components/HashMenu/editorUtils'; +import { INLINE_WP_TYPE, BLOCK_WP_TYPE } from '../../../../lib/utils/nodeTypes'; +import type { WorkPackage } from '../../../../lib/openProjectTypes'; +import { + blockContent, + blockText, + blockTypes, + chipContent, + createHeadlessEditor, + createHeadlessEditorWithText, + nthChipPosition, + placeCursorAfterText, + textContent, +} from '../../../helpers/headlessEditor'; + +const workPackage = { id: 1, displayId: '1', subject: 'Fix bug' } as unknown as WorkPackage; + +describe('removeTriggerBeforeChip', () => { + it('removes a single leftover # before the chip', () => { + const editor = createHeadlessEditor([textContent('Hello #'), chipContent('1', 'xxs')]); + + removeTriggerBeforeChip(editor, nthChipPosition(editor)); + + expect(blockText(editor)).toBe('Hello '); + }); + + it('removes multiple leftover hashes (##, ###) before the chip', () => { + const editor = createHeadlessEditor([textContent('Hello ###'), chipContent('1')]); + + removeTriggerBeforeChip(editor, nthChipPosition(editor)); + + expect(blockText(editor)).toBe('Hello '); + }); + + it('returns the position the chip moved to', () => { + const editor = createHeadlessEditor([textContent('Hello ###'), chipContent('1')]); + const positionBefore = nthChipPosition(editor); + + const positionAfter = removeTriggerBeforeChip(editor, positionBefore); + + expect(positionAfter).toBe(positionBefore - 3); + expect(positionAfter).toBe(nthChipPosition(editor)); + }); + + it('leaves an earlier # in the line alone — removes only the trailing hashes', () => { + const editor = createHeadlessEditor([ + textContent('Pre #one #two #'), + chipContent('1', 'xxs'), + ]); + + removeTriggerBeforeChip(editor, nthChipPosition(editor)); + + expect(blockText(editor)).toBe('Pre #one #two '); + }); + + it('preserves a preceding #word — does not over-delete an existing hash like "#42"', () => { + const editor = createHeadlessEditor([textContent('See PR #42'), chipContent('1', 'xxs')]); + + removeTriggerBeforeChip(editor, nthChipPosition(editor)); + + expect(blockText(editor)).toBe('See PR #42'); + }); + + it('removes the previous text node entirely when only # remains', () => { + const editor = createHeadlessEditor([textContent('#'), chipContent('1', 'xxs')]); + + removeTriggerBeforeChip(editor, nthChipPosition(editor)); + + expect(blockContent(editor)[0].type).toBe(INLINE_WP_TYPE); + }); + + it('does nothing if the given position is not a chip', () => { + const editor = createHeadlessEditorWithText('Hello #foo'); + const before = JSON.stringify(editor.document); + + expect(removeTriggerBeforeChip(editor, 0)).toBe(0); + expect(JSON.stringify(editor.document)).toBe(before); + }); + + it('does nothing when there is no preceding text node', () => { + const editor = createHeadlessEditor([chipContent('1', 'xxs')]); + const before = JSON.stringify(editor.document); + + removeTriggerBeforeChip(editor, nthChipPosition(editor)); + + expect(JSON.stringify(editor.document)).toBe(before); + }); +}); + +describe('insertWpChip', () => { + it('inserts a chip with the work package ID and size, followed by a space', () => { + const editor = createHeadlessEditorWithText('test '); + + insertWpChip(editor, workPackage, 'xxs'); + + expect(blockContent(editor)).toMatchObject([ + { type: 'text', text: 'test ' }, + { type: INLINE_WP_TYPE, props: { wpid: '1', size: 'xxs' } }, + { type: 'text', text: ' ' }, + ]); + }); +}); + +describe('insertWpForTarget', () => { + it('inserts an inline chip of the target size, replacing the trigger hashes', () => { + const editor = createHeadlessEditorWithText('##'); + + insertWpForTarget(editor, workPackage, { kind: 'inline', size: 'xs' }); + + expect(blockTypes(editor)).toEqual(['paragraph']); + expect(blockContent(editor)).toMatchObject([ + { type: INLINE_WP_TYPE, props: { wpid: '1', size: 'xs' } }, + { type: 'text', text: ' ' }, + ]); + }); + + it('inserts a block card, replacing the trigger hashes and leaving no chip', () => { + const editor = createHeadlessEditorWithText('###'); + + insertWpForTarget(editor, workPackage, { kind: 'block', size: 'm' }); + + expect(blockTypes(editor)).toEqual([BLOCK_WP_TYPE, 'paragraph']); + expect(editor.document[0].props).toMatchObject({ wpid: 1, size: 'm', displayId: '1' }); + expect(nthChipPosition(editor)).toBe(-1); + }); + + it('moves text that follows the trigger below the card', () => { + const editor = createHeadlessEditor('### World'); + placeCursorAfterText(editor, '###'); + + insertWpForTarget(editor, workPackage, { kind: 'block', size: 'm' }); + + expect(blockTypes(editor)).toEqual([BLOCK_WP_TYPE, 'paragraph']); + expect(blockText(editor, 1)).toBe(' World'); + }); + + it('splits the line, keeping the text before and after the trigger around the card', () => { + const editor = createHeadlessEditor('Hello ### World'); + placeCursorAfterText(editor, '###'); + + insertWpForTarget(editor, workPackage, { kind: 'block', size: 'm' }); + + expect(blockTypes(editor)).toEqual(['paragraph', BLOCK_WP_TYPE, 'paragraph']); + expect(blockText(editor, 0)).toBe('Hello '); + expect(blockText(editor, 2)).toBe(' World'); + }); +}); diff --git a/test/lib/components/HashMenu/hashTrigger.test.ts b/test/lib/components/HashMenu/hashTrigger.test.ts new file mode 100644 index 00000000..b42ef836 --- /dev/null +++ b/test/lib/components/HashMenu/hashTrigger.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { + canOpenHashMenu, + hashTargetFor, + MAX_TRIGGER_HASHES, +} from '../../../../lib/components/HashMenu/hashTrigger'; +import type { HashTarget } from '../../../../lib/components/HashMenu/hashTrigger'; +import { + chipContent, + createHeadlessEditor, + createHeadlessEditorWithText, + textContent, +} from '../../../helpers/headlessEditor'; + +function targetFor(blockText:string, query:string):HashTarget { + return hashTargetFor(createHeadlessEditorWithText(blockText), query); +} + +describe('hashTargetFor', () => { + it('maps a hash run to an inline chip size', () => { + expect(targetFor('#bug', 'bug')).toEqual({ kind: 'inline', size: 'xxs' }); + expect(targetFor('##bug', 'bug')).toEqual({ kind: 'inline', size: 'xs' }); + expect(targetFor('###bug', 'bug')).toEqual({ kind: 'inline', size: 's' }); + }); + + it('maps four hashes to a block card', () => { + expect(targetFor('####bug', 'bug')).toEqual({ kind: 'block', size: 'm' }); + }); + + it('resolves to none for a longer run — the hashes stay literal', () => { + expect(targetFor('#####bug', '#bug')).toEqual({ kind: 'none' }); + expect(targetFor('######bug', '##bug')).toEqual({ kind: 'none' }); + }); + + it('resolves to none when no hash precedes the query', () => { + expect(targetFor('bug', 'bug')).toEqual({ kind: 'none' }); + }); + + it('counts only the run directly before the query', () => { + expect(targetFor('Hello ####bug', 'bug')).toEqual({ kind: 'block', size: 'm' }); + expect(targetFor('#42 ##bug', 'bug')).toEqual({ kind: 'inline', size: 'xs' }); + expect(targetFor('#### #bug', 'bug')).toEqual({ kind: 'inline', size: 'xxs' }); + }); + + it('does not join runs separated by an inline chip', () => { + const editor = createHeadlessEditor([ + textContent('##'), + chipContent('1', 'xxs'), + textContent('##bug'), + ]); + editor.setTextCursorPosition(editor.document[0], 'end'); + + expect(hashTargetFor(editor, 'bug')).toEqual({ kind: 'inline', size: 'xs' }); + }); +}); + +describe('canOpenHashMenu', () => { + function canOpenNextHashAfter(standingText:string):boolean { + const editor = createHeadlessEditorWithText(standingText); + return canOpenHashMenu(editor.prosemirrorState.tr); + } + + it('opens for every hash up to and including the one that means a block card', () => { + expect(canOpenNextHashAfter('')).toBe(true); + expect(canOpenNextHashAfter('Hello ')).toBe(true); + expect(canOpenNextHashAfter('#')).toBe(true); + expect(canOpenNextHashAfter('##')).toBe(true); + expect(canOpenNextHashAfter('###')).toBe(true); + }); + + it('refuses the hash that would grow the run past the longest supported one', () => { + expect(canOpenNextHashAfter('#'.repeat(MAX_TRIGGER_HASHES))).toBe(false); + expect(canOpenNextHashAfter('#'.repeat(MAX_TRIGGER_HASHES + 1))).toBe(false); + }); + + it('starts over after a non-hash character', () => { + expect(canOpenNextHashAfter('#### ')).toBe(true); + }); +}); diff --git a/test/lib/components/hashMenu.test.ts b/test/lib/components/hashMenu.test.ts deleted file mode 100644 index 08e3978a..00000000 --- a/test/lib/components/hashMenu.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect } from 'vitest'; -import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core'; -import { - openProjectWorkPackageBlockSpec, - openProjectWorkPackageInlineSpec, -} from '../../../lib'; -import { - getSizeFromCurrentBlock, - insertWpChip, - removeTriggerBeforeChip, -} from '../../../lib/components/HashMenu/editorUtils'; - -const schema = BlockNoteSchema.create().extend({ - blockSpecs: { - openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(), - }, - inlineContentSpecs: { - openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, - }, -}); - -function createTestEditor(text:string) { - const editor = BlockNoteEditor.create({ - schema, - initialContent: [{ type: 'paragraph', content: text }], - }); - - const block = editor.document[0]; - editor.setTextCursorPosition(block, 'end'); - - return editor; -} - -function createEditorWithContent(content:any[]) { - return BlockNoteEditor.create({ - schema, - initialContent: [ - { - type: 'paragraph', - content, - } as any, - ], - }); -} - -// Position of the inline work package chip in the document, or -1 if absent. -function findChipPosition(editor:BlockNoteEditor):number { - let found = -1; - editor.prosemirrorState.doc.descendants((node, position) => { - if (found !== -1) return false; - if (node.type.name === 'openProjectWorkPackageInline') { - found = position; - return false; - } - return true; - }); - return found; -} - -describe('getSizeFromCurrentBlock', () => { - it('returns xxs for #', () => { - const editor = createTestEditor('#foo'); - expect(getSizeFromCurrentBlock(editor as any)).toBe('xxs'); - }); - - it('returns xs for ##', () => { - const editor = createTestEditor('##foo'); - expect(getSizeFromCurrentBlock(editor as any)).toBe('xs'); - }); - - it('returns s for ### or more', () => { - expect(getSizeFromCurrentBlock(createTestEditor('###foo') as any)).toBe('s'); - expect(getSizeFromCurrentBlock(createTestEditor('####foo') as any)).toBe('s'); - }); - - it('returns xxs if no hashes', () => { - const editor = createTestEditor('foo'); - expect(getSizeFromCurrentBlock(editor as any)).toBe('xxs'); - }); - - it('uses the last hash trigger in the block', () => { - const editor = createTestEditor('#first ##bug'); - expect(getSizeFromCurrentBlock(editor as any)).toBe('xs'); - }); -}); - -describe('removeTriggerBeforeChip', () => { - // BlockNote's suggestion menu removes the active `#query`; this function only - // strips the leftover bare trigger hashes it leaves for `##`/`###`. - - it('removes a single leftover # before the chip', () => { - const editor = createEditorWithContent([ - { type: 'text', text: 'Hello #', styles: {} }, - { - type: 'openProjectWorkPackageInline', - props: { wpid: '1', size: 'xxs' }, - }, - ]); - - removeTriggerBeforeChip(editor as any, findChipPosition(editor)); - - const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].text).toBe('Hello '); - }); - - it('removes multiple leftover hashes (##, ###) before the chip', () => { - const editor = createEditorWithContent([ - { type: 'text', text: 'Hello ###', styles: {} }, - { - type: 'openProjectWorkPackageInline', - props: { wpid: '1', size: 's' }, - }, - ]); - - removeTriggerBeforeChip(editor as any, findChipPosition(editor)); - - const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].text).toBe('Hello '); - }); - - it('leaves an earlier # in the line alone — removes only the trailing hashes', () => { - const editor = createEditorWithContent([ - { type: 'text', text: 'Pre #one #two #', styles: {} }, - { - type: 'openProjectWorkPackageInline', - props: { wpid: '1', size: 'xxs' }, - }, - ]); - - removeTriggerBeforeChip(editor as any, findChipPosition(editor)); - - const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].text).toBe('Pre #one #two '); - }); - - it('preserves a preceding #word — does not over-delete an existing hash like "#42"', () => { - // Regression: when the line already contains "#42" and a chip is inserted right - // after it (BlockNote has already removed the real "#query" trigger), the "#42" - // must survive — there are no leftover bare hashes to strip. - const editor = createEditorWithContent([ - { type: 'text', text: 'See PR #42', styles: {} }, - { - type: 'openProjectWorkPackageInline', - props: { wpid: '1', size: 'xxs' }, - }, - ]); - - removeTriggerBeforeChip(editor as any, findChipPosition(editor)); - - const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].text).toBe('See PR #42'); - }); - - it('removes the previous text node entirely when only # remains', () => { - const editor = createEditorWithContent([ - { type: 'text', text: '#', styles: {} }, - { - type: 'openProjectWorkPackageInline', - props: { wpid: '1', size: 'xxs' }, - }, - ]); - - removeTriggerBeforeChip(editor as any, findChipPosition(editor)); - - const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].type).toBe('openProjectWorkPackageInline'); - }); - - it('does nothing if the given position is not a chip', () => { - const editor = createTestEditor('Hello #foo'); - const before = JSON.stringify(editor.document); - - removeTriggerBeforeChip(editor as any, 0); - - expect(JSON.stringify(editor.document)).toBe(before); - }); - - it('does nothing when there is no preceding text node', () => { - const editor = createEditorWithContent([ - { - type: 'openProjectWorkPackageInline', - props: { wpid: '1', size: 'xxs' }, - }, - ]); - - const before = JSON.stringify(editor.document); - removeTriggerBeforeChip(editor as any, findChipPosition(editor)); - - expect(JSON.stringify(editor.document)).toBe(before); - }); -}); - -describe('insertWpChip', () => { - it('inserts a chip with the work package ID and size', () => { - const editor = createTestEditor('test '); - - insertWpChip( - editor as any, - { id: 1, subject: 'Fix bug' } as any, - 'xxs', - ); - - const block = editor.getBlock(editor.document[0].id); - const chip = (block?.content as any[]).find( - (n) => n.type === 'openProjectWorkPackageInline', - ); - - expect(chip).toBeDefined(); - expect(chip.props.wpid).toBe('1'); - expect(chip.props.size).toBe('xxs'); - }); - - it('inserts a trailing space after the chip', () => { - const editor = createTestEditor('test '); - - insertWpChip( - editor as any, - { id: 1, subject: 'Fix bug' } as any, - 'xxs', - ); - - const block = editor.getBlock(editor.document[0].id); - const content = block?.content as any[]; - - const chipIdx = content.findIndex( - (n) => n.type === 'openProjectWorkPackageInline', - ); - const afterChip = content[chipIdx + 1]; - - expect(afterChip?.type).toBe('text'); - expect(afterChip?.text).toBe(' '); - }); -}); diff --git a/test/lib/components/integration/blockWorkPackageHashTrigger.browser.test.tsx b/test/lib/components/integration/blockWorkPackageHashTrigger.browser.test.tsx new file mode 100644 index 00000000..ee9229c7 --- /dev/null +++ b/test/lib/components/integration/blockWorkPackageHashTrigger.browser.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { renderEditor } from '../../../helpers/renderEditor'; +import { + BLOCK_CARD_HASHES, + insertBlockWorkPackageViaHash, + insertBlockWorkPackageViaHashAtCursor, + openEditorAndType, +} from '../../../helpers/editorHelpers'; + +const inlineChip = () => + document.querySelector('[data-inline-content-type="openProjectWorkPackageInline"]'); + +describe('Block card - #### trigger', () => { + it('#### inserts a block card instead of an inline chip', async () => { + renderEditor(); + + await insertBlockWorkPackageViaHash(); + + await expect.element(page.getByText('Fix login bug')).toBeVisible(); + await expect.element(page.getByText('In Progress')).toBeVisible(); + await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible(); + expect(inlineChip()).toBeNull(); + }); + + it('leaves no trigger hashes behind', async () => { + renderEditor(); + + await insertBlockWorkPackageViaHash(); + + await expect.element(page.getByRole('textbox')).not.toHaveTextContent('###'); + }); + + it('moves the card onto its own line when invoked after text', async () => { + renderEditor(); + + await insertBlockWorkPackageViaHash('Hello '); + + await expect.element(page.getByText('Hello')).toBeVisible(); + const helloEl = page.getByText('Hello').element(); + const blockCardEl = page.getByTestId('block-card').element(); + expect(blockCardEl.contains(helloEl)).toBe(false); + expect(helloEl.compareDocumentPosition(blockCardEl) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + }); + + it('splits the sentence it is invoked in around the card', async () => { + renderEditor(); + + await openEditorAndType('Hello World'); + await userEvent.keyboard('{ArrowLeft>6/}'); + await insertBlockWorkPackageViaHashAtCursor(); + + await expect.element(page.getByText('Hello', { exact: true })).toBeVisible(); + await expect.element(page.getByText('World', { exact: true })).toBeVisible(); + await expect.element(page.getByText('Hello World')).not.toBeInTheDocument(); + + const helloEl = page.getByText('Hello', { exact: true }).element(); + const blockCardEl = page.getByTestId('block-card').element(); + const worldEl = page.getByText('World', { exact: true }).element(); + expect(helloEl.compareDocumentPosition(blockCardEl) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + expect(blockCardEl.compareDocumentPosition(worldEl) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + }); + + it('stops searching past #### and leaves the hashes as typed', async () => { + renderEditor(); + + await openEditorAndType(BLOCK_CARD_HASHES); + await expect.element(page.getByTestId('hash-menu')).toBeVisible(); + + await userEvent.keyboard('#'); + await expect.element(page.getByTestId('hash-menu')).not.toBeInTheDocument(); + + await userEvent.keyboard('Fix'); + + await expect.element(page.getByTestId('hash-menu')).not.toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toHaveTextContent('#####Fix'); + expect(inlineChip()).toBeNull(); + expect(document.querySelector('[data-testid="block-card"]')).toBeNull(); + }); +}); diff --git a/test/lib/components/integration/editor.undo.browser.test.tsx b/test/lib/components/integration/editor.undo.browser.test.tsx index d25061e2..65567825 100644 --- a/test/lib/components/integration/editor.undo.browser.test.tsx +++ b/test/lib/components/integration/editor.undo.browser.test.tsx @@ -3,6 +3,7 @@ import { page, userEvent } from 'vitest/browser'; import { renderEditor } from '../../../helpers/renderEditor'; import { insertInlineWorkPackageViaSlashMenu, + insertBlockWorkPackageViaHash, insertInlineWorkPackageViaHash, convertToCompactCard, openInlineWorkPackageSizeMenu, @@ -64,6 +65,16 @@ describe('Undo (Ctrl+Z)', () => { await expect.element(page.getByTestId('op-bn-work-package--type')).not.toBeInTheDocument(); }); + it('undoes block card inserted via #### hash — card is gone', async () => { + renderEditor(); + await insertBlockWorkPackageViaHash(); + + await ctrl_z(); + + await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument(); + await expect.element(page.getByText('#123')).not.toBeInTheDocument(); + }); + it('undoes inline -> block conversion — block card is gone, inline chip is restored', async () => { renderEditor(); await insertInlineWorkPackageViaSlashMenu(); diff --git a/test/lib/utils/inlineChipActions.test.ts b/test/lib/utils/inlineChipActions.test.ts index d098615c..cd52bc87 100644 --- a/test/lib/utils/inlineChipActions.test.ts +++ b/test/lib/utils/inlineChipActions.test.ts @@ -1,75 +1,24 @@ // @vitest-environment jsdom import { describe, it, expect } from 'vitest'; -import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core'; -import { - openProjectWorkPackageBlockSpec, - openProjectWorkPackageInlineSpec, -} from '../../../lib'; import { findPendingInlineChip, removeInlineChipAt, promoteInlineChipToBlockAt, convertBlockToInlineChip, } from '../../../lib/utils/inlineChipActions'; - -const schema = BlockNoteSchema.create().extend({ - blockSpecs: { - openProjectWorkPackageBlock: openProjectWorkPackageBlockSpec(), - }, - inlineContentSpecs: { - openProjectWorkPackageInline: openProjectWorkPackageInlineSpec, - }, -}); - - -function createEditorWithContent(content:any[]) { - return BlockNoteEditor.create({ - schema, - initialContent: [ - { - type: 'paragraph', - content, - - } as any, - ], - }); -} - -function chip(wpid:string, size = 's') { - return { type: 'openProjectWorkPackageInline', props: { wpid, size } }; -} - -function text(value:string) { - return { type: 'text', text: value, styles: {} }; -} - -// Position of the nth (0-based) inline chip in the document. -function nthChipPosition(editor:BlockNoteEditor, ordinal:number):number { - let seen = -1; - let found = -1; - editor.prosemirrorState.doc.descendants((node, position) => { - if (found !== -1) return false; - if (node.type.name === 'openProjectWorkPackageInline') { - seen += 1; - if (seen === ordinal) { - found = position; - return false; - } - } - return true; - }); - return found; -} - - -function blockContent(editor:BlockNoteEditor, blockIndex = 0):any[] { - - return (editor.document[blockIndex]?.content ?? []) as any[]; -} +import { BLOCK_WP_TYPE, INLINE_WP_TYPE } from '../../../lib/utils/nodeTypes'; +import { + blockContent, + chipContent, + createHeadlessEditor, + createHeadlessEditorWithBlocks, + nthChipPosition, + textContent, +} from '../../helpers/headlessEditor'; describe('findPendingInlineChip', () => { it('finds the chip with the given pending wpid', () => { - const editor = createEditorWithContent([text('a'), chip('pending:x'), text('b')]); + const editor = createHeadlessEditor([textContent('a'), chipContent('pending:x'), textContent('b')]); const found = findPendingInlineChip(editor.prosemirrorState.doc, 'pending:x'); @@ -79,13 +28,13 @@ describe('findPendingInlineChip', () => { }); it('returns null when no chip matches', () => { - const editor = createEditorWithContent([text('a'), chip('1')]); + const editor = createHeadlessEditor([textContent('a'), chipContent('1')]); expect(findPendingInlineChip(editor.prosemirrorState.doc, 'pending:x')).toBeNull(); }); it('returns null when the wpid does not carry the pending prefix, even if a matching chip exists', () => { - const editor = createEditorWithContent([chip('1')]); + const editor = createHeadlessEditor([chipContent('1')]); expect(findPendingInlineChip(editor.prosemirrorState.doc, '1')).toBeNull(); }); @@ -93,18 +42,18 @@ describe('findPendingInlineChip', () => { describe('removeInlineChipAt', () => { it('removes exactly the chip at the given position', () => { - const editor = createEditorWithContent([chip('1'), text(' between '), chip('1')]); + const editor = createHeadlessEditor([chipContent('1'), textContent(' between '), chipContent('1')]); removeInlineChipAt(editor, nthChipPosition(editor, 1)); const content = blockContent(editor); - const chips = content.filter((n) => n.type === 'openProjectWorkPackageInline'); + const chips = content.filter((n) => n.type === INLINE_WP_TYPE); expect(chips).toHaveLength(1); - expect(content[0].type).toBe('openProjectWorkPackageInline'); + expect(content[0].type).toBe(INLINE_WP_TYPE); }); it('does nothing when the position does not hold a chip', () => { - const editor = createEditorWithContent([text('abc'), chip('1')]); + const editor = createHeadlessEditor([textContent('abc'), chipContent('1')]); const before = JSON.stringify(editor.document); removeInlineChipAt(editor, 1); @@ -115,58 +64,55 @@ describe('removeInlineChipAt', () => { describe('promoteInlineChipToBlockAt', () => { it('replaces a lone chip with a block card', () => { - const editor = createEditorWithContent([chip('7')]); + const editor = createHeadlessEditor([chipContent('7')]); promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 0), 'm'); const blocks = editor.document; - expect(blocks[0].type).toBe('openProjectWorkPackageBlock'); - - expect((blocks[0].props as any).wpid).toBe(7); - - expect((blocks[0].props as any).size).toBe('m'); + expect(blocks[0].type).toBe(BLOCK_WP_TYPE); + expect(blocks[0].props).toMatchObject({ wpid: 7, size: 'm' }); }); it('keeps content before the chip in the original paragraph', () => { - const editor = createEditorWithContent([text('before '), chip('7')]); + const editor = createHeadlessEditor([textContent('before '), chipContent('7')]); promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 0), 'm'); const blocks = editor.document; expect(blocks[0].type).toBe('paragraph'); expect(blockContent(editor, 0)[0].text).toBe('before '); - expect(blocks[1].type).toBe('openProjectWorkPackageBlock'); + expect(blocks[1].type).toBe(BLOCK_WP_TYPE); }); it('moves content after the chip into a new paragraph below the block', () => { - const editor = createEditorWithContent([text('before '), chip('7'), text(' after')]); + const editor = createHeadlessEditor([textContent('before '), chipContent('7'), textContent(' after')]); promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 0), 'l'); const blocks = editor.document; expect(blocks[0].type).toBe('paragraph'); - expect(blocks[1].type).toBe('openProjectWorkPackageBlock'); + expect(blocks[1].type).toBe(BLOCK_WP_TYPE); expect(blocks[2].type).toBe('paragraph'); expect(blockContent(editor, 2)[0].text).toBe(' after'); }); it('promotes the correct chip when identical chips share the paragraph', () => { - const editor = createEditorWithContent([chip('7'), text(' mid '), chip('7'), text(' tail')]); + const editor = createHeadlessEditor([chipContent('7'), textContent(' mid '), chipContent('7'), textContent(' tail')]); promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 1), 'm'); const blocks = editor.document; // First chip stays inline in the first paragraph. const firstParagraphChips = blockContent(editor, 0).filter( - (n) => n.type === 'openProjectWorkPackageInline' + (n) => n.type === INLINE_WP_TYPE ); expect(firstParagraphChips).toHaveLength(1); - expect(blocks[1].type).toBe('openProjectWorkPackageBlock'); + expect(blocks[1].type).toBe(BLOCK_WP_TYPE); expect(blockContent(editor, 2)[0].text).toBe(' tail'); }); it('does nothing for a pending (non-numeric) wpid', () => { - const editor = createEditorWithContent([chip('pending:x')]); + const editor = createHeadlessEditor([chipContent('pending:x')]); const before = JSON.stringify(editor.document); promoteInlineChipToBlockAt(editor, nthChipPosition(editor, 0), 'm'); @@ -177,25 +123,18 @@ describe('promoteInlineChipToBlockAt', () => { describe('convertBlockToInlineChip', () => { it('replaces the block card with an inline chip paragraph', () => { - const editor = BlockNoteEditor.create({ - schema, - initialContent: [ - { - type: 'openProjectWorkPackageBlock', - props: { wpid: 7, size: 'm', displayId: '7' }, - - } as any, - ], - }); + const editor = createHeadlessEditorWithBlocks([ + { type: BLOCK_WP_TYPE, props: { wpid: 7, size: 'm', displayId: '7' } }, + ]); const blockId = editor.document[0].id; convertBlockToInlineChip(editor, blockId, 7, 'xs'); const blocks = editor.document; expect(blocks[0].type).toBe('paragraph'); - const inserted = blockContent(editor, 0)[0]; - expect(inserted.type).toBe('openProjectWorkPackageInline'); - expect(inserted.props.wpid).toBe('7'); - expect(inserted.props.size).toBe('xs'); + expect(blockContent(editor, 0)[0]).toMatchObject({ + type: INLINE_WP_TYPE, + props: { wpid: '7', size: 'xs' }, + }); }); });