testomatio-editor-blocks 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -110,6 +110,57 @@ return (
110
110
  - `testCase`: rich-text wrapper with status and reference metadata.
111
111
  - `testStep`: inline WYSIWYG inputs for Step Title, Data, and Expected Result with bold/italic/underline formatting.
112
112
 
113
+ ## Step Autocomplete & Image Upload Hooks
114
+
115
+ Configure everything via JS—no React providers required:
116
+
117
+ ```ts
118
+ import {
119
+ customSchema,
120
+ setGlobalStepSuggestionsFetcher,
121
+ setGlobalStepImageUploadHandler,
122
+ } from "testomatio-editor-blocks";
123
+
124
+ // Step suggestions (fetch or return an array of { id, title, ... })
125
+ setGlobalStepSuggestionsFetcher(async () => {
126
+ const res = await fetch("https://api.testomatio.com/v1/steps");
127
+ return res.json();
128
+ });
129
+
130
+ // Image upload handler used by Step Data / Expected fields
131
+ setGlobalStepImageUploadHandler(async (image: Blob) => {
132
+ const formData = new FormData();
133
+ formData.append("file", image);
134
+ const res = await fetch("https://api.testomatio.com/v1/uploads", { method: "POST", body: formData });
135
+ return res.json(); // must resolve to { url: "https://..." }
136
+ });
137
+ ```
138
+
139
+ Step suggestions accept either an array of `{ id, title, ... }` or the JSON:API shape:
140
+
141
+ ```json
142
+ {
143
+ "data": [
144
+ {
145
+ "id": "145",
146
+ "type": "step",
147
+ "attributes": {
148
+ "title": "Donec placerat, dui vitae",
149
+ "description": null,
150
+ "kind": "manual",
151
+ "labels": [],
152
+ "keywords": [],
153
+ "usage-count": 23,
154
+ "comments-count": 0,
155
+ "is-snippet": null
156
+ }
157
+ }
158
+ ]
159
+ }
160
+ ```
161
+
162
+ When a user types in Step Title, autocomplete filters these titles; Tab/Enter/Ctrl/Cmd+Space or the ⌄ button will insert the selection.
163
+
113
164
  ## Running Tests
114
165
 
115
166
  Vitest covers the Markdown/block converter. Run the suite with:
@@ -273,7 +273,7 @@ function serializeBlock(block, ctx, orderedIndex) {
273
273
  const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
274
274
  if (normalizedExpected.length > 0) {
275
275
  const expectedLines = normalizedExpected.split(/\r?\n/);
276
- const label = "*Expected Result*";
276
+ const label = "*Expected*";
277
277
  expectedLines.forEach((expectedLine, index) => {
278
278
  const trimmedLine = expectedLine.trim();
279
279
  if (trimmedLine.length === 0) {
@@ -611,7 +611,16 @@ function parseTestStep(lines, index) {
611
611
  if (!trimmed.startsWith("* ") && !trimmed.startsWith("- ")) {
612
612
  return null;
613
613
  }
614
- const stepTitle = unescapeMarkdown(trimmed.slice(2)).trim();
614
+ const rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
615
+ const titleImages = [];
616
+ const titleWithPlaceholders = rawTitle
617
+ .replace(/!\[[^\]]*\]\(([^)]+)\)/g, (match) => {
618
+ titleImages.push(match);
619
+ return "!";
620
+ })
621
+ .replace(/\s{2,}/g, " ")
622
+ .trim();
623
+ const isLikelyStep = /^step\b/i.test(titleWithPlaceholders) || titleImages.length > 0;
615
624
  const stepDataLines = [];
616
625
  let expectedResult = "";
617
626
  let next = index + 1;
@@ -678,22 +687,24 @@ function parseTestStep(lines, index) {
678
687
  .map((line) => line.trimEnd())
679
688
  .join("\n")
680
689
  .trim();
681
- // Only parse as test step if there's expected result or data content
682
- if (expectedResult || stepData) {
683
- return {
684
- block: {
685
- type: "testStep",
686
- props: {
687
- stepTitle,
688
- stepData,
689
- expectedResult,
690
- },
691
- children: [],
692
- },
693
- nextIndex: next,
694
- };
690
+ if (!isLikelyStep && !expectedResult && stepDataLines.length === 0) {
691
+ return null;
695
692
  }
696
- return null;
693
+ const stepDataWithImages = [stepData, titleImages.join("\n")]
694
+ .filter(Boolean)
695
+ .join(stepData ? "\n" : "");
696
+ return {
697
+ block: {
698
+ type: "testStep",
699
+ props: {
700
+ stepTitle: titleWithPlaceholders,
701
+ stepData: stepDataWithImages,
702
+ expectedResult,
703
+ },
704
+ children: [],
705
+ },
706
+ nextIndex: next,
707
+ };
697
708
  }
698
709
  function parseTestCase(lines, index) {
699
710
  var _a;
@@ -2,7 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { defaultBlockSpecs, defaultProps } from "@blocknote/core";
3
3
  import { BlockNoteSchema } from "@blocknote/core";
4
4
  import { createReactBlockSpec } from "@blocknote/react";
5
- import { useCallback, useEffect, useRef, useState } from "react";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { useStepAutocomplete } from "./stepAutocomplete";
7
+ import { useStepImageUpload } from "./stepImageUpload";
6
8
  function escapeHtml(text) {
7
9
  return text
8
10
  .replace(/&/g, "&")
@@ -160,23 +162,61 @@ const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
160
162
  function escapeMarkdownText(text) {
161
163
  return text.replace(MARKDOWN_ESCAPE_REGEX, "\\$1");
162
164
  }
163
- function StepField({ label, value, placeholder, onChange, autoFocus, multiline = false }) {
165
+ function normalizePlainText(text) {
166
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
167
+ }
168
+ function StepField({ label, value, placeholder, onChange, autoFocus, multiline = false, enableAutocomplete = false, fieldName, enableImageUpload = false, onImageFile, }) {
164
169
  const editorRef = useRef(null);
165
170
  const [isFocused, setIsFocused] = useState(false);
166
171
  const autoFocusRef = useRef(false);
172
+ const [plainTextValue, setPlainTextValue] = useState("");
173
+ const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
174
+ const [showAllSuggestions, setShowAllSuggestions] = useState(false);
175
+ const suggestions = useStepAutocomplete();
176
+ const uploadImage = useStepImageUpload();
177
+ const fileInputRef = useRef(null);
178
+ const [isUploading, setIsUploading] = useState(false);
179
+ const normalizedQuery = normalizePlainText(plainTextValue);
180
+ const filteredSuggestions = useMemo(() => {
181
+ if (!enableAutocomplete) {
182
+ return [];
183
+ }
184
+ const pool = showAllSuggestions || !normalizedQuery
185
+ ? suggestions
186
+ : suggestions.filter((item) => normalizePlainText(item.title).startsWith(normalizedQuery));
187
+ return pool.slice(0, 8);
188
+ }, [enableAutocomplete, normalizedQuery, showAllSuggestions, suggestions]);
189
+ const hasExactMatch = filteredSuggestions.some((item) => normalizePlainText(item.title) === normalizedQuery);
190
+ const shouldShowAutocomplete = enableAutocomplete &&
191
+ isFocused &&
192
+ filteredSuggestions.length > 0 &&
193
+ (!hasExactMatch || showAllSuggestions) &&
194
+ (showAllSuggestions || normalizedQuery.length >= 1);
195
+ useEffect(() => {
196
+ setActiveSuggestionIndex(0);
197
+ }, [normalizedQuery, filteredSuggestions.length, showAllSuggestions]);
198
+ useEffect(() => {
199
+ if (normalizedQuery.length > 0) {
200
+ setShowAllSuggestions(false);
201
+ }
202
+ }, [normalizedQuery]);
167
203
  useEffect(() => {
204
+ var _a;
168
205
  const element = editorRef.current;
169
206
  if (!element || isFocused) {
170
207
  return;
171
208
  }
172
209
  if (value.trim().length === 0) {
173
210
  element.innerHTML = "";
211
+ setPlainTextValue("");
174
212
  }
175
213
  else {
176
214
  element.innerHTML = markdownToHtml(value);
215
+ setPlainTextValue((_a = element.textContent) !== null && _a !== void 0 ? _a : "");
177
216
  }
178
217
  }, [value, isFocused]);
179
218
  const syncValue = useCallback(() => {
219
+ var _a;
180
220
  const element = editorRef.current;
181
221
  if (!element) {
182
222
  return;
@@ -185,47 +225,228 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
185
225
  if (markdown !== value) {
186
226
  onChange(markdown);
187
227
  }
228
+ setPlainTextValue((_a = element.innerText) !== null && _a !== void 0 ? _a : "");
188
229
  if (!markdown && element.innerHTML !== "") {
189
230
  element.innerHTML = "";
190
231
  }
191
232
  }, [onChange, value]);
192
233
  useEffect(() => {
193
- if (autoFocus && !autoFocusRef.current && editorRef.current) {
194
- editorRef.current.focus();
234
+ if (!autoFocus || autoFocusRef.current || !editorRef.current) {
235
+ return;
236
+ }
237
+ autoFocusRef.current = true;
238
+ const element = editorRef.current;
239
+ const focusElement = () => {
240
+ var _a;
241
+ element.focus();
195
242
  setIsFocused(true);
196
- autoFocusRef.current = true;
243
+ const selection = typeof window !== "undefined" ? (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window) : null;
244
+ if (selection) {
245
+ selection.selectAllChildren(element);
246
+ selection.collapseToEnd();
247
+ }
248
+ };
249
+ if (typeof requestAnimationFrame === "function") {
250
+ const frame = requestAnimationFrame(focusElement);
251
+ return () => cancelAnimationFrame(frame);
197
252
  }
253
+ const timeout = setTimeout(focusElement, 0);
254
+ return () => clearTimeout(timeout);
198
255
  }, [autoFocus]);
199
256
  const applyFormat = useCallback((command) => {
200
257
  document.execCommand(command);
201
258
  syncValue();
202
259
  }, [syncValue]);
203
- const handlePaste = useCallback((event) => {
204
- var _a, _b;
260
+ const ensureCaretInEditor = useCallback(() => {
261
+ var _a;
262
+ const element = editorRef.current;
263
+ if (!element) {
264
+ return false;
265
+ }
266
+ const selection = (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window);
267
+ if (!selection) {
268
+ return false;
269
+ }
270
+ if (selection.rangeCount === 0 || !element.contains(selection.anchorNode)) {
271
+ const range = document.createRange();
272
+ range.selectNodeContents(element);
273
+ range.collapse(false);
274
+ selection.removeAllRanges();
275
+ selection.addRange(range);
276
+ }
277
+ element.focus();
278
+ return true;
279
+ }, []);
280
+ const insertImageAtCursor = useCallback((url) => {
281
+ const element = editorRef.current;
282
+ if (!element) {
283
+ return;
284
+ }
285
+ const escapedUrl = escapeHtml(url);
286
+ const imgHtml = `<img src="${escapedUrl}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
287
+ element.focus();
288
+ ensureCaretInEditor();
289
+ document.execCommand("insertHTML", false, imgHtml);
290
+ syncValue();
291
+ }, [ensureCaretInEditor, syncValue]);
292
+ const handleImagePick = useCallback(async () => {
293
+ if (!enableImageUpload || !uploadImage) {
294
+ return;
295
+ }
296
+ const fileInput = fileInputRef.current;
297
+ if (fileInput) {
298
+ fileInput.click();
299
+ }
300
+ }, [enableImageUpload, uploadImage]);
301
+ const handleFileChange = useCallback(async (event) => {
302
+ var _a;
303
+ const file = (_a = event.target.files) === null || _a === void 0 ? void 0 : _a[0];
304
+ if (!file || !uploadImage) {
305
+ return;
306
+ }
307
+ try {
308
+ setIsUploading(true);
309
+ const response = await uploadImage(file);
310
+ if (response === null || response === void 0 ? void 0 : response.url) {
311
+ insertImageAtCursor(response.url);
312
+ }
313
+ }
314
+ catch (error) {
315
+ console.error("Failed to upload image", error);
316
+ }
317
+ finally {
318
+ setIsUploading(false);
319
+ event.target.value = "";
320
+ }
321
+ }, [insertImageAtCursor, uploadImage]);
322
+ const handlePaste = useCallback(async (event) => {
323
+ var _a, _b, _c;
324
+ if ((enableImageUpload && uploadImage) || onImageFile) {
325
+ const items = Array.from((_a = event.clipboardData.items) !== null && _a !== void 0 ? _a : []);
326
+ const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
327
+ const file = imageItem === null || imageItem === void 0 ? void 0 : imageItem.getAsFile();
328
+ if (file) {
329
+ event.preventDefault();
330
+ if (onImageFile) {
331
+ await onImageFile(file);
332
+ return;
333
+ }
334
+ if (enableImageUpload && uploadImage) {
335
+ try {
336
+ setIsUploading(true);
337
+ const result = await uploadImage(file);
338
+ if (result === null || result === void 0 ? void 0 : result.url) {
339
+ ensureCaretInEditor();
340
+ document.execCommand("insertHTML", false, `<img src="${escapeHtml(result.url)}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`);
341
+ syncValue();
342
+ }
343
+ }
344
+ catch (error) {
345
+ console.error("Failed to upload image from paste", error);
346
+ }
347
+ finally {
348
+ setIsUploading(false);
349
+ }
350
+ return;
351
+ }
352
+ }
353
+ }
205
354
  event.preventDefault();
206
- const text = (_b = (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.getData("text/plain")) !== null && _b !== void 0 ? _b : "";
207
- document.execCommand("insertText", false, text);
355
+ const text = (_c = (_b = event.clipboardData) === null || _b === void 0 ? void 0 : _b.getData("text/plain")) !== null && _c !== void 0 ? _c : "";
356
+ const html = markdownToHtml(text);
357
+ ensureCaretInEditor();
358
+ document.execCommand("insertHTML", false, html);
208
359
  syncValue();
209
- }, [syncValue]);
210
- return (_jsxs("div", { className: "bn-step-field", children: [_jsxs("div", { className: "bn-step-field__top", children: [_jsx("span", { className: "bn-step-field__label", children: label }), _jsxs("div", { className: "bn-step-toolbar", "aria-label": `${label} formatting`, children: [_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
360
+ }, [enableImageUpload, ensureCaretInEditor, syncValue, uploadImage]);
361
+ const applySuggestion = useCallback((suggestion) => {
362
+ var _a;
363
+ const escaped = escapeMarkdownText(suggestion.title);
364
+ onChange(escaped);
365
+ setPlainTextValue(suggestion.title);
366
+ setActiveSuggestionIndex(0);
367
+ setShowAllSuggestions(false);
368
+ if (editorRef.current) {
369
+ editorRef.current.innerHTML = markdownToHtml(escaped);
370
+ editorRef.current.focus();
371
+ const selection = typeof window !== "undefined" ? (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window) : null;
372
+ if (selection && editorRef.current.firstChild) {
373
+ const range = document.createRange();
374
+ range.selectNodeContents(editorRef.current);
375
+ range.collapse(false);
376
+ selection.removeAllRanges();
377
+ selection.addRange(range);
378
+ }
379
+ }
380
+ }, [onChange]);
381
+ return (_jsxs("div", { className: "bn-step-field", children: [_jsxs("div", { className: "bn-step-field__top", children: [_jsxs("span", { className: "bn-step-field__label", children: [label, enableAutocomplete && (_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
382
+ var _a;
383
+ event.preventDefault();
384
+ setShowAllSuggestions(true);
385
+ (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
386
+ }, "aria-label": "Show step suggestions", tabIndex: -1, children: "\u2304" }))] }), _jsxs("div", { className: "bn-step-toolbar", "aria-label": `${label} formatting`, children: [_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
211
387
  var _a;
212
388
  event.preventDefault();
213
389
  (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
214
390
  applyFormat("bold");
215
- }, "aria-label": "Bold", children: "B" }), _jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
391
+ }, "aria-label": "Bold", tabIndex: -1, children: "B" }), _jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
216
392
  var _a;
217
393
  event.preventDefault();
218
394
  (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
219
395
  applyFormat("italic");
220
- }, "aria-label": "Italic", children: "I" }), _jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
396
+ }, "aria-label": "Italic", tabIndex: -1, children: "I" }), _jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
221
397
  var _a;
222
398
  event.preventDefault();
223
399
  (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
224
400
  applyFormat("underline");
225
- }, "aria-label": "Underline", children: "U" })] })] }), _jsx("div", { ref: editorRef, className: "bn-step-editor", contentEditable: true, suppressContentEditableWarning: true, "data-placeholder": placeholder, "data-multiline": multiline ? "true" : "false", onFocus: () => setIsFocused(true), onBlur: () => {
401
+ }, "aria-label": "Underline", tabIndex: -1, children: "U" }), enableImageUpload && uploadImage && (_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
402
+ event.preventDefault();
403
+ handleImagePick();
404
+ }, "aria-label": "Insert image", tabIndex: -1, disabled: isUploading, children: "Img" }))] })] }), enableImageUpload && (_jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", style: { display: "none" }, onChange: handleFileChange })), _jsx("div", { ref: editorRef, className: "bn-step-editor", contentEditable: true, suppressContentEditableWarning: true, "data-placeholder": placeholder, "data-multiline": multiline ? "true" : "false", "data-step-field": fieldName, onFocus: () => {
405
+ var _a, _b;
406
+ setIsFocused(true);
407
+ setPlainTextValue((_b = (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.innerText) !== null && _b !== void 0 ? _b : "");
408
+ }, onBlur: () => {
226
409
  setIsFocused(false);
227
410
  syncValue();
228
411
  }, onInput: syncValue, onPaste: handlePaste, onKeyDown: (event) => {
412
+ var _a, _b;
413
+ if ((event.key === "a" || event.key === "A") && (event.metaKey || event.ctrlKey)) {
414
+ event.preventDefault();
415
+ const selection = (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window);
416
+ const node = editorRef.current;
417
+ if (selection && node) {
418
+ const range = document.createRange();
419
+ range.selectNodeContents(node);
420
+ selection.removeAllRanges();
421
+ selection.addRange(range);
422
+ }
423
+ return;
424
+ }
425
+ if (enableAutocomplete && shouldShowAutocomplete) {
426
+ if (event.key === "ArrowDown") {
427
+ event.preventDefault();
428
+ setActiveSuggestionIndex((prev) => prev + 1 >= filteredSuggestions.length ? 0 : prev + 1);
429
+ return;
430
+ }
431
+ if (event.key === "ArrowUp") {
432
+ event.preventDefault();
433
+ setActiveSuggestionIndex((prev) => prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1);
434
+ return;
435
+ }
436
+ if (event.key === "Enter" || event.key === "Tab") {
437
+ event.preventDefault();
438
+ const suggestion = (_b = filteredSuggestions[activeSuggestionIndex]) !== null && _b !== void 0 ? _b : filteredSuggestions[0];
439
+ if (suggestion) {
440
+ applySuggestion(suggestion);
441
+ }
442
+ return;
443
+ }
444
+ }
445
+ if (enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ")) {
446
+ event.preventDefault();
447
+ setShowAllSuggestions(true);
448
+ return;
449
+ }
229
450
  if (event.key === "Enter") {
230
451
  event.preventDefault();
231
452
  if (multiline && event.shiftKey) {
@@ -237,7 +458,12 @@ function StepField({ label, value, placeholder, onChange, autoFocus, multiline =
237
458
  }
238
459
  syncValue();
239
460
  }
240
- } })] }));
461
+ } }), shouldShowAutocomplete && (_jsx("div", { className: "bn-step-suggestions", role: "listbox", "aria-label": `${label} suggestions`, children: filteredSuggestions.map((suggestion, index) => (_jsxs("button", { type: "button", role: "option", "aria-selected": index === activeSuggestionIndex, className: index === activeSuggestionIndex
462
+ ? "bn-step-suggestion bn-step-suggestion--active"
463
+ : "bn-step-suggestion", onMouseDown: (event) => {
464
+ event.preventDefault();
465
+ applySuggestion(suggestion);
466
+ }, tabIndex: -1, children: [_jsx("span", { className: "bn-step-suggestion__title", children: suggestion.title }), typeof suggestion.usageCount === "number" && suggestion.usageCount > 0 && (_jsxs("span", { className: "bn-step-suggestion__meta", children: [suggestion.usageCount, " uses"] }))] }, suggestion.id))) }))] }));
241
467
  }
242
468
  const statusOptions = ["draft", "ready", "blocked"];
243
469
  const statusLabels = {
@@ -272,6 +498,7 @@ const testStepBlock = createReactBlockSpec({
272
498
  const showExpectedField = stepTitle.trim().length > 0 || stepData.trim().length > 0 || expectedResult.trim().length > 0;
273
499
  const [isDataVisible, setIsDataVisible] = useState(() => stepData.trim().length > 0);
274
500
  const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
501
+ const uploadImage = useStepImageUpload();
275
502
  useEffect(() => {
276
503
  if (stepData.trim().length > 0 && !isDataVisible) {
277
504
  setIsDataVisible(true);
@@ -318,7 +545,27 @@ const testStepBlock = createReactBlockSpec({
318
545
  },
319
546
  });
320
547
  }, [editor, block.id, expectedResult]);
321
- return (_jsxs("div", { className: "bn-teststep", children: [_jsx(StepField, { label: "Step Title", value: stepTitle, placeholder: "Describe the action to perform", onChange: handleStepTitleChange, autoFocus: stepTitle.length === 0 }), !isDataVisible && (_jsx("button", { type: "button", className: "bn-teststep__toggle", onClick: handleShowDataField, "aria-expanded": "false", children: "[+ Data]" })), isDataVisible && (_jsx(StepField, { label: "Step Data", value: stepData, placeholder: "Provide additional data about the step", onChange: handleStepDataChange, autoFocus: shouldFocusDataField, multiline: true })), showExpectedField && (_jsx(StepField, { label: "Expected Result", value: expectedResult, placeholder: "What should happen?", onChange: handleExpectedChange, multiline: true }))] }));
548
+ return (_jsxs("div", { className: "bn-teststep", "data-block-id": block.id, children: [_jsx(StepField, { label: "Step Title", value: stepTitle, placeholder: "Describe the action to perform", onChange: handleStepTitleChange, autoFocus: stepTitle.length === 0, enableAutocomplete: true, fieldName: "title", enableImageUpload: false, onImageFile: async (file) => {
549
+ if (!uploadImage) {
550
+ return;
551
+ }
552
+ setIsDataVisible(true);
553
+ setShouldFocusDataField(true);
554
+ try {
555
+ const result = await uploadImage(file);
556
+ if (result === null || result === void 0 ? void 0 : result.url) {
557
+ const nextValue = stepData.trim().length > 0 ? `${stepData}\n![](${result.url})` : `![](${result.url})`;
558
+ editor.updateBlock(block.id, {
559
+ props: {
560
+ stepData: nextValue,
561
+ },
562
+ });
563
+ }
564
+ }
565
+ catch (error) {
566
+ console.error("Failed to upload image to Step Data", error);
567
+ }
568
+ } }), !isDataVisible && (_jsx("button", { type: "button", className: "bn-teststep__toggle", onClick: handleShowDataField, "aria-expanded": "false", tabIndex: -1, children: "+ Step Data" })), isDataVisible && (_jsx(StepField, { label: "Step Data", value: stepData, placeholder: "Provide additional data about the step", onChange: handleStepDataChange, autoFocus: shouldFocusDataField, multiline: true, enableImageUpload: true })), showExpectedField && (_jsx(StepField, { label: "Expected Result", value: expectedResult, placeholder: "What should happen?", onChange: handleExpectedChange, multiline: true, enableImageUpload: true }))] }));
322
569
  },
323
570
  });
324
571
  const testCaseBlock = createReactBlockSpec({
@@ -0,0 +1,35 @@
1
+ export type StepSuggestion = {
2
+ id: string;
3
+ title: string;
4
+ description?: string | null;
5
+ kind?: string | null;
6
+ usageCount?: number | null;
7
+ commentsCount?: number | null;
8
+ isSnippet?: boolean | null;
9
+ labels?: string[];
10
+ keywords?: string[];
11
+ };
12
+ export type StepJsonApiAttributes = {
13
+ title?: string | null;
14
+ description?: string | null;
15
+ kind?: string | null;
16
+ keywords?: string[] | null;
17
+ labels?: string[] | null;
18
+ "usage-count"?: number | string | null;
19
+ "comments-count"?: number | string | null;
20
+ "is-snippet"?: boolean | null;
21
+ };
22
+ export type StepJsonApiResource = {
23
+ id?: string | number | null;
24
+ type?: string | null;
25
+ attributes?: StepJsonApiAttributes | null;
26
+ };
27
+ export type StepJsonApiDocument = {
28
+ data?: StepJsonApiResource[] | null;
29
+ };
30
+ export type StepSuggestionsFetcher = () => Promise<StepInput> | StepInput;
31
+ type StepInput = StepSuggestion[] | StepJsonApiDocument | StepJsonApiResource[] | null | undefined;
32
+ export declare function setGlobalStepSuggestionsFetcher(fetcher: StepSuggestionsFetcher | null): void;
33
+ export declare function useStepAutocomplete(): StepSuggestion[];
34
+ export declare function parseStepsFromJsonApi(document: StepJsonApiDocument | StepJsonApiResource[] | null | undefined): StepSuggestion[];
35
+ export {};
@@ -0,0 +1,84 @@
1
+ import { useEffect, useState } from "react";
2
+ let globalFetcher = null;
3
+ let cachedSuggestions = [];
4
+ export function setGlobalStepSuggestionsFetcher(fetcher) {
5
+ globalFetcher = fetcher;
6
+ cachedSuggestions = [];
7
+ }
8
+ export function useStepAutocomplete() {
9
+ const [suggestions, setSuggestions] = useState(cachedSuggestions);
10
+ useEffect(() => {
11
+ if (suggestions.length > 0) {
12
+ return;
13
+ }
14
+ if (!globalFetcher) {
15
+ return;
16
+ }
17
+ let cancelled = false;
18
+ Promise.resolve(globalFetcher())
19
+ .then((result) => normalizeStepSuggestions(result))
20
+ .then((items) => {
21
+ if (cancelled)
22
+ return;
23
+ cachedSuggestions = items;
24
+ setSuggestions(items);
25
+ })
26
+ .catch((error) => console.error("Failed to fetch step suggestions", error));
27
+ return () => {
28
+ cancelled = true;
29
+ };
30
+ }, [suggestions.length]);
31
+ return suggestions;
32
+ }
33
+ export function parseStepsFromJsonApi(document) {
34
+ const resources = Array.isArray(document) ? document : document === null || document === void 0 ? void 0 : document.data;
35
+ if (!Array.isArray(resources) || resources.length === 0) {
36
+ return [];
37
+ }
38
+ return resources
39
+ .map((resource) => normalizeJsonApiResource(resource))
40
+ .filter((value) => Boolean(value));
41
+ }
42
+ function normalizeStepSuggestions(steps) {
43
+ if (!steps)
44
+ return [];
45
+ if (Array.isArray(steps)) {
46
+ if (steps.length === 0)
47
+ return [];
48
+ if (isStepSuggestionArray(steps))
49
+ return steps;
50
+ return parseStepsFromJsonApi(steps);
51
+ }
52
+ return parseStepsFromJsonApi(steps);
53
+ }
54
+ function normalizeJsonApiResource(resource) {
55
+ var _a, _b, _c, _d, _e, _f;
56
+ if (!resource)
57
+ return null;
58
+ const attrs = resource.attributes;
59
+ const id = resource.id;
60
+ const title = (_a = attrs === null || attrs === void 0 ? void 0 : attrs.title) !== null && _a !== void 0 ? _a : "";
61
+ if (!id || !title)
62
+ return null;
63
+ return {
64
+ id: String(id),
65
+ title: String(title),
66
+ description: (_b = attrs === null || attrs === void 0 ? void 0 : attrs.description) !== null && _b !== void 0 ? _b : null,
67
+ kind: (_c = attrs === null || attrs === void 0 ? void 0 : attrs.kind) !== null && _c !== void 0 ? _c : null,
68
+ usageCount: coerceNumber(attrs === null || attrs === void 0 ? void 0 : attrs["usage-count"]),
69
+ commentsCount: coerceNumber(attrs === null || attrs === void 0 ? void 0 : attrs["comments-count"]),
70
+ isSnippet: (_d = attrs === null || attrs === void 0 ? void 0 : attrs["is-snippet"]) !== null && _d !== void 0 ? _d : null,
71
+ labels: (_e = attrs === null || attrs === void 0 ? void 0 : attrs.labels) !== null && _e !== void 0 ? _e : [],
72
+ keywords: (_f = attrs === null || attrs === void 0 ? void 0 : attrs.keywords) !== null && _f !== void 0 ? _f : [],
73
+ };
74
+ }
75
+ function isStepSuggestionArray(value) {
76
+ var _a;
77
+ return Array.isArray(value) && value.length > 0 && typeof ((_a = value[0]) === null || _a === void 0 ? void 0 : _a.title) === "string";
78
+ }
79
+ function coerceNumber(value) {
80
+ if (value === null || value === undefined)
81
+ return null;
82
+ const num = Number(value);
83
+ return Number.isFinite(num) ? num : null;
84
+ }
@@ -0,0 +1,5 @@
1
+ export type StepImageUploadHandler = (image: Blob) => Promise<{
2
+ url: string;
3
+ }>;
4
+ export declare function setGlobalStepImageUploadHandler(handler: StepImageUploadHandler | null): void;
5
+ export declare function useStepImageUpload(): StepImageUploadHandler | null;
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from "react";
2
+ let globalUploadHandler = null;
3
+ export function setGlobalStepImageUploadHandler(handler) {
4
+ globalUploadHandler = handler;
5
+ }
6
+ export function useStepImageUpload() {
7
+ const [handler, setHandler] = useState(globalUploadHandler);
8
+ useEffect(() => {
9
+ setHandler(globalUploadHandler);
10
+ }, []);
11
+ return handler;
12
+ }
@@ -1,3 +1,5 @@
1
1
  export { customSchema, type CustomSchema, type CustomBlock, type CustomEditor, } from "./editor/customSchema";
2
2
  export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, } from "./editor/customMarkdownConverter";
3
+ export { useStepAutocomplete, parseStepsFromJsonApi, setGlobalStepSuggestionsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
4
+ export { useStepImageUpload, setGlobalStepImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
3
5
  export declare const testomatioEditorClassName = "markdown testomatio-editor";
package/package/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { customSchema, } from "./editor/customSchema";
2
2
  export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
3
+ export { useStepAutocomplete, parseStepsFromJsonApi, setGlobalStepSuggestionsFetcher, } from "./editor/stepAutocomplete";
4
+ export { useStepImageUpload, setGlobalStepImageUploadHandler, } from "./editor/stepImageUpload";
3
5
  export const testomatioEditorClassName = "markdown testomatio-editor";