From 89434c4ea867999a171393e6dda950c134c4ace7 Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Tue, 26 May 2026 13:24:27 +0300 Subject: [PATCH 1/2] feat: insert work package as block card when slash menu is used on an empty line --- .../BlockWorkPackage/BlockWorkPackage.tsx | 44 +++++++------------ .../BlockWorkPackage/pendingBlockRegistry.ts | 7 +++ lib/components/SlashMenu.tsx | 32 +++++++++++++- .../editor.slashMenu.browser.test.tsx | 42 ++++++++++++++++++ 4 files changed, 97 insertions(+), 28 deletions(-) create mode 100644 lib/components/BlockWorkPackage/pendingBlockRegistry.ts create mode 100644 test/lib/components/integration/editor.slashMenu.browser.test.tsx diff --git a/lib/components/BlockWorkPackage/BlockWorkPackage.tsx b/lib/components/BlockWorkPackage/BlockWorkPackage.tsx index ff657e8..5264954 100644 --- a/lib/components/BlockWorkPackage/BlockWorkPackage.tsx +++ b/lib/components/BlockWorkPackage/BlockWorkPackage.tsx @@ -14,10 +14,11 @@ import { SearchContainer, SearchLabel } from "../Search/SearchContainer"; import { SearchDropdown } from "../Search/SearchDropdown"; import { defaultWpVariables } from "../WorkPackage/atoms"; import { formatWorkPackageId } from "../../utils/id"; +import { pendingBlockRegistry } from "./pendingBlockRegistry"; -const Block = styled.div.attrs({ className: "op-bn-extensions" })` +const Block = styled.div.attrs({ className: "op-bn-extensions" })<{ $pending?: boolean }>` ${defaultWpVariables} - background-color: var(--op-chip-bg); + background-color: ${({ $pending }) => ($pending ? "transparent" : "var(--op-chip-bg)")}; `; const BlockCardWrapper = styled.div` @@ -29,6 +30,8 @@ interface BlockProps { id: string; props: { wpid?: number; + // Not used for render logic - only written on select so the spec serialises + // data-initialized="true" in toExternalHTML. initialized?: boolean; size?: BlockWpSize; }; @@ -47,7 +50,6 @@ export const BlockWorkPackageComponent = ({ // The hook handles triggering re-renders when data arrives. useColors(); - const [isActive, setIsActive] = useState(false); const [isOptionsOpen, setIsOptionsOpen] = useState(false); const workPackageResult = useWorkPackage(block.props.wpid); @@ -55,32 +57,24 @@ export const BlockWorkPackageComponent = ({ const cardSize: BlockWpSize = block.props.size ?? "m"; + useEffect(() => { + return () => { pendingBlockRegistry.delete(block.id); }; + }, [block.id]); + const handleSelectWorkPackage = (wp: WorkPackage) => { + pendingBlockRegistry.delete(block.id); editor.updateBlock(block, { props: { ...block.props, wpid: wp.id, initialized: true }, }); requestAnimationFrame(() => moveCursorToNextBlock(editor, block.id)); }; - useEffect(() => { - // accessing private tiptap instance until public API is available - const tiptap = (editor as any)._tiptapEditor; - if (!tiptap) return; - - const updateActiveState = () => { - setIsActive(editor.getTextCursorPosition()?.block?.id === block.id); - }; - - tiptap.on("selectionUpdate", updateActiveState); - updateActiveState(); - return () => { tiptap.off("selectionUpdate", updateActiveState); }; - }, [editor, block.id]); - // Close options popover on outside click useEffect(() => { if (!isOptionsOpen) return; const handleClickOutside = (e: MouseEvent) => { - if (cardRef.current && !cardRef.current.contains(e.target as Node)) { + const path = e.composedPath(); + if (cardRef.current && !path.includes(cardRef.current as EventTarget)) { setIsOptionsOpen(false); } }; @@ -132,15 +126,12 @@ export const BlockWorkPackageComponent = ({ editor.removeBlocks([block]); }; - const disableFocus = block.props.initialized && !block.props.wpid; + const isPending = pendingBlockRegistry.has(block.id); return ( - +
- {!block.props.wpid && !block.props.initialized && isActive && ( + {isPending && ( {t("search.label")} @@ -149,9 +140,8 @@ export const BlockWorkPackageComponent = ({ autoFocus onSelect={handleSelectWorkPackage} onCancel={() => { - editor.updateBlock(block, { - props: { ...block.props, initialized: true }, - }); + pendingBlockRegistry.delete(block.id); + editor.removeBlocks([block]); editor.focus(); }} renderItem={(wp) => } diff --git a/lib/components/BlockWorkPackage/pendingBlockRegistry.ts b/lib/components/BlockWorkPackage/pendingBlockRegistry.ts new file mode 100644 index 0000000..a2dc248 --- /dev/null +++ b/lib/components/BlockWorkPackage/pendingBlockRegistry.ts @@ -0,0 +1,7 @@ +const pending = new Set(); + +export const pendingBlockRegistry = { + add: (blockId: string) => pending.add(blockId), + has: (blockId: string) => pending.has(blockId), + delete: (blockId: string) => pending.delete(blockId), +}; diff --git a/lib/components/SlashMenu.tsx b/lib/components/SlashMenu.tsx index 3dc116b..0414dda 100644 --- a/lib/components/SlashMenu.tsx +++ b/lib/components/SlashMenu.tsx @@ -4,6 +4,7 @@ import i18n from "../services/i18n.ts"; import { getAliases } from "../services/slashMenuAliases"; import { registerInlineWpCallbacks, clearInlineWpCallbacks, makePendingWpid } from "./InlineWorkPackage/callbacks"; import { makeInstanceId } from "../utils/id.ts"; +import { pendingBlockRegistry } from "./BlockWorkPackage/pendingBlockRegistry"; type AnyEditor = BlockNoteEditor; type AnyInlineNode = InlineContentFromConfig; @@ -75,6 +76,29 @@ function buildOnCancel( }; } +function isCurrentBlockEmpty(editor: AnyEditor): boolean { + const block = editor.getTextCursorPosition()?.block; + if (!block) return false; + const content = (block as any).content; + return Array.isArray(content) && content.length === 0; +} + +function handleBlockWorkPackageClick(editor: AnyEditor): void { + const blockId = editor.getTextCursorPosition()?.block?.id as string | undefined; + if (!blockId) return; + + const block = { + type: "openProjectWorkPackageBlock" as const, + props: { initialized: false }, + } as Parameters[0][number]; + + const [insertedBlock] = editor.insertBlocks([block], blockId, "after"); + if (!insertedBlock?.id) return; + + pendingBlockRegistry.add(insertedBlock.id); + editor.removeBlocks([blockId]); +} + function handleInlineWorkPackageClick(editor: AnyEditor): void { const instanceId = makeInstanceId(); const pendingWpid = makePendingWpid(instanceId); @@ -100,7 +124,13 @@ function handleInlineWorkPackageClick(editor: AnyEditor): void { export const workPackageSlashMenu = (editor: BlockNoteEditor) => ({ title: i18n.t("slashMenu.title"), - onItemClick: () => handleInlineWorkPackageClick(editor), + onItemClick: () => { + if (isCurrentBlockEmpty(editor)) { + handleBlockWorkPackageClick(editor); + } else { + handleInlineWorkPackageClick(editor); + } + }, aliases: [...getAliases()], group: "OpenProject", icon: , diff --git a/test/lib/components/integration/editor.slashMenu.browser.test.tsx b/test/lib/components/integration/editor.slashMenu.browser.test.tsx new file mode 100644 index 0000000..51af434 --- /dev/null +++ b/test/lib/components/integration/editor.slashMenu.browser.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { renderEditor } from '../../../helpers/renderEditor'; + +async function openSlashMenuAndSelectWp() { + await expect.element(page.getByText('Link existing work package').first()).toBeVisible(); + await userEvent.click(page.getByText('Link existing work package').first()); + + const searchInput = page.getByPlaceholder('Search by work package ID or subject'); + await expect.element(searchInput).toBeVisible(); + await userEvent.type(searchInput, 'Fix'); + await expect.element(page.getByText('Fix login bug')).toBeVisible(); + await userEvent.click(page.getByText('Fix login bug')); + await expect.element(searchInput).not.toBeInTheDocument(); +} + +describe('Slash menu - block vs inline routing', () => { + it('inserts a block card when triggered on an empty line', async () => { + renderEditor(); + const editorEl = page.getByRole('textbox'); + await userEvent.click(editorEl); + await userEvent.type(editorEl, '/'); + + await openSlashMenuAndSelectWp(); + + await expect.element(page.getByTestId('block-card')).toBeVisible(); + await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible(); + }); + + it('inserts an inline chip when triggered on a non-empty line', async () => { + renderEditor(); + const editorEl = page.getByRole('textbox'); + await userEvent.click(editorEl); + await userEvent.type(editorEl, 'Some text '); + await userEvent.type(editorEl, '/'); + + await openSlashMenuAndSelectWp(); + + await expect.element(page.getByText('#123')).toBeVisible(); + await expect.element(page.getByTestId('block-card')).not.toBeInTheDocument(); + }); +}); From 42f183bcd9a224422bf1479764e4191b313533eb Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Tue, 26 May 2026 18:00:29 +0300 Subject: [PATCH 2/2] test: fix insertInlineChipViaSlashMenu --- test/helpers/editorHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helpers/editorHelpers.ts b/test/helpers/editorHelpers.ts index 2290de7..c7e0455 100644 --- a/test/helpers/editorHelpers.ts +++ b/test/helpers/editorHelpers.ts @@ -10,7 +10,7 @@ export async function openEditorAndType(text: string) { } export async function insertInlineChipViaSlashMenu(searchTerm:string='Fix', resultTerm:string='Fix login bug') { - await openEditorAndType('/'); + await openEditorAndType(' /'); await expect.element(page.getByText('Link existing work package').first()).toBeVisible(); await userEvent.click(page.getByText('Link existing work package').first());