Skip to content

Commit 02086aa

Browse files
committed
test(op-blocknote-extension): improve browser-based tests and coverage for work package chips, rename inlineWorkPackageSpec to openProjectWorkPackageInlineSpec
1 parent 4cc3bb2 commit 02086aa

22 files changed

+918
-568
lines changed

lib/components/BlockWorkPackage/BlockCards.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export const BlockCardM = ({
102102
className="op-bn-work-package op-bn-work-package--m"
103103
$inDropdown={inDropdown}
104104
onClick={onClick}
105+
data-testid="block-card"
105106
style={onClick ? { cursor: "pointer" } : undefined}
106107
>
107108
<CardDetails>
@@ -136,6 +137,7 @@ export const BlockCardL = ({
136137
className="op-bn-work-package op-bn-work-package--l"
137138
$inDropdown={inDropdown}
138139
onClick={onClick}
140+
data-testid="block-card"
139141
style={onClick ? { cursor: "pointer" } : undefined}
140142
>
141143
<CardDetailsSpaced>
@@ -184,6 +186,7 @@ export const BlockCardXL = ({
184186
className="op-bn-work-package op-bn-work-package--xl"
185187
$inDropdown={inDropdown}
186188
onClick={onClick}
189+
data-testid="block-card"
187190
style={onClick ? { cursor: "pointer" } : undefined}
188191
>
189192
<CardDetailsSpaced>

lib/components/BlockWorkPackage/spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createReactBlockSpec } from "@blocknote/react";
33
import { BlockWorkPackageComponent } from "./BlockWorkPackage";
44

55
export const blockConfig = createBlockConfig((() => ({
6-
type: "openProjectWorkPackage",
6+
type: "openProjectWorkPackageBlock" as const,
77
propSchema: {
88
wpid: { default: undefined, type: "number" },
99
initialized: { default: false, type: "boolean" },

lib/components/HashMenu/editorUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function insertWpChip(editor: AnyEditor, wp: WorkPackage, size: InlineWpS
8181
const instanceId = makeInstanceId();
8282

8383
(editor.insertInlineContent as (content: unknown[]) => void)([
84-
{ type: "inlineWorkPackage", props: { wpid: String(wp.id), instanceId, size } },
84+
{ type: "openProjectWorkPackageInline", props: { wpid: String(wp.id), instanceId, size } },
8585
{ type: "text", text: " ", styles: {} },
8686
]);
8787

@@ -113,7 +113,7 @@ export function insertWpChipIntoBlock(
113113
editor.updateBlock(blockId, {
114114
content: [
115115
...content,
116-
{ type: "inlineWorkPackage", props: { wpid: String(wp.id), instanceId, size } },
116+
{ type: "openProjectWorkPackageInline", props: { wpid: String(wp.id), instanceId, size } },
117117
{ type: "text", text: " ", styles: {} },
118118
],
119119
} as any);
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export { inlineWorkPackageSpec } from "./spec";
1+
export { openProjectWorkPackageInlineSpec } from "./spec";
22
export { registerInlineWpCallbacks, clearInlineWpCallbacks } from "./callbacks";
33
export type { InlineWpSize } from "../WorkPackage/types";

lib/components/InlineWorkPackage/spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { createReactInlineContentSpec } from "@blocknote/react";
22
import { InlineWorkPackageChip } from "./InlineWorkPackageChip";
33
import { linkToWorkPackage } from "../../services/openProjectApi";
44

5-
export const inlineWorkPackageSpec = createReactInlineContentSpec(
5+
export const openProjectWorkPackageInlineSpec = createReactInlineContentSpec(
66
{
7-
type: "inlineWorkPackage" as const,
7+
type: "openProjectWorkPackageInline" as const,
88
propSchema: {
99
wpid: { default: "" },
1010
instanceId: { default: "" },

lib/components/SlashMenu.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ function buildOnSelect(
2929

3030
const chipIndex = current.content.findIndex((node) => {
3131
const n = node as { type: string; props?: { wpid?: string } };
32-
return n.type === "inlineWorkPackage" && n.props?.wpid === pendingWpid;
32+
return n.type === "openProjectWorkPackageInline" && n.props?.wpid === pendingWpid;
3333
});
3434

3535
const updatedContent = current.content.map((node) => {
3636
const n = node as { type: string; props?: { wpid?: string; instanceId?: string } };
37-
if (n.type === "inlineWorkPackage" && n.props?.wpid === pendingWpid) {
37+
if (n.type === "openProjectWorkPackageInline" && n.props?.wpid === pendingWpid) {
3838
return { ...n, props: { ...n.props, wpid: String(wpid), instanceId } };
3939
}
4040
return node;
@@ -67,7 +67,7 @@ function buildOnCancel(
6767

6868
const updatedContent = current.content.filter((node) => {
6969
const n = node as { type: string; props?: { wpid?: string } };
70-
return !(n.type === "inlineWorkPackage" && n.props?.wpid === pendingWpid);
70+
return !(n.type === "openProjectWorkPackageInline" && n.props?.wpid === pendingWpid);
7171
});
7272

7373
editor.updateBlock(current.blockId, { content: updatedContent } as any);
@@ -89,7 +89,7 @@ function handleInlineWorkPackageClick(editor: AnyEditor): void {
8989

9090
try {
9191
(editor.insertInlineContent as (content: unknown[]) => void)([
92-
{ type: "inlineWorkPackage", props: { wpid: pendingWpid, instanceId, size: "s" } },
92+
{ type: "openProjectWorkPackageInline", props: { wpid: pendingWpid, instanceId, size: "s" } },
9393
" ",
9494
]);
9595
} catch (error) {

lib/components/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export { openProjectWorkPackageBlockSpec } from "./BlockWorkPackage";
2-
export { inlineWorkPackageSpec } from "./InlineWorkPackage";
2+
export { openProjectWorkPackageInlineSpec } from "./InlineWorkPackage";
33
export { workPackageSlashMenu } from "./SlashMenu";
44
export { ShadowDomWrapper } from "./ShadowDomWrapper";

lib/hooks/useInlineWpEvents.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { useEffect } from 'react';
2+
import type { BlockNoteEditor, InlineContentFromConfig } from '@blocknote/core';
3+
import { wpBridge, makeInstanceId } from '../../lib';
4+
import type { InlineWpSize, BlockWpSize, WpSize } from '../../lib';
5+
6+
type AnyEditor = BlockNoteEditor<any, any, any>;
7+
type AnyInlineNode = InlineContentFromConfig<any, any>;
8+
9+
interface InlineWpNode {
10+
type:'openProjectWorkPackageInline';
11+
props:{
12+
wpid:string;
13+
instanceId:string;
14+
size:InlineWpSize;
15+
};
16+
content: AnyInlineNode[];
17+
}
18+
19+
const VALID_INLINE_SIZES:Set<InlineWpSize> = new Set(['xxs', 'xs', 's']);
20+
21+
function isInlineWpNode(node:unknown): node is InlineWpNode {
22+
if (typeof node !== 'object' || node === null) return false;
23+
24+
const n = node as Record<string, unknown>;
25+
if (n['type'] !== 'openProjectWorkPackageInline') return false;
26+
27+
const props = n['props'];
28+
if (typeof props !== 'object' || props === null) return false;
29+
30+
const p = props as Record<string, unknown>;
31+
return (
32+
typeof p['instanceId'] === 'string' &&
33+
typeof p['wpid'] === 'string' &&
34+
VALID_INLINE_SIZES.has(p['size'] as InlineWpSize)
35+
);
36+
}
37+
38+
function asInlineNode(node:InlineWpNode):AnyInlineNode {
39+
return node as unknown as AnyInlineNode;
40+
}
41+
42+
interface FoundInlineBlock {
43+
blockId:string;
44+
content:AnyInlineNode[];
45+
chip:InlineWpNode;
46+
}
47+
48+
function findInlineChip(editor:AnyEditor, instanceId:string):FoundInlineBlock | null {
49+
let found: FoundInlineBlock | null = null;
50+
51+
editor.forEachBlock((block) => {
52+
if (found) return false;
53+
54+
if (!Array.isArray(block.content)) return true;
55+
56+
const content = (block.content ?? []) as AnyInlineNode[];
57+
const chip = content.find(
58+
(node) => isInlineWpNode(node) && node.props.instanceId === instanceId
59+
) as InlineWpNode | undefined;
60+
61+
if (chip) {
62+
found = { blockId:block.id, content, chip };
63+
return false;
64+
}
65+
66+
return true;
67+
});
68+
69+
return found;
70+
}
71+
72+
// The updater returns the updated node, or null to remove it.
73+
// Returns found so the caller can use it without a second traversal.
74+
function updateInlineChip(
75+
editor:AnyEditor,
76+
instanceId:string,
77+
updater:(chip:InlineWpNode) => InlineWpNode | null
78+
): FoundInlineBlock | null {
79+
const found = findInlineChip(editor, instanceId);
80+
if (!found) return null;
81+
82+
const updatedContent = found.content.reduce<AnyInlineNode[]>((acc, node) => {
83+
if (!isInlineWpNode(node) || node.props.instanceId !== instanceId) {
84+
acc.push(node);
85+
return acc;
86+
}
87+
const updated = updater(node);
88+
if (updated !== null) acc.push(asInlineNode(updated));
89+
return acc;
90+
}, []);
91+
92+
editor.updateBlock(found.blockId, { content:updatedContent });
93+
return found;
94+
}
95+
96+
function moveCursorAfter(editor:AnyEditor, blockId:string):void {
97+
requestAnimationFrame(() => {
98+
editor.focus();
99+
editor.setTextCursorPosition(blockId, 'end');
100+
101+
const cursor = editor.getTextCursorPosition();
102+
if (!cursor?.nextBlock && cursor?.block) {
103+
editor.insertBlocks(
104+
[{ type:'paragraph', content:[] }],
105+
cursor.block.id,
106+
'after'
107+
);
108+
}
109+
110+
const updated = editor.getTextCursorPosition();
111+
if (updated?.nextBlock) {
112+
editor.setTextCursorPosition(updated.nextBlock.id, 'start');
113+
}
114+
});
115+
}
116+
117+
function handleResize(editor:AnyEditor, instanceId:string, size:WpSize):void {
118+
const isBlockSize = size === 'm' || size === 'l' || size === 'xl';
119+
120+
if (isBlockSize) {
121+
handlePromoteToBlock(editor, instanceId, size as BlockWpSize);
122+
return;
123+
}
124+
125+
updateInlineChip(editor, instanceId, (chip) => ({
126+
...chip,
127+
props:{ ...chip.props, size:size as InlineWpSize },
128+
}));
129+
}
130+
131+
function handleDelete(editor:AnyEditor, instanceId:string):void {
132+
updateInlineChip(editor, instanceId, () => null);
133+
}
134+
135+
function handlePromoteToBlock(
136+
editor:AnyEditor,
137+
instanceId:string,
138+
size:BlockWpSize = 'm'
139+
):void {
140+
const found = findInlineChip(editor, instanceId);
141+
if (!found) return;
142+
143+
// wpid must be a positive integer
144+
const wpid = Number(found.chip.props.wpid);
145+
if (Number.isNaN(wpid) || wpid <= 0) return;
146+
147+
updateInlineChip(editor, instanceId, () => null);
148+
149+
const block = {
150+
type:'openProjectWorkPackageBlock',
151+
props:{ wpid, initialized:true, size },
152+
} as Parameters<typeof editor.insertBlocks>[0][number];
153+
154+
const [insertedBlock] = editor.insertBlocks(
155+
[block],
156+
found.blockId,
157+
'after'
158+
);
159+
160+
if (insertedBlock?.id) {
161+
moveCursorAfter(editor, insertedBlock.id);
162+
}
163+
}
164+
165+
function handleConvertToInline(
166+
editor:AnyEditor,
167+
wpid:number,
168+
size:InlineWpSize,
169+
blockId:string
170+
):void {
171+
const block = editor.getBlock(blockId);
172+
if (!block) return;
173+
174+
const instanceId = makeInstanceId();
175+
176+
const paragraph = {
177+
type:'paragraph',
178+
content:[
179+
{
180+
type:'openProjectWorkPackageInline',
181+
props:{ wpid:String(wpid), instanceId, size },
182+
},
183+
],
184+
} as Parameters<typeof editor.insertBlocks>[0][number];
185+
186+
const [insertedParagraph] = editor.insertBlocks(
187+
[paragraph],
188+
blockId,
189+
'before'
190+
);
191+
192+
editor.removeBlocks([blockId]);
193+
194+
requestAnimationFrame(() => {
195+
if (!insertedParagraph?.id) return;
196+
editor.focus();
197+
editor.setTextCursorPosition(insertedParagraph.id, 'end');
198+
});
199+
}
200+
201+
// editor instance is stable for the lifetime of the component re-subscription only on editor replacement
202+
export function useInlineWpEvents(editor: AnyEditor):void {
203+
useEffect(() => {
204+
const offResize = wpBridge.onResize(({ instanceId, size }) =>
205+
handleResize(editor, instanceId, size)
206+
);
207+
208+
const offDelete = wpBridge.onDelete(({ instanceId }) =>
209+
handleDelete(editor, instanceId)
210+
);
211+
212+
const offToInline = wpBridge.onConvertToInline(({ wpid, size, blockId }) =>
213+
handleConvertToInline(editor, wpid, size, blockId)
214+
);
215+
216+
return () => {
217+
offResize();
218+
offDelete();
219+
offToInline();
220+
};
221+
}, [editor]);
222+
}

lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "./services/i18n.ts";
22
export {
33
openProjectWorkPackageBlockSpec,
4-
inlineWorkPackageSpec,
4+
openProjectWorkPackageInlineSpec,
55
workPackageSlashMenu,
66
ShadowDomWrapper,
77
} from "./components";

lib/services/colors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ function idFromHref(href: string) {
203203
}
204204

205205
let theme: OpColorMode;
206-
export function getTheme(): OpColorMode {
206+
function getTheme(): OpColorMode {
207207
return theme ?? (theme = detectTheme());
208208
}
209209

0 commit comments

Comments
 (0)