import { createReactBlockSpec } from "@blocknote/react"; import { useEffect, useRef, useState } from "react"; import { getMetaFieldSuggestions } from "../testMetaFields"; export type MetaField = { key: string; value: string }; const ID_KEYS = new Set(["id"]); function parseMetaFields(raw: unknown): MetaField[] { if (typeof raw !== "string" || raw.trim().length === 0) { return []; } try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) { return []; } return parsed .filter((item) => item && typeof item === "object") .map((item) => ({ key: typeof item.key === "string" ? item.key : "", value: typeof item.value === "string" ? item.value : "", })); } catch { return []; } } export function serializeMetaFields(fields: MetaField[]): string { return JSON.stringify(fields); } type TestEditorLike = { document: any[]; getTextCursorPosition?: () => { block?: { id?: string } }; insertBlocks: ( blocks: any[], referenceId: string, placement: "before" | "after", ) => any[]; }; /** Reads the `labels` value from the first suite `testMeta` block, if any. */ function suiteLabelsFromDocument(document: any[]): string { for (const block of document) { if (block?.type !== "testMeta") continue; if ((block.props as any)?.metaKind !== "suite") continue; const fields = parseMetaFields((block.props as any)?.metaFields); const labels = fields.find((f) => f.key.trim().toLowerCase() === "labels"); if (labels) return labels.value; } return ""; } /** * Insert a new test: a `testMeta` panel * seeded with default metadata — `type: manual`, `priority: normal`, and `labels` * copied from the suite block — followed by an empty H2 heading for the test title. * * Inserts at the text cursor when the editor is focused, otherwise at the end of * the document. Returns the title heading's block id (for focusing), or null. */ export function addTestBlock(editor: TestEditorLike): string | null { const fields: MetaField[] = [ { key: "type", value: "manual" }, { key: "priority", value: "normal" }, { key: "labels", value: suiteLabelsFromDocument(editor.document) }, ]; const metaBlock = { type: "testMeta" as const, props: { metaKind: "test", metaFields: serializeMetaFields(fields), metaInline: false, }, children: [], }; const titleHeading = { type: "heading" as const, props: { level: 2 }, content: [], children: [], }; // Prefer the text cursor position — BlockNote keeps it in the editor state even // after the editor blurs (e.g. when a toolbar button is clicked), so the test // lands where the user left the caret. Fall back to the document end only when // there is no cursor at all. const docs = editor.document; let referenceId: string | undefined; try { referenceId = editor.getTextCursorPosition?.().block?.id; } catch { referenceId = undefined; } if (!referenceId) referenceId = docs[docs.length - 1]?.id; if (!referenceId) return null; const inserted = editor.insertBlocks([metaBlock, titleHeading], referenceId, "after"); return inserted?.[1]?.id ?? null; } /** * The `` / `` header: a card that folds open into * its fields. Shut, it shows the id, the first filled field and how many more. */ export function TestMetaCard({ kind, fields, onChange, readOnly }: TestMetaCardProps) { const id = fields.find(isIdField)?.value; const rows = fields .map((field, index) => ({ field, index })) .filter(({ field }) => !isIdField(field)); const filled = rows .filter(({ field }) => field.key.trim() && field.value.trim()) .map(({ field }) => `${field.key}: ${field.value}`); const summary = filled[0]; const more = filled.length - 1; const [open, setOpen] = useState(() => !summary && rows.length > 0); const update = (index: number, patch: Partial) => onChange(fields.map((field, i) => (i === index ? { ...field, ...patch } : field))); const add = (key: string) => { onChange([...fields, { key, value: "" }]); setOpen(true); }; return (
`${field.key}: ${field.value}`).join("\n")} >
{!readOnly && ( field.key)} onPick={add} /> )}
{open && (
{rows.length === 0 &&

No details yet.

} {rows.map(({ field, index }) => (
update(index, { key: event.target.value })} /> update(index, { value: event.target.value })} /> {!readOnly && ( )}
))}
)}
); } function AddFieldMenu({ kind, usedKeys, onPick }: AddFieldMenuProps) { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); useEffect(() => { if (!isOpen) return; const handleMouseDown = (event: MouseEvent) => { if (containerRef.current?.contains(event.target as Node)) return; setIsOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setIsOpen(false); }; document.addEventListener("mousedown", handleMouseDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("mousedown", handleMouseDown); document.removeEventListener("keydown", handleKeyDown); }; }, [isOpen]); const taken = new Set(usedKeys.map((key) => key.trim().toLowerCase())); const options = getMetaFieldSuggestions(kind).filter( (option) => !taken.has(option.key.trim().toLowerCase()), ); const pick = (key: string) => { onPick(key); setIsOpen(false); }; return (
{isOpen && (
{options.map((option) => ( ))} {options.length > 0 &&
}
)}
); } export const testMetaBlock = createReactBlockSpec( { type: "testMeta", content: "none", propSchema: { // "test" | "suite" — which keyword the comment opened with. metaKind: { default: "test", }, // JSON-encoded MetaField[] so insertion order is preserved. metaFields: { default: "[]", }, // true when the source comment was a one-liner (``). metaInline: { default: false, }, }, }, { render: ({ block, editor }) => (
editor.updateBlock(block.id, { props: { metaFields: serializeMetaFields(next) } as any, }) } />
), }, ); function isIdField(field: MetaField) { return ID_KEYS.has(field.key.trim().toLowerCase()); } const KIND_LABEL: Record = { test: "Test", suite: "Suite" }; type MetaKind = "test" | "suite"; export type TestMetaCardProps = { kind: MetaKind; fields: MetaField[]; onChange: (fields: MetaField[]) => void; readOnly?: boolean; }; type AddFieldMenuProps = { kind: MetaKind; usedKeys: string[]; onPick: (key: string) => void; };