testomatio-editor-blocks 0.1.2 → 0.2.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 +13 -6
- package/package/editor/blocks/markdown.d.ts +5 -0
- package/package/editor/blocks/markdown.js +160 -0
- package/package/editor/blocks/snippet.d.ts +38 -0
- package/package/editor/blocks/snippet.js +65 -0
- package/package/editor/blocks/step.d.ts +32 -0
- package/package/editor/blocks/step.js +97 -0
- package/package/editor/blocks/stepField.d.ts +26 -0
- package/package/editor/blocks/stepField.js +316 -0
- package/package/editor/customMarkdownConverter.js +111 -80
- package/package/editor/customSchema.d.ts +31 -45
- package/package/editor/customSchema.js +6 -616
- package/package/editor/snippetAutocomplete.d.ts +28 -0
- package/package/editor/snippetAutocomplete.js +94 -0
- package/package/editor/stepAutocomplete.d.ts +1 -1
- package/package/editor/stepAutocomplete.js +1 -1
- package/package/index.d.ts +4 -1
- package/package/index.js +4 -1
- package/package/styles.css +57 -0
- package/package.json +1 -1
- package/src/App.tsx +143 -41
- package/src/editor/blocks/blocks.test.ts +22 -0
- package/src/editor/blocks/markdown.ts +199 -0
- package/src/editor/blocks/snippet.tsx +109 -0
- package/src/editor/blocks/step.tsx +175 -0
- package/src/editor/blocks/stepField.tsx +487 -0
- package/src/editor/customMarkdownConverter.test.ts +121 -36
- package/src/editor/customMarkdownConverter.ts +128 -85
- package/src/editor/customSchema.tsx +6 -935
- package/src/editor/snippetAutocomplete.test.ts +54 -0
- package/src/editor/snippetAutocomplete.ts +133 -0
- package/src/editor/stepAutocomplete.test.ts +3 -3
- package/src/editor/stepAutocomplete.tsx +1 -1
- package/src/editor/styles.css +57 -0
- package/src/index.ts +4 -1
- package/src/editor/customSchema.test.ts +0 -47
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import {
|
|
5
|
+
setSnippetFetcher,
|
|
6
|
+
useSnippetAutocomplete,
|
|
7
|
+
parseSnippetsFromJsonApi,
|
|
8
|
+
} from "./snippetAutocomplete";
|
|
9
|
+
|
|
10
|
+
describe("snippet autocomplete", () => {
|
|
11
|
+
it("parses JSON:API snippet resources", () => {
|
|
12
|
+
const suggestions = parseSnippetsFromJsonApi({
|
|
13
|
+
data: [
|
|
14
|
+
{
|
|
15
|
+
id: "501",
|
|
16
|
+
type: "snippet",
|
|
17
|
+
attributes: {
|
|
18
|
+
title: "Login setup",
|
|
19
|
+
body: "Open /login",
|
|
20
|
+
description: "Ready state",
|
|
21
|
+
"usage-count": 4,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(suggestions).toEqual([
|
|
28
|
+
{
|
|
29
|
+
id: "501",
|
|
30
|
+
title: "Login setup",
|
|
31
|
+
body: "Open /login",
|
|
32
|
+
description: "Ready state",
|
|
33
|
+
usageCount: 4,
|
|
34
|
+
isSnippet: true,
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("reads suggestions from the global snippet fetcher via useSnippetAutocomplete", () => {
|
|
40
|
+
setSnippetFetcher(() => [{ id: "1", title: "Reusable snippet" }]);
|
|
41
|
+
|
|
42
|
+
let seen: any[] = [];
|
|
43
|
+
const Probe = () => {
|
|
44
|
+
seen = useSnippetAutocomplete();
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
renderToStaticMarkup(React.createElement(Probe));
|
|
49
|
+
|
|
50
|
+
expect(seen).toEqual([{ id: "1", title: "Reusable snippet" }]);
|
|
51
|
+
|
|
52
|
+
setSnippetFetcher(null);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export type SnippetSuggestion = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
body?: string | null;
|
|
7
|
+
description?: string | null;
|
|
8
|
+
usageCount?: number | null;
|
|
9
|
+
isSnippet?: boolean | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type SnippetJsonApiAttributes = {
|
|
13
|
+
title?: string | null;
|
|
14
|
+
body?: string | null;
|
|
15
|
+
description?: string | null;
|
|
16
|
+
"usage-count"?: number | string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SnippetJsonApiResource = {
|
|
20
|
+
id?: string | number | null;
|
|
21
|
+
type?: string | null;
|
|
22
|
+
attributes?: SnippetJsonApiAttributes | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SnippetJsonApiDocument = {
|
|
26
|
+
data?: SnippetJsonApiResource[] | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SnippetSuggestionsFetcher = () => Promise<SnippetInput> | SnippetInput;
|
|
30
|
+
|
|
31
|
+
type SnippetInput = SnippetSuggestion[] | SnippetJsonApiDocument | SnippetJsonApiResource[] | null | undefined;
|
|
32
|
+
|
|
33
|
+
let globalFetcher: SnippetSuggestionsFetcher | null = null;
|
|
34
|
+
let cachedSuggestions: SnippetSuggestion[] = [];
|
|
35
|
+
|
|
36
|
+
export function setSnippetFetcher(fetcher: SnippetSuggestionsFetcher | null) {
|
|
37
|
+
globalFetcher = fetcher;
|
|
38
|
+
cachedSuggestions = [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useSnippetAutocomplete(): SnippetSuggestion[] {
|
|
42
|
+
const [suggestions, setSuggestions] = useState<SnippetSuggestion[]>(() => {
|
|
43
|
+
if (cachedSuggestions.length > 0) {
|
|
44
|
+
return cachedSuggestions;
|
|
45
|
+
}
|
|
46
|
+
if (globalFetcher) {
|
|
47
|
+
const result = globalFetcher();
|
|
48
|
+
if (!result || typeof (result as Promise<unknown>).then !== "function") {
|
|
49
|
+
const normalized = normalizeSnippetSuggestions(result as SnippetInput);
|
|
50
|
+
cachedSuggestions = normalized;
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (suggestions.length > 0) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!globalFetcher) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
Promise.resolve(globalFetcher())
|
|
67
|
+
.then((result) => normalizeSnippetSuggestions(result))
|
|
68
|
+
.then((items) => {
|
|
69
|
+
if (cancelled) return;
|
|
70
|
+
cachedSuggestions = items;
|
|
71
|
+
setSuggestions(items);
|
|
72
|
+
})
|
|
73
|
+
.catch((error) => console.error("Failed to fetch snippet suggestions", error));
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
}, [suggestions.length]);
|
|
79
|
+
|
|
80
|
+
return suggestions;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseSnippetsFromJsonApi(
|
|
84
|
+
document: SnippetJsonApiDocument | SnippetJsonApiResource[] | null | undefined,
|
|
85
|
+
): SnippetSuggestion[] {
|
|
86
|
+
const resources = Array.isArray(document) ? document : document?.data;
|
|
87
|
+
if (!Array.isArray(resources) || resources.length === 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return resources
|
|
92
|
+
.map((resource) => normalizeJsonApiResource(resource))
|
|
93
|
+
.filter((value): value is SnippetSuggestion => Boolean(value));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeSnippetSuggestions(snippets?: SnippetInput): SnippetSuggestion[] {
|
|
97
|
+
if (!snippets) return [];
|
|
98
|
+
|
|
99
|
+
if (Array.isArray(snippets)) {
|
|
100
|
+
if (snippets.length === 0) return [];
|
|
101
|
+
if (isSnippetSuggestionArray(snippets)) return snippets;
|
|
102
|
+
return parseSnippetsFromJsonApi(snippets);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return parseSnippetsFromJsonApi(snippets);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeJsonApiResource(resource: SnippetJsonApiResource | null | undefined): SnippetSuggestion | null {
|
|
109
|
+
if (!resource) return null;
|
|
110
|
+
const attrs = resource.attributes;
|
|
111
|
+
const id = resource.id;
|
|
112
|
+
const title = attrs?.title ?? "";
|
|
113
|
+
if (!id || !title) return null;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id: String(id),
|
|
117
|
+
title: String(title),
|
|
118
|
+
body: attrs?.body ?? null,
|
|
119
|
+
description: attrs?.description ?? null,
|
|
120
|
+
usageCount: coerceNumber(attrs?.["usage-count"]),
|
|
121
|
+
isSnippet: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isSnippetSuggestionArray(value: SnippetInput): value is SnippetSuggestion[] {
|
|
126
|
+
return Array.isArray(value) && value.length > 0 && typeof (value[0] as SnippetSuggestion | any)?.title === "string";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function coerceNumber(value: string | number | null | undefined): number | null {
|
|
130
|
+
if (value === null || value === undefined) return null;
|
|
131
|
+
const num = Number(value);
|
|
132
|
+
return Number.isFinite(num) ? num : null;
|
|
133
|
+
}
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { parseStepsFromJsonApi } from "./stepAutocomplete";
|
|
3
3
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
4
|
import React from "react";
|
|
5
|
-
import {
|
|
5
|
+
import { setStepsFetcher, useStepAutocomplete } from "./stepAutocomplete";
|
|
6
6
|
|
|
7
7
|
describe("parseStepsFromJsonApi", () => {
|
|
8
8
|
it("converts JSON:API resources into step suggestions", () => {
|
|
@@ -85,7 +85,7 @@ describe("parseStepsFromJsonApi", () => {
|
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
it("reads suggestions from the global fetcher via useStepAutocomplete", () => {
|
|
88
|
-
|
|
88
|
+
setStepsFetcher(() => [{ id: "1", title: "Global step" }]);
|
|
89
89
|
|
|
90
90
|
let seen: any[] = [];
|
|
91
91
|
const Probe = () => {
|
|
@@ -98,6 +98,6 @@ describe("parseStepsFromJsonApi", () => {
|
|
|
98
98
|
expect(seen).toEqual([{ id: "1", title: "Global step" }]);
|
|
99
99
|
|
|
100
100
|
// reset for other tests
|
|
101
|
-
|
|
101
|
+
setStepsFetcher(null);
|
|
102
102
|
});
|
|
103
103
|
});
|
|
@@ -40,7 +40,7 @@ type StepInput = StepSuggestion[] | StepJsonApiDocument | StepJsonApiResource[]
|
|
|
40
40
|
let globalFetcher: StepSuggestionsFetcher | null = null;
|
|
41
41
|
let cachedSuggestions: StepSuggestion[] = [];
|
|
42
42
|
|
|
43
|
-
export function
|
|
43
|
+
export function setStepsFetcher(fetcher: StepSuggestionsFetcher | null) {
|
|
44
44
|
globalFetcher = fetcher;
|
|
45
45
|
cachedSuggestions = [];
|
|
46
46
|
}
|
package/src/editor/styles.css
CHANGED
|
@@ -112,6 +112,63 @@
|
|
|
112
112
|
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.08);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
.bn-snippet {
|
|
116
|
+
border-left-color: rgba(16, 185, 129, 0.75);
|
|
117
|
+
background: rgba(16, 185, 129, 0.12);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.bn-snippet::after {
|
|
121
|
+
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.16);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.bn-snippet .bn-teststep__toggle {
|
|
125
|
+
border-color: rgba(16, 185, 129, 0.45);
|
|
126
|
+
background: rgba(16, 185, 129, 0.1);
|
|
127
|
+
color: #0f766e;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.bn-snippet .bn-teststep__toggle:hover {
|
|
131
|
+
background: rgba(16, 185, 129, 0.18);
|
|
132
|
+
border-color: rgba(16, 185, 129, 0.55);
|
|
133
|
+
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.2);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.bn-snippet .bn-step-field__label {
|
|
137
|
+
color: #0f766e;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.bn-snippet .bn-step-toolbar__button {
|
|
141
|
+
border-color: rgba(16, 185, 129, 0.35);
|
|
142
|
+
color: #0f766e;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.bn-snippet .bn-step-toolbar__button:hover {
|
|
146
|
+
background: rgba(16, 185, 129, 0.12);
|
|
147
|
+
border-color: rgba(16, 185, 129, 0.55);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.bn-snippet .bn-step-editor {
|
|
151
|
+
border-color: rgba(16, 185, 129, 0.25);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.bn-snippet .bn-step-editor:focus-visible {
|
|
155
|
+
border-color: rgba(16, 185, 129, 0.7);
|
|
156
|
+
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.bn-snippet .bn-step-suggestions {
|
|
160
|
+
border-color: rgba(16, 185, 129, 0.25);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.bn-snippet .bn-step-suggestion:hover,
|
|
164
|
+
.bn-snippet .bn-step-suggestion--active {
|
|
165
|
+
background: rgba(16, 185, 129, 0.1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.bn-snippet .bn-step-suggestion__meta {
|
|
169
|
+
color: rgba(15, 118, 110, 0.65);
|
|
170
|
+
}
|
|
171
|
+
|
|
115
172
|
.bn-teststep__toggle {
|
|
116
173
|
align-self: flex-start;
|
|
117
174
|
padding: 0.35rem 0.6rem;
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,9 @@ export {
|
|
|
4
4
|
type CustomBlock,
|
|
5
5
|
type CustomEditor,
|
|
6
6
|
} from "./editor/customSchema";
|
|
7
|
+
export { stepBlock } from "./editor/blocks/step";
|
|
8
|
+
export { snippetBlock } from "./editor/blocks/snippet";
|
|
9
|
+
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
7
10
|
|
|
8
11
|
export {
|
|
9
12
|
blocksToMarkdown,
|
|
@@ -15,7 +18,7 @@ export {
|
|
|
15
18
|
export {
|
|
16
19
|
useStepAutocomplete,
|
|
17
20
|
parseStepsFromJsonApi,
|
|
18
|
-
|
|
21
|
+
setStepsFetcher,
|
|
19
22
|
type StepSuggestion,
|
|
20
23
|
type StepJsonApiDocument,
|
|
21
24
|
type StepJsonApiResource,
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { __markdownTestUtils } from "./customSchema";
|
|
3
|
-
|
|
4
|
-
describe("customSchema markdown helpers", () => {
|
|
5
|
-
it("renders markdown images as <img> tags and round-trips back to markdown", () => {
|
|
6
|
-
const markdown = "";
|
|
7
|
-
const html = __markdownTestUtils.markdownToHtml(markdown);
|
|
8
|
-
|
|
9
|
-
expect(html).toContain('<img src="/attachments/example.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
10
|
-
|
|
11
|
-
const roundTrip = __markdownTestUtils.htmlToMarkdown(html);
|
|
12
|
-
expect(roundTrip).toBe(markdown);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("handles images mixed with surrounding text", () => {
|
|
16
|
-
const markdown = [
|
|
17
|
-
"Success screenshot:",
|
|
18
|
-
"",
|
|
19
|
-
"Please archive it.",
|
|
20
|
-
].join("\n");
|
|
21
|
-
|
|
22
|
-
const html = __markdownTestUtils.markdownToHtml(markdown);
|
|
23
|
-
expect(html).toContain('<img src="/attachments/success.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
24
|
-
|
|
25
|
-
const roundTrip = __markdownTestUtils.htmlToMarkdown(html);
|
|
26
|
-
expect(roundTrip).toBe("Success screenshot:\n\nPlease archive it.");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("renders expected-result style markdown with an image", () => {
|
|
30
|
-
const markdown = [
|
|
31
|
-
"Login should look like this",
|
|
32
|
-
"",
|
|
33
|
-
].join("\n");
|
|
34
|
-
|
|
35
|
-
const html = __markdownTestUtils.markdownToHtml(markdown);
|
|
36
|
-
expect(html).toContain('<img src="/login.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
37
|
-
expect(__markdownTestUtils.htmlToMarkdown(html)).toBe(markdown);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("keeps inline images when mixed inside a single line of markdown text", () => {
|
|
41
|
-
const markdown = "* asdsadsad aaaaa asd ";
|
|
42
|
-
const html = __markdownTestUtils.markdownToHtml(markdown);
|
|
43
|
-
|
|
44
|
-
expect(html).toContain('<img src="https://placehold.co/600x400?text=Uploaded+1763329962213" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
45
|
-
expect(__markdownTestUtils.htmlToMarkdown(html)).toBe(markdown);
|
|
46
|
-
});
|
|
47
|
-
});
|