testomatio-editor-blocks 0.4.75 → 0.4.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,24 @@ export type MetaField = {
3
3
  value: string;
4
4
  };
5
5
  export declare function serializeMetaFields(fields: MetaField[]): string;
6
+ type TestEditorLike = {
7
+ document: any[];
8
+ getTextCursorPosition?: () => {
9
+ block?: {
10
+ id?: string;
11
+ };
12
+ };
13
+ insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
14
+ };
15
+ /**
16
+ * Insert a new test: a `testMeta` panel (labelled "NEW TEST" until it gets an id)
17
+ * seeded with default metadata — `type: manual`, `priority: normal`, and `labels`
18
+ * copied from the suite block — followed by an empty H2 heading for the test title.
19
+ *
20
+ * Inserts at the text cursor when the editor is focused, otherwise at the end of
21
+ * the document. Returns the title heading's block id (for focusing), or null.
22
+ */
23
+ export declare function addTestBlock(editor: TestEditorLike): string | null;
6
24
  export declare const testMetaBlock: {
7
25
  config: {
8
26
  readonly type: "testMeta";
@@ -35,3 +53,4 @@ export declare const testMetaBlock: {
35
53
  };
36
54
  }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
37
55
  };
56
+ export {};
@@ -26,6 +26,70 @@ function parseMetaFields(raw) {
26
26
  export function serializeMetaFields(fields) {
27
27
  return JSON.stringify(fields);
28
28
  }
29
+ /** Reads the `labels` value from the first suite `testMeta` block, if any. */
30
+ function suiteLabelsFromDocument(document) {
31
+ var _a, _b;
32
+ for (const block of document) {
33
+ if ((block === null || block === void 0 ? void 0 : block.type) !== "testMeta")
34
+ continue;
35
+ if (((_a = block.props) === null || _a === void 0 ? void 0 : _a.metaKind) !== "suite")
36
+ continue;
37
+ const fields = parseMetaFields((_b = block.props) === null || _b === void 0 ? void 0 : _b.metaFields);
38
+ const labels = fields.find((f) => f.key.trim().toLowerCase() === "labels");
39
+ if (labels)
40
+ return labels.value;
41
+ }
42
+ return "";
43
+ }
44
+ /**
45
+ * Insert a new test: a `testMeta` panel (labelled "NEW TEST" until it gets an id)
46
+ * seeded with default metadata — `type: manual`, `priority: normal`, and `labels`
47
+ * copied from the suite block — followed by an empty H2 heading for the test title.
48
+ *
49
+ * Inserts at the text cursor when the editor is focused, otherwise at the end of
50
+ * the document. Returns the title heading's block id (for focusing), or null.
51
+ */
52
+ export function addTestBlock(editor) {
53
+ var _a, _b, _c, _d, _e;
54
+ const fields = [
55
+ { key: "type", value: "manual" },
56
+ { key: "priority", value: "normal" },
57
+ { key: "labels", value: suiteLabelsFromDocument(editor.document) },
58
+ ];
59
+ const metaBlock = {
60
+ type: "testMeta",
61
+ props: {
62
+ metaKind: "test",
63
+ metaFields: serializeMetaFields(fields),
64
+ metaInline: false,
65
+ },
66
+ children: [],
67
+ };
68
+ const titleHeading = {
69
+ type: "heading",
70
+ props: { level: 2 },
71
+ content: [],
72
+ children: [],
73
+ };
74
+ // Prefer the text cursor position — BlockNote keeps it in the editor state even
75
+ // after the editor blurs (e.g. when a toolbar button is clicked), so the test
76
+ // lands where the user left the caret. Fall back to the document end only when
77
+ // there is no cursor at all.
78
+ const docs = editor.document;
79
+ let referenceId;
80
+ try {
81
+ referenceId = (_b = (_a = editor.getTextCursorPosition) === null || _a === void 0 ? void 0 : _a.call(editor).block) === null || _b === void 0 ? void 0 : _b.id;
82
+ }
83
+ catch {
84
+ referenceId = undefined;
85
+ }
86
+ if (!referenceId)
87
+ referenceId = (_c = docs[docs.length - 1]) === null || _c === void 0 ? void 0 : _c.id;
88
+ if (!referenceId)
89
+ return null;
90
+ const inserted = editor.insertBlocks([metaBlock, titleHeading], referenceId, "after");
91
+ return (_e = (_d = inserted === null || inserted === void 0 ? void 0 : inserted[1]) === null || _d === void 0 ? void 0 : _d.id) !== null && _e !== void 0 ? _e : null;
92
+ }
29
93
  function AddFieldMenu({ kind, usedKeys, onPick }) {
30
94
  const [isOpen, setIsOpen] = useState(false);
31
95
  const containerRef = useRef(null);
@@ -122,6 +186,6 @@ export const testMetaBlock = createReactBlockSpec({
122
186
  // so it's immediately editable. `expanded` is UI-only state — never
123
187
  // serialized.
124
188
  const [expanded, setExpanded] = useState(() => fields.length === 0);
125
- return (_jsxs("div", { className: `bn-testmeta${expanded ? " bn-testmeta--expanded" : " bn-testmeta--collapsed"}`, "data-block-id": block.id, "data-kind": kind, contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: [_jsxs("div", { className: "bn-testmeta__header", children: [_jsx("span", { className: "bn-testmeta__label", children: kind.toUpperCase() }), (idField === null || idField === void 0 ? void 0 : idField.value) && _jsx("span", { className: "bn-testmeta__id", children: idField.value }), !expanded && (_jsx("button", { type: "button", className: "bn-testmeta__summary", title: summaryText || "No metadata yet", onClick: () => setExpanded(true), children: summaryText || _jsx("span", { className: "bn-testmeta__summary--empty", children: "No metadata" }) })), _jsxs("div", { className: "bn-testmeta__actions", children: [expanded && _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField }), _jsx("button", { type: "button", className: `bn-testmeta__toggle${expanded ? " bn-testmeta__toggle--expanded" : ""}`, "aria-expanded": expanded, "aria-label": expanded ? "Collapse metadata" : "Expand metadata", title: expanded ? "Collapse" : "Expand", onClick: () => setExpanded((prev) => !prev), children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 6L8 10L12 6", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] })] }), expanded && editableFields.length > 0 && (_jsx("div", { className: "bn-testmeta__rows", children: editableFields.map(({ field, index }) => (_jsxs("div", { className: "bn-testmeta__row", children: [_jsx("input", { className: "bn-testmeta__key bn-testmeta__key--input", type: "text", value: field.key, placeholder: "key", spellCheck: false, onChange: (e) => handleKeyChange(index, e.target.value) }), _jsx("input", { className: "bn-testmeta__value", type: "text", value: field.value, placeholder: "value", spellCheck: false, onChange: (e) => handleValueChange(index, e.target.value) }), _jsx("button", { type: "button", className: "bn-testmeta__remove", "aria-label": "Remove field", title: "Remove field", onClick: () => handleRemove(index), children: "\u00D7" })] }, index))) }))] }));
189
+ return (_jsxs("div", { className: `bn-testmeta${expanded ? " bn-testmeta--expanded" : " bn-testmeta--collapsed"}`, "data-block-id": block.id, "data-kind": kind, contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: [_jsxs("div", { className: "bn-testmeta__header", children: [_jsx("span", { className: "bn-testmeta__label", children: kind === "test" && !(idField === null || idField === void 0 ? void 0 : idField.value) ? "NEW TEST" : kind.toUpperCase() }), (idField === null || idField === void 0 ? void 0 : idField.value) && _jsx("span", { className: "bn-testmeta__id", children: idField.value }), !expanded && (_jsx("button", { type: "button", className: "bn-testmeta__summary", title: summaryText || "No metadata yet", onClick: () => setExpanded(true), children: summaryText || _jsx("span", { className: "bn-testmeta__summary--empty", children: "No metadata" }) })), _jsxs("div", { className: "bn-testmeta__actions", children: [expanded && _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField }), _jsx("button", { type: "button", className: `bn-testmeta__toggle${expanded ? " bn-testmeta__toggle--expanded" : ""}`, "aria-expanded": expanded, "aria-label": expanded ? "Collapse metadata" : "Expand metadata", title: expanded ? "Collapse" : "Expand", onClick: () => setExpanded((prev) => !prev), children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 6L8 10L12 6", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] })] }), expanded && editableFields.length > 0 && (_jsx("div", { className: "bn-testmeta__rows", children: editableFields.map(({ field, index }) => (_jsxs("div", { className: "bn-testmeta__row", children: [_jsx("input", { className: "bn-testmeta__key bn-testmeta__key--input", type: "text", value: field.key, placeholder: "key", spellCheck: false, onChange: (e) => handleKeyChange(index, e.target.value) }), _jsx("input", { className: "bn-testmeta__value", type: "text", value: field.value, placeholder: "value", spellCheck: false, onChange: (e) => handleValueChange(index, e.target.value) }), _jsx("button", { type: "button", className: "bn-testmeta__remove", "aria-label": "Remove field", title: "Remove field", onClick: () => handleRemove(index), children: "\u00D7" })] }, index))) }))] }));
126
190
  },
127
191
  });
@@ -1,7 +1,7 @@
1
1
  export { customSchema, type CustomSchema, type CustomBlock, type CustomEditor, } from "./editor/customSchema";
2
2
  export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
- export { testMetaBlock } from "./editor/blocks/testMeta";
4
+ export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta";
5
5
  export { setMetaFieldSuggestions, getMetaFieldSuggestions, type MetaFieldSuggestion, type MetaFieldSuggestionsConfig, } from "./editor/testMetaFields";
6
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
7
7
  export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
package/package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { customSchema, } from "./editor/customSchema";
2
2
  export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
- export { testMetaBlock } from "./editor/blocks/testMeta";
4
+ export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta";
5
5
  export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
6
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
7
7
  export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
@@ -998,6 +998,19 @@ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
998
998
  color: var(--text-muted);
999
999
  }
1000
1000
 
1001
+ /*
1002
+ * The "new test" title heading is inserted directly after a testMeta panel, so it
1003
+ * is the block-outer immediately following the one containing a testMeta block.
1004
+ * Override BlockNote's injected "Heading" placeholder just for that heading.
1005
+ * !important beats the dynamically injected placeholder rule.
1006
+ */
1007
+ .bn-block-outer:has(.bn-block-content[data-content-type="testMeta"])
1008
+ + .bn-block-outer
1009
+ .bn-block-content[data-content-type="heading"]
1010
+ .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
1011
+ content: "Enter test title" !important;
1012
+ }
1013
+
1001
1014
  .bn-snippet-dropdown {
1002
1015
  position: relative;
1003
1016
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.75",
3
+ "version": "0.4.76",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
package/src/App.tsx CHANGED
@@ -25,6 +25,7 @@ import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomp
25
25
  import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
26
26
  import { setImageUploadHandler } from "./editor/stepImageUpload";
27
27
  import { canInsertStepOrSnippet, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
28
+ import { addTestBlock } from "./editor/blocks/testMeta";
28
29
  import "./App.css";
29
30
 
30
31
  const focusStepField = (
@@ -356,14 +357,8 @@ function CustomSlashMenu() {
356
357
  icon: <span className="bn-suggestion-icon">@T</span>,
357
358
  aliases: ["test", "metadata", "meta", "test id"],
358
359
  onItemClick: () => {
359
- insertOrUpdateBlock(editor, {
360
- type: "testMeta",
361
- props: {
362
- metaKind: "test",
363
- metaFields: "[]",
364
- metaInline: false,
365
- },
366
- });
360
+ const headingId = addTestBlock(editor);
361
+ focusStepField(editor, headingId ?? undefined);
367
362
  },
368
363
  };
369
364
 
@@ -554,6 +549,10 @@ function App() {
554
549
  const id = addSnippetBlock(editor);
555
550
  if (id) focusStepField(editor, id, "snippet-title");
556
551
  };
552
+ const insertTest = () => {
553
+ const id = addTestBlock(editor);
554
+ if (id) focusStepField(editor, id);
555
+ };
557
556
 
558
557
  const handleCopyMarkdown = async () => {
559
558
  if (conversionError) {
@@ -645,6 +644,13 @@ function App() {
645
644
  >
646
645
  Insert Snippet
647
646
  </button>
647
+ <button
648
+ type="button"
649
+ className="app__action app__action--ghost"
650
+ onClick={insertTest}
651
+ >
652
+ Insert Test
653
+ </button>
648
654
  <button
649
655
  type="button"
650
656
  className="app__dark-toggle"
@@ -31,6 +31,77 @@ export function serializeMetaFields(fields: MetaField[]): string {
31
31
  return JSON.stringify(fields);
32
32
  }
33
33
 
34
+ type TestEditorLike = {
35
+ document: any[];
36
+ getTextCursorPosition?: () => { block?: { id?: string } };
37
+ insertBlocks: (
38
+ blocks: any[],
39
+ referenceId: string,
40
+ placement: "before" | "after",
41
+ ) => any[];
42
+ };
43
+
44
+ /** Reads the `labels` value from the first suite `testMeta` block, if any. */
45
+ function suiteLabelsFromDocument(document: any[]): string {
46
+ for (const block of document) {
47
+ if (block?.type !== "testMeta") continue;
48
+ if ((block.props as any)?.metaKind !== "suite") continue;
49
+ const fields = parseMetaFields((block.props as any)?.metaFields);
50
+ const labels = fields.find((f) => f.key.trim().toLowerCase() === "labels");
51
+ if (labels) return labels.value;
52
+ }
53
+ return "";
54
+ }
55
+
56
+ /**
57
+ * Insert a new test: a `testMeta` panel (labelled "NEW TEST" until it gets an id)
58
+ * seeded with default metadata — `type: manual`, `priority: normal`, and `labels`
59
+ * copied from the suite block — followed by an empty H2 heading for the test title.
60
+ *
61
+ * Inserts at the text cursor when the editor is focused, otherwise at the end of
62
+ * the document. Returns the title heading's block id (for focusing), or null.
63
+ */
64
+ export function addTestBlock(editor: TestEditorLike): string | null {
65
+ const fields: MetaField[] = [
66
+ { key: "type", value: "manual" },
67
+ { key: "priority", value: "normal" },
68
+ { key: "labels", value: suiteLabelsFromDocument(editor.document) },
69
+ ];
70
+
71
+ const metaBlock = {
72
+ type: "testMeta" as const,
73
+ props: {
74
+ metaKind: "test",
75
+ metaFields: serializeMetaFields(fields),
76
+ metaInline: false,
77
+ },
78
+ children: [],
79
+ };
80
+ const titleHeading = {
81
+ type: "heading" as const,
82
+ props: { level: 2 },
83
+ content: [],
84
+ children: [],
85
+ };
86
+
87
+ // Prefer the text cursor position — BlockNote keeps it in the editor state even
88
+ // after the editor blurs (e.g. when a toolbar button is clicked), so the test
89
+ // lands where the user left the caret. Fall back to the document end only when
90
+ // there is no cursor at all.
91
+ const docs = editor.document;
92
+ let referenceId: string | undefined;
93
+ try {
94
+ referenceId = editor.getTextCursorPosition?.().block?.id;
95
+ } catch {
96
+ referenceId = undefined;
97
+ }
98
+ if (!referenceId) referenceId = docs[docs.length - 1]?.id;
99
+ if (!referenceId) return null;
100
+
101
+ const inserted = editor.insertBlocks([metaBlock, titleHeading], referenceId, "after");
102
+ return inserted?.[1]?.id ?? null;
103
+ }
104
+
34
105
  type AddFieldMenuProps = {
35
106
  kind: "test" | "suite";
36
107
  usedKeys: string[];
@@ -214,7 +285,9 @@ export const testMetaBlock = createReactBlockSpec(
214
285
  draggable={false}
215
286
  >
216
287
  <div className="bn-testmeta__header">
217
- <span className="bn-testmeta__label">{kind.toUpperCase()}</span>
288
+ <span className="bn-testmeta__label">
289
+ {kind === "test" && !idField?.value ? "NEW TEST" : kind.toUpperCase()}
290
+ </span>
218
291
  {idField?.value && <span className="bn-testmeta__id">{idField.value}</span>}
219
292
  {!expanded && (
220
293
  <button
@@ -998,6 +998,19 @@ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
998
998
  color: var(--text-muted);
999
999
  }
1000
1000
 
1001
+ /*
1002
+ * The "new test" title heading is inserted directly after a testMeta panel, so it
1003
+ * is the block-outer immediately following the one containing a testMeta block.
1004
+ * Override BlockNote's injected "Heading" placeholder just for that heading.
1005
+ * !important beats the dynamically injected placeholder rule.
1006
+ */
1007
+ .bn-block-outer:has(.bn-block-content[data-content-type="testMeta"])
1008
+ + .bn-block-outer
1009
+ .bn-block-content[data-content-type="heading"]
1010
+ .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
1011
+ content: "Enter test title" !important;
1012
+ }
1013
+
1001
1014
  .bn-snippet-dropdown {
1002
1015
  position: relative;
1003
1016
  }
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export {
6
6
  } from "./editor/customSchema";
7
7
  export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
8
8
  export { snippetBlock } from "./editor/blocks/snippet";
9
- export { testMetaBlock } from "./editor/blocks/testMeta";
9
+ export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta";
10
10
  export {
11
11
  setMetaFieldSuggestions,
12
12
  getMetaFieldSuggestions,