testomatio-editor-blocks 0.1.0 → 0.1.2
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 +46 -0
- package/package/editor/customMarkdownConverter.js +28 -17
- package/package/editor/customSchema.js +263 -16
- package/package/editor/stepAutocomplete.d.ts +35 -0
- package/package/editor/stepAutocomplete.js +97 -0
- package/package/editor/stepImageUpload.d.ts +5 -0
- package/package/editor/stepImageUpload.js +7 -0
- package/package/index.d.ts +2 -0
- package/package/index.js +2 -0
- package/package/styles.css +52 -0
- package/package.json +1 -1
- package/src/App.css +41 -0
- package/src/App.tsx +266 -8
- package/src/editor/customMarkdownConverter.test.ts +59 -25
- package/src/editor/customMarkdownConverter.ts +30 -17
- package/src/editor/customSchema.test.ts +8 -0
- package/src/editor/customSchema.tsx +369 -12
- package/src/editor/stepAutocomplete.test.ts +103 -0
- package/src/editor/stepAutocomplete.tsx +143 -0
- package/src/editor/stepImageUpload.test.ts +25 -0
- package/src/editor/stepImageUpload.ts +11 -0
- package/src/editor/styles.css +52 -0
- package/src/index.ts +15 -0
package/README.md
CHANGED
|
@@ -110,6 +110,52 @@ 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 uses BlockNote's `uploadFile` handler you pass to `useCreateBlockNote`.
|
|
131
|
+
// No extra setup is required for step fields.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Step suggestions accept either an array of `{ id, title, ... }` or the JSON:API shape:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"data": [
|
|
139
|
+
{
|
|
140
|
+
"id": "145",
|
|
141
|
+
"type": "step",
|
|
142
|
+
"attributes": {
|
|
143
|
+
"title": "Donec placerat, dui vitae",
|
|
144
|
+
"description": null,
|
|
145
|
+
"kind": "manual",
|
|
146
|
+
"labels": [],
|
|
147
|
+
"keywords": [],
|
|
148
|
+
"usage-count": 23,
|
|
149
|
+
"comments-count": 0,
|
|
150
|
+
"is-snippet": null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
When a user types in Step Title, autocomplete filters these titles; Tab/Enter/Ctrl/Cmd+Space or the ⌄ button will insert the selection.
|
|
158
|
+
|
|
113
159
|
## Running Tests
|
|
114
160
|
|
|
115
161
|
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
|
|
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
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
204
|
-
var _a
|
|
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 = (
|
|
207
|
-
|
|
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
|
-
|
|
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" })
|
|
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
|
|
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` : ``;
|
|
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,97 @@
|
|
|
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(() => {
|
|
10
|
+
if (cachedSuggestions.length > 0) {
|
|
11
|
+
return cachedSuggestions;
|
|
12
|
+
}
|
|
13
|
+
if (globalFetcher) {
|
|
14
|
+
const result = globalFetcher();
|
|
15
|
+
if (!result || typeof result.then !== "function") {
|
|
16
|
+
const normalized = normalizeStepSuggestions(result);
|
|
17
|
+
cachedSuggestions = normalized;
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return [];
|
|
22
|
+
});
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (suggestions.length > 0) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (!globalFetcher) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
let cancelled = false;
|
|
31
|
+
Promise.resolve(globalFetcher())
|
|
32
|
+
.then((result) => normalizeStepSuggestions(result))
|
|
33
|
+
.then((items) => {
|
|
34
|
+
if (cancelled)
|
|
35
|
+
return;
|
|
36
|
+
cachedSuggestions = items;
|
|
37
|
+
setSuggestions(items);
|
|
38
|
+
})
|
|
39
|
+
.catch((error) => console.error("Failed to fetch step suggestions", error));
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
};
|
|
43
|
+
}, [suggestions.length]);
|
|
44
|
+
return suggestions;
|
|
45
|
+
}
|
|
46
|
+
export function parseStepsFromJsonApi(document) {
|
|
47
|
+
const resources = Array.isArray(document) ? document : document === null || document === void 0 ? void 0 : document.data;
|
|
48
|
+
if (!Array.isArray(resources) || resources.length === 0) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
return resources
|
|
52
|
+
.map((resource) => normalizeJsonApiResource(resource))
|
|
53
|
+
.filter((value) => Boolean(value));
|
|
54
|
+
}
|
|
55
|
+
function normalizeStepSuggestions(steps) {
|
|
56
|
+
if (!steps)
|
|
57
|
+
return [];
|
|
58
|
+
if (Array.isArray(steps)) {
|
|
59
|
+
if (steps.length === 0)
|
|
60
|
+
return [];
|
|
61
|
+
if (isStepSuggestionArray(steps))
|
|
62
|
+
return steps;
|
|
63
|
+
return parseStepsFromJsonApi(steps);
|
|
64
|
+
}
|
|
65
|
+
return parseStepsFromJsonApi(steps);
|
|
66
|
+
}
|
|
67
|
+
function normalizeJsonApiResource(resource) {
|
|
68
|
+
var _a, _b, _c, _d, _e, _f;
|
|
69
|
+
if (!resource)
|
|
70
|
+
return null;
|
|
71
|
+
const attrs = resource.attributes;
|
|
72
|
+
const id = resource.id;
|
|
73
|
+
const title = (_a = attrs === null || attrs === void 0 ? void 0 : attrs.title) !== null && _a !== void 0 ? _a : "";
|
|
74
|
+
if (!id || !title)
|
|
75
|
+
return null;
|
|
76
|
+
return {
|
|
77
|
+
id: String(id),
|
|
78
|
+
title: String(title),
|
|
79
|
+
description: (_b = attrs === null || attrs === void 0 ? void 0 : attrs.description) !== null && _b !== void 0 ? _b : null,
|
|
80
|
+
kind: (_c = attrs === null || attrs === void 0 ? void 0 : attrs.kind) !== null && _c !== void 0 ? _c : null,
|
|
81
|
+
usageCount: coerceNumber(attrs === null || attrs === void 0 ? void 0 : attrs["usage-count"]),
|
|
82
|
+
commentsCount: coerceNumber(attrs === null || attrs === void 0 ? void 0 : attrs["comments-count"]),
|
|
83
|
+
isSnippet: (_d = attrs === null || attrs === void 0 ? void 0 : attrs["is-snippet"]) !== null && _d !== void 0 ? _d : null,
|
|
84
|
+
labels: (_e = attrs === null || attrs === void 0 ? void 0 : attrs.labels) !== null && _e !== void 0 ? _e : [],
|
|
85
|
+
keywords: (_f = attrs === null || attrs === void 0 ? void 0 : attrs.keywords) !== null && _f !== void 0 ? _f : [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function isStepSuggestionArray(value) {
|
|
89
|
+
var _a;
|
|
90
|
+
return Array.isArray(value) && value.length > 0 && typeof ((_a = value[0]) === null || _a === void 0 ? void 0 : _a.title) === "string";
|
|
91
|
+
}
|
|
92
|
+
function coerceNumber(value) {
|
|
93
|
+
if (value === null || value === undefined)
|
|
94
|
+
return null;
|
|
95
|
+
const num = Number(value);
|
|
96
|
+
return Number.isFinite(num) ? num : null;
|
|
97
|
+
}
|
package/package/index.d.ts
CHANGED
|
@@ -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, setImageUploadHandler, 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, setImageUploadHandler, } from "./editor/stepImageUpload";
|
|
3
5
|
export const testomatioEditorClassName = "markdown testomatio-editor";
|