testomatio-editor-blocks 0.4.70 → 0.4.72
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.
- package/package/editor/blocks/testMeta.js +17 -1
- package/package/editor/tagBadge.d.ts +35 -0
- package/package/editor/tagBadge.js +114 -0
- package/package/index.d.ts +1 -0
- package/package/index.js +1 -0
- package/package/styles.css +86 -1
- package/package.json +1 -1
- package/src/App.tsx +2 -0
- package/src/editor/blocks/testMeta.tsx +44 -3
- package/src/editor/styles.css +86 -1
- package/src/editor/tagBadge.test.ts +75 -0
- package/src/editor/tagBadge.ts +143 -0
- package/src/index.ts +8 -0
|
@@ -106,6 +106,22 @@ export const testMetaBlock = createReactBlockSpec({
|
|
|
106
106
|
const editableFields = fields
|
|
107
107
|
.map((field, index) => ({ field, index }))
|
|
108
108
|
.filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase()));
|
|
109
|
-
|
|
109
|
+
// Compact (reading) view: collapse the rows into a single truncated line
|
|
110
|
+
// built only from fields that actually have a value — keys without values
|
|
111
|
+
// are an editing-only concern and never appear in the compact summary (the
|
|
112
|
+
// expanded rows still show them so they can be filled in). The
|
|
113
|
+
// expand/collapse toggle pinned to the far right reveals the full rows.
|
|
114
|
+
const summaryText = editableFields
|
|
115
|
+
.filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
|
|
116
|
+
.map(({ field }) => `${field.key}: ${field.value}`)
|
|
117
|
+
.join(" · ");
|
|
118
|
+
// Default to the compact (collapsed) view whenever the block carries any
|
|
119
|
+
// field — this keeps suite blocks compact too, even when their fields have
|
|
120
|
+
// no value yet (e.g. a seeded `emoji:` row) and so produce an empty
|
|
121
|
+
// summary. Only a genuinely empty block (no fields at all) starts expanded
|
|
122
|
+
// so it's immediately editable. `expanded` is UI-only state — never
|
|
123
|
+
// serialized.
|
|
124
|
+
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))) }))] }));
|
|
110
126
|
},
|
|
111
127
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
3
|
+
export declare const TAGS_DETECT_REGEXP: RegExp;
|
|
4
|
+
export interface TagMatch {
|
|
5
|
+
/** Offset of the `@` within the scanned string (the leading space is excluded). */
|
|
6
|
+
start: number;
|
|
7
|
+
/** Offset just past the last character of the tag. */
|
|
8
|
+
end: number;
|
|
9
|
+
/** The matched tag text, including the leading `@`. */
|
|
10
|
+
tag: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
14
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectTags(text: string): TagMatch[];
|
|
17
|
+
/**
|
|
18
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
19
|
+
*
|
|
20
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
21
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* useCreateBlockNote({
|
|
25
|
+
* schema: customSchema,
|
|
26
|
+
* extensions: [tagBadgeExtension()],
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare class TagBadgeExtension extends BlockNoteExtension {
|
|
31
|
+
static key(): string;
|
|
32
|
+
constructor();
|
|
33
|
+
}
|
|
34
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
35
|
+
export declare const tagBadgeExtension: () => TagBadgeExtension;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
3
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
4
|
+
/**
|
|
5
|
+
* Tags are tokens that start with `@` and may contain alphanumerics plus a few
|
|
6
|
+
* symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`.
|
|
7
|
+
*
|
|
8
|
+
* This is a faithful JS port of the backend (Ruby) tag regexes:
|
|
9
|
+
* TAG_PREFIX_REGEXP = /(?:^|[ \t])/
|
|
10
|
+
* TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/
|
|
11
|
+
* TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@<allowed>)/
|
|
12
|
+
*
|
|
13
|
+
* `\w` already covers `[0-9_]`, so the symbol class is kept minimal while
|
|
14
|
+
* staying faithful to the allowed characters. A tag must be preceded by the
|
|
15
|
+
* start of the string or whitespace — so email-like text (`user@example.com`)
|
|
16
|
+
* is correctly ignored.
|
|
17
|
+
*/
|
|
18
|
+
const TAG_ALLOWED_SYMBOLS = String.raw `[\w=\-_().:&]*[\w)]`;
|
|
19
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
20
|
+
export const TAGS_DETECT_REGEXP = new RegExp(String.raw `(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`, "g");
|
|
21
|
+
/**
|
|
22
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
23
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
24
|
+
*/
|
|
25
|
+
export function detectTags(text) {
|
|
26
|
+
const matches = [];
|
|
27
|
+
// Use a fresh regex each call so the shared `lastIndex` state never leaks
|
|
28
|
+
// between invocations.
|
|
29
|
+
const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g");
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = regexp.exec(text)) !== null) {
|
|
32
|
+
const tag = match[1];
|
|
33
|
+
// The match may include a leading space/tab consumed by the prefix; the tag
|
|
34
|
+
// itself starts that many characters into the overall match.
|
|
35
|
+
const start = match.index + (match[0].length - tag.length);
|
|
36
|
+
matches.push({ start, end: start + tag.length, tag });
|
|
37
|
+
// Defensive guard against a zero-length match looping forever (a tag always
|
|
38
|
+
// starts with `@`, so this should never trigger).
|
|
39
|
+
if (regexp.lastIndex === match.index) {
|
|
40
|
+
regexp.lastIndex++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return matches;
|
|
44
|
+
}
|
|
45
|
+
const tagBadgePluginKey = new PluginKey("testomatioTagBadge");
|
|
46
|
+
/**
|
|
47
|
+
* Build inline decorations for every `@tag` found inside heading blocks. Tags
|
|
48
|
+
* are only *painted* — the underlying text is untouched, so markdown
|
|
49
|
+
* serialization round-trips unchanged.
|
|
50
|
+
*/
|
|
51
|
+
function buildHeadingTagDecorations(doc) {
|
|
52
|
+
const decorations = [];
|
|
53
|
+
doc.descendants((node, pos) => {
|
|
54
|
+
if (node.type.name !== "heading") {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
// Walk the heading's inline children so positions stay correct even when it
|
|
58
|
+
// contains links or inline images alongside text.
|
|
59
|
+
node.forEach((child, offset) => {
|
|
60
|
+
if (!child.isText || !child.text) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// `pos + 1` steps inside the heading content node; `offset` is the child's
|
|
64
|
+
// position relative to the node's content.
|
|
65
|
+
const base = pos + 1 + offset;
|
|
66
|
+
for (const { start, end } of detectTags(child.text)) {
|
|
67
|
+
decorations.push(Decoration.inline(base + start, base + end, {
|
|
68
|
+
class: "bn-tag-badge",
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// Headings only hold inline content; no need to descend further.
|
|
73
|
+
return false;
|
|
74
|
+
});
|
|
75
|
+
return DecorationSet.create(doc, decorations);
|
|
76
|
+
}
|
|
77
|
+
function tagBadgePlugin() {
|
|
78
|
+
return new Plugin({
|
|
79
|
+
key: tagBadgePluginKey,
|
|
80
|
+
state: {
|
|
81
|
+
init: (_config, state) => buildHeadingTagDecorations(state.doc),
|
|
82
|
+
apply: (tr, value) => tr.docChanged ? buildHeadingTagDecorations(tr.doc) : value,
|
|
83
|
+
},
|
|
84
|
+
props: {
|
|
85
|
+
decorations(state) {
|
|
86
|
+
return tagBadgePluginKey.getState(state);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
93
|
+
*
|
|
94
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
95
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* useCreateBlockNote({
|
|
99
|
+
* schema: customSchema,
|
|
100
|
+
* extensions: [tagBadgeExtension()],
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export class TagBadgeExtension extends BlockNoteExtension {
|
|
105
|
+
static key() {
|
|
106
|
+
return "tagBadge";
|
|
107
|
+
}
|
|
108
|
+
constructor() {
|
|
109
|
+
super();
|
|
110
|
+
this.addProsemirrorPlugin(tagBadgePlugin());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
114
|
+
export const tagBadgeExtension = () => new TagBadgeExtension();
|
package/package/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { snippetBlock } from "./editor/blocks/snippet";
|
|
|
4
4
|
export { testMetaBlock } 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
|
+
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
|
|
7
8
|
export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
|
|
8
9
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
|
|
9
10
|
export { useStepImageUpload, setImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { snippetBlock } from "./editor/blocks/snippet";
|
|
|
4
4
|
export { testMetaBlock } from "./editor/blocks/testMeta";
|
|
5
5
|
export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
|
|
6
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
7
|
+
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
|
|
7
8
|
export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
|
|
8
9
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
|
|
9
10
|
export { useStepImageUpload, setImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/styles.css
CHANGED
|
@@ -723,6 +723,30 @@ html.dark .bn-teststep__view-toggle--compact svg {
|
|
|
723
723
|
--prev-level: 18px;
|
|
724
724
|
}
|
|
725
725
|
|
|
726
|
+
/* ============================================
|
|
727
|
+
TAG BADGES IN HEADINGS
|
|
728
|
+
`@tag` tokens inside headings are painted as compact badges by a ProseMirror
|
|
729
|
+
inline decoration (see src/editor/tagBadge.ts). The text stays editable; only
|
|
730
|
+
the styling is applied.
|
|
731
|
+
============================================ */
|
|
732
|
+
.testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
733
|
+
font-size: 0.78em;
|
|
734
|
+
font-weight: 500;
|
|
735
|
+
line-height: 1.2;
|
|
736
|
+
background: var(--bg-muted);
|
|
737
|
+
color: var(--text-muted);
|
|
738
|
+
border: 1px solid var(--border-light);
|
|
739
|
+
border-radius: 6px;
|
|
740
|
+
padding: 0.05em 0.4em;
|
|
741
|
+
vertical-align: middle;
|
|
742
|
+
white-space: nowrap;
|
|
743
|
+
}
|
|
744
|
+
html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
745
|
+
background: rgba(255, 255, 255, 0.08);
|
|
746
|
+
color: rgba(255, 255, 255, 0.6);
|
|
747
|
+
border-color: rgba(255, 255, 255, 0.12);
|
|
748
|
+
}
|
|
749
|
+
|
|
726
750
|
/* ============================================
|
|
727
751
|
TEST / SUITE METADATA BLOCK
|
|
728
752
|
Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
|
|
@@ -752,8 +776,69 @@ html.dark .bn-teststep__view-toggle--compact svg {
|
|
|
752
776
|
margin-left: 8px;
|
|
753
777
|
}
|
|
754
778
|
|
|
755
|
-
|
|
779
|
+
/* Add button + expand/collapse toggle live in a right-pinned action group. */
|
|
780
|
+
.bn-testmeta__header .bn-testmeta__actions {
|
|
756
781
|
margin-left: auto;
|
|
782
|
+
display: inline-flex;
|
|
783
|
+
align-items: center;
|
|
784
|
+
gap: 2px;
|
|
785
|
+
flex-shrink: 0;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/* Compact one-liner: the metadata rendered as truncated read text, click to
|
|
789
|
+
expand. Takes the remaining header width and ellipsizes when it overflows. */
|
|
790
|
+
.bn-testmeta__summary {
|
|
791
|
+
flex: 1 1 auto;
|
|
792
|
+
min-width: 0;
|
|
793
|
+
overflow: hidden;
|
|
794
|
+
white-space: nowrap;
|
|
795
|
+
text-overflow: ellipsis;
|
|
796
|
+
text-align: left;
|
|
797
|
+
font-family: inherit;
|
|
798
|
+
font-size: 13px;
|
|
799
|
+
color: var(--text-muted);
|
|
800
|
+
background: transparent;
|
|
801
|
+
border: none;
|
|
802
|
+
padding: 0;
|
|
803
|
+
cursor: pointer;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.bn-testmeta__summary:hover {
|
|
807
|
+
color: var(--text-primary);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.bn-testmeta__summary--empty {
|
|
811
|
+
font-style: italic;
|
|
812
|
+
opacity: 0.7;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.bn-testmeta__toggle {
|
|
816
|
+
width: 24px;
|
|
817
|
+
height: 24px;
|
|
818
|
+
display: inline-flex;
|
|
819
|
+
align-items: center;
|
|
820
|
+
justify-content: center;
|
|
821
|
+
color: var(--text-muted);
|
|
822
|
+
background: transparent;
|
|
823
|
+
border: none;
|
|
824
|
+
border-radius: 6px;
|
|
825
|
+
cursor: pointer;
|
|
826
|
+
padding: 0;
|
|
827
|
+
flex-shrink: 0;
|
|
828
|
+
transition: background-color 120ms ease;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
.bn-testmeta__toggle:hover {
|
|
832
|
+
background: var(--step-bg-button-hover);
|
|
833
|
+
color: var(--text-primary);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.bn-testmeta__toggle svg {
|
|
837
|
+
transition: transform 150ms ease;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
.bn-testmeta__toggle--expanded svg {
|
|
841
|
+
transform: rotate(180deg);
|
|
757
842
|
}
|
|
758
843
|
|
|
759
844
|
.bn-testmeta__label {
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./editor/customMarkdownConverter";
|
|
21
21
|
import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
|
|
22
22
|
import { customSchema, type CustomEditor } from "./editor/customSchema";
|
|
23
|
+
import { tagBadgeExtension } from "./editor/tagBadge";
|
|
23
24
|
import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
|
|
24
25
|
import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
|
|
25
26
|
import { setImageUploadHandler } from "./editor/stepImageUpload";
|
|
@@ -404,6 +405,7 @@ function CustomSlashMenu() {
|
|
|
404
405
|
function App() {
|
|
405
406
|
const editor = useCreateBlockNote({
|
|
406
407
|
schema: customSchema,
|
|
408
|
+
extensions: [tagBadgeExtension()],
|
|
407
409
|
pasteHandler: createMarkdownPasteHandler(markdownToBlocks),
|
|
408
410
|
uploadFile: async (file: File) => {
|
|
409
411
|
const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
|
|
@@ -187,9 +187,26 @@ export const testMetaBlock = createReactBlockSpec(
|
|
|
187
187
|
.map((field, index) => ({ field, index }))
|
|
188
188
|
.filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase()));
|
|
189
189
|
|
|
190
|
+
// Compact (reading) view: collapse the rows into a single truncated line
|
|
191
|
+
// built only from fields that actually have a value — keys without values
|
|
192
|
+
// are an editing-only concern and never appear in the compact summary (the
|
|
193
|
+
// expanded rows still show them so they can be filled in). The
|
|
194
|
+
// expand/collapse toggle pinned to the far right reveals the full rows.
|
|
195
|
+
const summaryText = editableFields
|
|
196
|
+
.filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
|
|
197
|
+
.map(({ field }) => `${field.key}: ${field.value}`)
|
|
198
|
+
.join(" · ");
|
|
199
|
+
// Default to the compact (collapsed) view whenever the block carries any
|
|
200
|
+
// field — this keeps suite blocks compact too, even when their fields have
|
|
201
|
+
// no value yet (e.g. a seeded `emoji:` row) and so produce an empty
|
|
202
|
+
// summary. Only a genuinely empty block (no fields at all) starts expanded
|
|
203
|
+
// so it's immediately editable. `expanded` is UI-only state — never
|
|
204
|
+
// serialized.
|
|
205
|
+
const [expanded, setExpanded] = useState(() => fields.length === 0);
|
|
206
|
+
|
|
190
207
|
return (
|
|
191
208
|
<div
|
|
192
|
-
className="bn-testmeta"
|
|
209
|
+
className={`bn-testmeta${expanded ? " bn-testmeta--expanded" : " bn-testmeta--collapsed"}`}
|
|
193
210
|
data-block-id={block.id}
|
|
194
211
|
data-kind={kind}
|
|
195
212
|
contentEditable={false}
|
|
@@ -199,10 +216,34 @@ export const testMetaBlock = createReactBlockSpec(
|
|
|
199
216
|
<div className="bn-testmeta__header">
|
|
200
217
|
<span className="bn-testmeta__label">{kind.toUpperCase()}</span>
|
|
201
218
|
{idField?.value && <span className="bn-testmeta__id">{idField.value}</span>}
|
|
202
|
-
|
|
219
|
+
{!expanded && (
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
className="bn-testmeta__summary"
|
|
223
|
+
title={summaryText || "No metadata yet"}
|
|
224
|
+
onClick={() => setExpanded(true)}
|
|
225
|
+
>
|
|
226
|
+
{summaryText || <span className="bn-testmeta__summary--empty">No metadata</span>}
|
|
227
|
+
</button>
|
|
228
|
+
)}
|
|
229
|
+
<div className="bn-testmeta__actions">
|
|
230
|
+
{expanded && <AddFieldMenu kind={kind} usedKeys={usedKeys} onPick={handleAddField} />}
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
className={`bn-testmeta__toggle${expanded ? " bn-testmeta__toggle--expanded" : ""}`}
|
|
234
|
+
aria-expanded={expanded}
|
|
235
|
+
aria-label={expanded ? "Collapse metadata" : "Expand metadata"}
|
|
236
|
+
title={expanded ? "Collapse" : "Expand"}
|
|
237
|
+
onClick={() => setExpanded((prev) => !prev)}
|
|
238
|
+
>
|
|
239
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
240
|
+
<path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
241
|
+
</svg>
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
203
244
|
</div>
|
|
204
245
|
|
|
205
|
-
{editableFields.length > 0 && (
|
|
246
|
+
{expanded && editableFields.length > 0 && (
|
|
206
247
|
<div className="bn-testmeta__rows">
|
|
207
248
|
{editableFields.map(({ field, index }) => (
|
|
208
249
|
<div className="bn-testmeta__row" key={index}>
|
package/src/editor/styles.css
CHANGED
|
@@ -723,6 +723,30 @@ html.dark .bn-teststep__view-toggle--compact svg {
|
|
|
723
723
|
--prev-level: 18px;
|
|
724
724
|
}
|
|
725
725
|
|
|
726
|
+
/* ============================================
|
|
727
|
+
TAG BADGES IN HEADINGS
|
|
728
|
+
`@tag` tokens inside headings are painted as compact badges by a ProseMirror
|
|
729
|
+
inline decoration (see src/editor/tagBadge.ts). The text stays editable; only
|
|
730
|
+
the styling is applied.
|
|
731
|
+
============================================ */
|
|
732
|
+
.testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
733
|
+
font-size: 0.78em;
|
|
734
|
+
font-weight: 500;
|
|
735
|
+
line-height: 1.2;
|
|
736
|
+
background: var(--bg-muted);
|
|
737
|
+
color: var(--text-muted);
|
|
738
|
+
border: 1px solid var(--border-light);
|
|
739
|
+
border-radius: 6px;
|
|
740
|
+
padding: 0.05em 0.4em;
|
|
741
|
+
vertical-align: middle;
|
|
742
|
+
white-space: nowrap;
|
|
743
|
+
}
|
|
744
|
+
html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
745
|
+
background: rgba(255, 255, 255, 0.08);
|
|
746
|
+
color: rgba(255, 255, 255, 0.6);
|
|
747
|
+
border-color: rgba(255, 255, 255, 0.12);
|
|
748
|
+
}
|
|
749
|
+
|
|
726
750
|
/* ============================================
|
|
727
751
|
TEST / SUITE METADATA BLOCK
|
|
728
752
|
Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
|
|
@@ -752,8 +776,69 @@ html.dark .bn-teststep__view-toggle--compact svg {
|
|
|
752
776
|
margin-left: 8px;
|
|
753
777
|
}
|
|
754
778
|
|
|
755
|
-
|
|
779
|
+
/* Add button + expand/collapse toggle live in a right-pinned action group. */
|
|
780
|
+
.bn-testmeta__header .bn-testmeta__actions {
|
|
756
781
|
margin-left: auto;
|
|
782
|
+
display: inline-flex;
|
|
783
|
+
align-items: center;
|
|
784
|
+
gap: 2px;
|
|
785
|
+
flex-shrink: 0;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/* Compact one-liner: the metadata rendered as truncated read text, click to
|
|
789
|
+
expand. Takes the remaining header width and ellipsizes when it overflows. */
|
|
790
|
+
.bn-testmeta__summary {
|
|
791
|
+
flex: 1 1 auto;
|
|
792
|
+
min-width: 0;
|
|
793
|
+
overflow: hidden;
|
|
794
|
+
white-space: nowrap;
|
|
795
|
+
text-overflow: ellipsis;
|
|
796
|
+
text-align: left;
|
|
797
|
+
font-family: inherit;
|
|
798
|
+
font-size: 13px;
|
|
799
|
+
color: var(--text-muted);
|
|
800
|
+
background: transparent;
|
|
801
|
+
border: none;
|
|
802
|
+
padding: 0;
|
|
803
|
+
cursor: pointer;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.bn-testmeta__summary:hover {
|
|
807
|
+
color: var(--text-primary);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.bn-testmeta__summary--empty {
|
|
811
|
+
font-style: italic;
|
|
812
|
+
opacity: 0.7;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.bn-testmeta__toggle {
|
|
816
|
+
width: 24px;
|
|
817
|
+
height: 24px;
|
|
818
|
+
display: inline-flex;
|
|
819
|
+
align-items: center;
|
|
820
|
+
justify-content: center;
|
|
821
|
+
color: var(--text-muted);
|
|
822
|
+
background: transparent;
|
|
823
|
+
border: none;
|
|
824
|
+
border-radius: 6px;
|
|
825
|
+
cursor: pointer;
|
|
826
|
+
padding: 0;
|
|
827
|
+
flex-shrink: 0;
|
|
828
|
+
transition: background-color 120ms ease;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
.bn-testmeta__toggle:hover {
|
|
832
|
+
background: var(--step-bg-button-hover);
|
|
833
|
+
color: var(--text-primary);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.bn-testmeta__toggle svg {
|
|
837
|
+
transition: transform 150ms ease;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
.bn-testmeta__toggle--expanded svg {
|
|
841
|
+
transform: rotate(180deg);
|
|
757
842
|
}
|
|
758
843
|
|
|
759
844
|
.bn-testmeta__label {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { detectTags } from "./tagBadge";
|
|
3
|
+
|
|
4
|
+
describe("detectTags", () => {
|
|
5
|
+
it("detects a single tag at the start of the string", () => {
|
|
6
|
+
expect(detectTags("@smoke")).toEqual([{ start: 0, end: 6, tag: "@smoke" }]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("detects a tag that follows a space", () => {
|
|
10
|
+
// "Login flow @smoke" — the @ is at index 11.
|
|
11
|
+
expect(detectTags("Login flow @smoke")).toEqual([
|
|
12
|
+
{ start: 11, end: 17, tag: "@smoke" },
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("detects multiple tags in one title", () => {
|
|
17
|
+
const matches = detectTags("Login flow @smoke @regression");
|
|
18
|
+
expect(matches).toEqual([
|
|
19
|
+
{ start: 11, end: 17, tag: "@smoke" },
|
|
20
|
+
{ start: 18, end: 29, tag: "@regression" },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("detects tags that contain allowed symbols", () => {
|
|
25
|
+
expect(detectTags("@severity:high")).toEqual([
|
|
26
|
+
{ start: 0, end: 14, tag: "@severity:high" },
|
|
27
|
+
]);
|
|
28
|
+
expect(detectTags("@T1234abcd")).toEqual([
|
|
29
|
+
{ start: 0, end: 10, tag: "@T1234abcd" },
|
|
30
|
+
]);
|
|
31
|
+
expect(detectTags("@a-b_c")).toEqual([{ start: 0, end: 6, tag: "@a-b_c" }]);
|
|
32
|
+
expect(detectTags("@group(1)")).toEqual([
|
|
33
|
+
{ start: 0, end: 9, tag: "@group(1)" },
|
|
34
|
+
]);
|
|
35
|
+
expect(detectTags("@key=value")).toEqual([
|
|
36
|
+
{ start: 0, end: 10, tag: "@key=value" },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("detects a tag after a tab character", () => {
|
|
41
|
+
expect(detectTags("title\t@flaky")).toEqual([
|
|
42
|
+
{ start: 6, end: 12, tag: "@flaky" },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not match an email address", () => {
|
|
47
|
+
// The `@` is preceded by a word character, not start/whitespace.
|
|
48
|
+
expect(detectTags("Contact user@example.com")).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not match a bare @ with no body", () => {
|
|
52
|
+
expect(detectTags("just an @ symbol")).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not match @ in the middle of a word", () => {
|
|
56
|
+
expect(detectTags("foo@bar")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns an empty array for text without tags", () => {
|
|
60
|
+
expect(detectTags("A plain heading title")).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("ignores a trailing dot that is not a valid tag terminator", () => {
|
|
64
|
+
// The tag must end with a word char or `)`, so the trailing `.` is excluded.
|
|
65
|
+
expect(detectTags("end of @smoke.")).toEqual([
|
|
66
|
+
{ start: 7, end: 13, tag: "@smoke" },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does not keep stale regex state across calls", () => {
|
|
71
|
+
// Guards against a shared lastIndex leaking between invocations.
|
|
72
|
+
expect(detectTags("@one")).toEqual([{ start: 0, end: 4, tag: "@one" }]);
|
|
73
|
+
expect(detectTags("@two")).toEqual([{ start: 0, end: 4, tag: "@two" }]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
3
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
4
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tags are tokens that start with `@` and may contain alphanumerics plus a few
|
|
8
|
+
* symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`.
|
|
9
|
+
*
|
|
10
|
+
* This is a faithful JS port of the backend (Ruby) tag regexes:
|
|
11
|
+
* TAG_PREFIX_REGEXP = /(?:^|[ \t])/
|
|
12
|
+
* TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/
|
|
13
|
+
* TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@<allowed>)/
|
|
14
|
+
*
|
|
15
|
+
* `\w` already covers `[0-9_]`, so the symbol class is kept minimal while
|
|
16
|
+
* staying faithful to the allowed characters. A tag must be preceded by the
|
|
17
|
+
* start of the string or whitespace — so email-like text (`user@example.com`)
|
|
18
|
+
* is correctly ignored.
|
|
19
|
+
*/
|
|
20
|
+
const TAG_ALLOWED_SYMBOLS = String.raw`[\w=\-_().:&]*[\w)]`;
|
|
21
|
+
|
|
22
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
23
|
+
export const TAGS_DETECT_REGEXP = new RegExp(
|
|
24
|
+
String.raw`(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`,
|
|
25
|
+
"g",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export interface TagMatch {
|
|
29
|
+
/** Offset of the `@` within the scanned string (the leading space is excluded). */
|
|
30
|
+
start: number;
|
|
31
|
+
/** Offset just past the last character of the tag. */
|
|
32
|
+
end: number;
|
|
33
|
+
/** The matched tag text, including the leading `@`. */
|
|
34
|
+
tag: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
39
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
40
|
+
*/
|
|
41
|
+
export function detectTags(text: string): TagMatch[] {
|
|
42
|
+
const matches: TagMatch[] = [];
|
|
43
|
+
// Use a fresh regex each call so the shared `lastIndex` state never leaks
|
|
44
|
+
// between invocations.
|
|
45
|
+
const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g");
|
|
46
|
+
let match: RegExpExecArray | null;
|
|
47
|
+
while ((match = regexp.exec(text)) !== null) {
|
|
48
|
+
const tag = match[1];
|
|
49
|
+
// The match may include a leading space/tab consumed by the prefix; the tag
|
|
50
|
+
// itself starts that many characters into the overall match.
|
|
51
|
+
const start = match.index + (match[0].length - tag.length);
|
|
52
|
+
matches.push({ start, end: start + tag.length, tag });
|
|
53
|
+
// Defensive guard against a zero-length match looping forever (a tag always
|
|
54
|
+
// starts with `@`, so this should never trigger).
|
|
55
|
+
if (regexp.lastIndex === match.index) {
|
|
56
|
+
regexp.lastIndex++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return matches;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tagBadgePluginKey = new PluginKey<DecorationSet>("testomatioTagBadge");
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build inline decorations for every `@tag` found inside heading blocks. Tags
|
|
66
|
+
* are only *painted* — the underlying text is untouched, so markdown
|
|
67
|
+
* serialization round-trips unchanged.
|
|
68
|
+
*/
|
|
69
|
+
function buildHeadingTagDecorations(doc: PMNode): DecorationSet {
|
|
70
|
+
const decorations: Decoration[] = [];
|
|
71
|
+
|
|
72
|
+
doc.descendants((node, pos) => {
|
|
73
|
+
if (node.type.name !== "heading") {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Walk the heading's inline children so positions stay correct even when it
|
|
78
|
+
// contains links or inline images alongside text.
|
|
79
|
+
node.forEach((child, offset) => {
|
|
80
|
+
if (!child.isText || !child.text) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// `pos + 1` steps inside the heading content node; `offset` is the child's
|
|
84
|
+
// position relative to the node's content.
|
|
85
|
+
const base = pos + 1 + offset;
|
|
86
|
+
for (const { start, end } of detectTags(child.text)) {
|
|
87
|
+
decorations.push(
|
|
88
|
+
Decoration.inline(base + start, base + end, {
|
|
89
|
+
class: "bn-tag-badge",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Headings only hold inline content; no need to descend further.
|
|
96
|
+
return false;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return DecorationSet.create(doc, decorations);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tagBadgePlugin(): Plugin<DecorationSet> {
|
|
103
|
+
return new Plugin<DecorationSet>({
|
|
104
|
+
key: tagBadgePluginKey,
|
|
105
|
+
state: {
|
|
106
|
+
init: (_config, state) => buildHeadingTagDecorations(state.doc),
|
|
107
|
+
apply: (tr, value) =>
|
|
108
|
+
tr.docChanged ? buildHeadingTagDecorations(tr.doc) : value,
|
|
109
|
+
},
|
|
110
|
+
props: {
|
|
111
|
+
decorations(state) {
|
|
112
|
+
return tagBadgePluginKey.getState(state);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
120
|
+
*
|
|
121
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
122
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
123
|
+
*
|
|
124
|
+
* ```ts
|
|
125
|
+
* useCreateBlockNote({
|
|
126
|
+
* schema: customSchema,
|
|
127
|
+
* extensions: [tagBadgeExtension()],
|
|
128
|
+
* });
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export class TagBadgeExtension extends BlockNoteExtension {
|
|
132
|
+
static key() {
|
|
133
|
+
return "tagBadge";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
constructor() {
|
|
137
|
+
super();
|
|
138
|
+
this.addProsemirrorPlugin(tagBadgePlugin());
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
143
|
+
export const tagBadgeExtension = () => new TagBadgeExtension();
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,14 @@ export {
|
|
|
15
15
|
} from "./editor/testMetaFields";
|
|
16
16
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
17
17
|
|
|
18
|
+
export {
|
|
19
|
+
tagBadgeExtension,
|
|
20
|
+
TagBadgeExtension,
|
|
21
|
+
TAGS_DETECT_REGEXP,
|
|
22
|
+
detectTags,
|
|
23
|
+
type TagMatch,
|
|
24
|
+
} from "./editor/tagBadge";
|
|
25
|
+
|
|
18
26
|
export {
|
|
19
27
|
blocksToMarkdown,
|
|
20
28
|
markdownToBlocks,
|