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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseStepsFromJsonApi } from "./stepAutocomplete";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { setGlobalStepSuggestionsFetcher, useStepAutocomplete } from "./stepAutocomplete";
|
|
6
|
+
|
|
7
|
+
describe("parseStepsFromJsonApi", () => {
|
|
8
|
+
it("converts JSON:API resources into step suggestions", () => {
|
|
9
|
+
const suggestions = parseStepsFromJsonApi({
|
|
10
|
+
data: [
|
|
11
|
+
{
|
|
12
|
+
id: "42",
|
|
13
|
+
type: "step",
|
|
14
|
+
attributes: {
|
|
15
|
+
title: "Click the red button",
|
|
16
|
+
description: "Opens the modal",
|
|
17
|
+
kind: "manual",
|
|
18
|
+
labels: ["ui"],
|
|
19
|
+
keywords: ["button"],
|
|
20
|
+
"usage-count": 12,
|
|
21
|
+
"comments-count": 4,
|
|
22
|
+
"is-snippet": true,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(suggestions).toEqual([
|
|
29
|
+
{
|
|
30
|
+
id: "42",
|
|
31
|
+
title: "Click the red button",
|
|
32
|
+
description: "Opens the modal",
|
|
33
|
+
kind: "manual",
|
|
34
|
+
labels: ["ui"],
|
|
35
|
+
keywords: ["button"],
|
|
36
|
+
usageCount: 12,
|
|
37
|
+
commentsCount: 4,
|
|
38
|
+
isSnippet: true,
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("skips entries without identifiers or titles", () => {
|
|
44
|
+
const suggestions = parseStepsFromJsonApi({
|
|
45
|
+
data: [
|
|
46
|
+
{ id: "100", type: "step", attributes: { title: "" } },
|
|
47
|
+
{ type: "step", attributes: { title: "Valid Step" } },
|
|
48
|
+
{ id: "101", type: "step", attributes: { title: "Another Step" } },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(suggestions).toEqual([
|
|
53
|
+
{
|
|
54
|
+
id: "101",
|
|
55
|
+
title: "Another Step",
|
|
56
|
+
description: null,
|
|
57
|
+
kind: null,
|
|
58
|
+
labels: [],
|
|
59
|
+
keywords: [],
|
|
60
|
+
usageCount: null,
|
|
61
|
+
commentsCount: null,
|
|
62
|
+
isSnippet: null,
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("accepts a raw array of JSON:API resources", () => {
|
|
68
|
+
const suggestions = parseStepsFromJsonApi([
|
|
69
|
+
{
|
|
70
|
+
id: "200",
|
|
71
|
+
type: "step",
|
|
72
|
+
attributes: { title: "Enter credentials" },
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "201",
|
|
76
|
+
type: "step",
|
|
77
|
+
attributes: { title: "Click submit" },
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
expect(suggestions.map((item) => item.title)).toEqual([
|
|
82
|
+
"Enter credentials",
|
|
83
|
+
"Click submit",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reads suggestions from the global fetcher via useStepAutocomplete", () => {
|
|
88
|
+
setGlobalStepSuggestionsFetcher(() => [{ id: "1", title: "Global step" }]);
|
|
89
|
+
|
|
90
|
+
let seen: any[] = [];
|
|
91
|
+
const Probe = () => {
|
|
92
|
+
seen = useStepAutocomplete();
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
renderToStaticMarkup(React.createElement(Probe));
|
|
97
|
+
|
|
98
|
+
expect(seen).toEqual([{ id: "1", title: "Global step" }]);
|
|
99
|
+
|
|
100
|
+
// reset for other tests
|
|
101
|
+
setGlobalStepSuggestionsFetcher(null);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export type StepSuggestion = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string | null;
|
|
7
|
+
kind?: string | null;
|
|
8
|
+
usageCount?: number | null;
|
|
9
|
+
commentsCount?: number | null;
|
|
10
|
+
isSnippet?: boolean | null;
|
|
11
|
+
labels?: string[];
|
|
12
|
+
keywords?: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type StepJsonApiAttributes = {
|
|
16
|
+
title?: string | null;
|
|
17
|
+
description?: string | null;
|
|
18
|
+
kind?: string | null;
|
|
19
|
+
keywords?: string[] | null;
|
|
20
|
+
labels?: string[] | null;
|
|
21
|
+
"usage-count"?: number | string | null;
|
|
22
|
+
"comments-count"?: number | string | null;
|
|
23
|
+
"is-snippet"?: boolean | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type StepJsonApiResource = {
|
|
27
|
+
id?: string | number | null;
|
|
28
|
+
type?: string | null;
|
|
29
|
+
attributes?: StepJsonApiAttributes | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type StepJsonApiDocument = {
|
|
33
|
+
data?: StepJsonApiResource[] | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type StepSuggestionsFetcher = () => Promise<StepInput> | StepInput;
|
|
37
|
+
|
|
38
|
+
type StepInput = StepSuggestion[] | StepJsonApiDocument | StepJsonApiResource[] | null | undefined;
|
|
39
|
+
|
|
40
|
+
let globalFetcher: StepSuggestionsFetcher | null = null;
|
|
41
|
+
let cachedSuggestions: StepSuggestion[] = [];
|
|
42
|
+
|
|
43
|
+
export function setGlobalStepSuggestionsFetcher(fetcher: StepSuggestionsFetcher | null) {
|
|
44
|
+
globalFetcher = fetcher;
|
|
45
|
+
cachedSuggestions = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useStepAutocomplete(): StepSuggestion[] {
|
|
49
|
+
const [suggestions, setSuggestions] = useState<StepSuggestion[]>(() => {
|
|
50
|
+
if (cachedSuggestions.length > 0) {
|
|
51
|
+
return cachedSuggestions;
|
|
52
|
+
}
|
|
53
|
+
if (globalFetcher) {
|
|
54
|
+
const result = globalFetcher();
|
|
55
|
+
if (!result || typeof (result as Promise<unknown>).then !== "function") {
|
|
56
|
+
const normalized = normalizeStepSuggestions(result as StepInput);
|
|
57
|
+
cachedSuggestions = normalized;
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (suggestions.length > 0) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!globalFetcher) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
Promise.resolve(globalFetcher())
|
|
74
|
+
.then((result) => normalizeStepSuggestions(result))
|
|
75
|
+
.then((items) => {
|
|
76
|
+
if (cancelled) return;
|
|
77
|
+
cachedSuggestions = items;
|
|
78
|
+
setSuggestions(items);
|
|
79
|
+
})
|
|
80
|
+
.catch((error) => console.error("Failed to fetch step suggestions", error));
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
cancelled = true;
|
|
84
|
+
};
|
|
85
|
+
}, [suggestions.length]);
|
|
86
|
+
|
|
87
|
+
return suggestions;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseStepsFromJsonApi(
|
|
91
|
+
document: StepJsonApiDocument | StepJsonApiResource[] | null | undefined,
|
|
92
|
+
): StepSuggestion[] {
|
|
93
|
+
const resources = Array.isArray(document) ? document : document?.data;
|
|
94
|
+
if (!Array.isArray(resources) || resources.length === 0) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return resources
|
|
99
|
+
.map((resource) => normalizeJsonApiResource(resource))
|
|
100
|
+
.filter((value): value is StepSuggestion => Boolean(value));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeStepSuggestions(steps?: StepInput): StepSuggestion[] {
|
|
104
|
+
if (!steps) return [];
|
|
105
|
+
|
|
106
|
+
if (Array.isArray(steps)) {
|
|
107
|
+
if (steps.length === 0) return [];
|
|
108
|
+
if (isStepSuggestionArray(steps)) return steps;
|
|
109
|
+
return parseStepsFromJsonApi(steps);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return parseStepsFromJsonApi(steps);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeJsonApiResource(resource: StepJsonApiResource | null | undefined): StepSuggestion | null {
|
|
116
|
+
if (!resource) return null;
|
|
117
|
+
const attrs = resource.attributes;
|
|
118
|
+
const id = resource.id;
|
|
119
|
+
const title = attrs?.title ?? "";
|
|
120
|
+
if (!id || !title) return null;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id: String(id),
|
|
124
|
+
title: String(title),
|
|
125
|
+
description: attrs?.description ?? null,
|
|
126
|
+
kind: attrs?.kind ?? null,
|
|
127
|
+
usageCount: coerceNumber(attrs?.["usage-count"]),
|
|
128
|
+
commentsCount: coerceNumber(attrs?.["comments-count"]),
|
|
129
|
+
isSnippet: attrs?.["is-snippet"] ?? null,
|
|
130
|
+
labels: attrs?.labels ?? [],
|
|
131
|
+
keywords: attrs?.keywords ?? [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isStepSuggestionArray(value: StepInput): value is StepSuggestion[] {
|
|
136
|
+
return Array.isArray(value) && value.length > 0 && typeof (value[0] as StepSuggestion | any)?.title === "string";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function coerceNumber(value: string | number | null | undefined): number | null {
|
|
140
|
+
if (value === null || value === undefined) return null;
|
|
141
|
+
const num = Number(value);
|
|
142
|
+
return Number.isFinite(num) ? num : null;
|
|
143
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { setImageUploadHandler, useStepImageUpload, type StepImageUploadHandler } from "./stepImageUpload";
|
|
5
|
+
|
|
6
|
+
describe("image upload handler hook", () => {
|
|
7
|
+
it("returns the configured upload handler", async () => {
|
|
8
|
+
const handler: StepImageUploadHandler = async () => ({ url: "https://example.com/image.png" });
|
|
9
|
+
setImageUploadHandler(handler);
|
|
10
|
+
|
|
11
|
+
let seen: any;
|
|
12
|
+
const Probe = () => {
|
|
13
|
+
seen = useStepImageUpload();
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
renderToStaticMarkup(React.createElement(Probe));
|
|
18
|
+
|
|
19
|
+
expect(seen).toBe(handler);
|
|
20
|
+
const result = await (seen as StepImageUploadHandler)(new Blob(["demo"], { type: "image/png" }));
|
|
21
|
+
expect(result).toEqual({ url: "https://example.com/image.png" });
|
|
22
|
+
|
|
23
|
+
setImageUploadHandler(null);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type StepImageUploadHandler = (image: Blob) => Promise<{ url: string }>;
|
|
2
|
+
|
|
3
|
+
let imageUploadHandler: StepImageUploadHandler | null = null;
|
|
4
|
+
|
|
5
|
+
export function setImageUploadHandler(handler: StepImageUploadHandler | null) {
|
|
6
|
+
imageUploadHandler = handler;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useStepImageUpload(): StepImageUploadHandler | null {
|
|
10
|
+
return imageUploadHandler;
|
|
11
|
+
}
|
package/src/editor/styles.css
CHANGED
|
@@ -140,6 +140,7 @@
|
|
|
140
140
|
display: flex;
|
|
141
141
|
flex-direction: column;
|
|
142
142
|
gap: 0.35rem;
|
|
143
|
+
position: relative;
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
.bn-step-field__top {
|
|
@@ -150,6 +151,9 @@
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
.bn-step-field__label {
|
|
154
|
+
display: inline-flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
gap: 0.35rem;
|
|
153
157
|
font-size: 0.8rem;
|
|
154
158
|
text-transform: uppercase;
|
|
155
159
|
letter-spacing: 0.08em;
|
|
@@ -216,6 +220,54 @@
|
|
|
216
220
|
pointer-events: none;
|
|
217
221
|
}
|
|
218
222
|
|
|
223
|
+
.bn-step-suggestions {
|
|
224
|
+
position: absolute;
|
|
225
|
+
left: 0;
|
|
226
|
+
right: 0;
|
|
227
|
+
top: calc(100% + 0.25rem);
|
|
228
|
+
background: rgba(255, 255, 255, 0.98);
|
|
229
|
+
border-radius: 0.65rem;
|
|
230
|
+
border: 1px solid rgba(37, 99, 235, 0.25);
|
|
231
|
+
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.16);
|
|
232
|
+
padding: 0.25rem;
|
|
233
|
+
display: flex;
|
|
234
|
+
flex-direction: column;
|
|
235
|
+
gap: 0.15rem;
|
|
236
|
+
z-index: 5;
|
|
237
|
+
max-height: 12rem;
|
|
238
|
+
overflow-y: auto;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.bn-step-suggestion {
|
|
242
|
+
border: none;
|
|
243
|
+
background: transparent;
|
|
244
|
+
border-radius: 0.5rem;
|
|
245
|
+
padding: 0.45rem 0.75rem;
|
|
246
|
+
text-align: left;
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
justify-content: space-between;
|
|
250
|
+
gap: 0.5rem;
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
color: #0f172a;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.bn-step-suggestion:hover,
|
|
256
|
+
.bn-step-suggestion--active {
|
|
257
|
+
background: rgba(59, 130, 246, 0.1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.bn-step-suggestion__title {
|
|
261
|
+
font-weight: 600;
|
|
262
|
+
font-size: 0.95rem;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.bn-step-suggestion__meta {
|
|
266
|
+
font-size: 0.75rem;
|
|
267
|
+
font-weight: 600;
|
|
268
|
+
color: rgba(15, 23, 42, 0.65);
|
|
269
|
+
}
|
|
270
|
+
|
|
219
271
|
.bn-inline-image {
|
|
220
272
|
display: block;
|
|
221
273
|
max-width: 100%;
|
package/src/index.ts
CHANGED
|
@@ -12,4 +12,19 @@ export {
|
|
|
12
12
|
type CustomPartialBlock,
|
|
13
13
|
} from "./editor/customMarkdownConverter";
|
|
14
14
|
|
|
15
|
+
export {
|
|
16
|
+
useStepAutocomplete,
|
|
17
|
+
parseStepsFromJsonApi,
|
|
18
|
+
setGlobalStepSuggestionsFetcher,
|
|
19
|
+
type StepSuggestion,
|
|
20
|
+
type StepJsonApiDocument,
|
|
21
|
+
type StepJsonApiResource,
|
|
22
|
+
} from "./editor/stepAutocomplete";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
useStepImageUpload,
|
|
26
|
+
setImageUploadHandler,
|
|
27
|
+
type StepImageUploadHandler,
|
|
28
|
+
} from "./editor/stepImageUpload";
|
|
29
|
+
|
|
15
30
|
export const testomatioEditorClassName = "markdown testomatio-editor";
|