@truedat/core 8.3.3 → 8.3.4
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/package.json +16 -5
- package/src/components/Markdown.js +346 -0
- package/src/components/TextPromptModal.js +214 -0
- package/src/components/__tests__/Markdown.spec.js +140 -0
- package/src/components/__tests__/TextPromptModal.spec.js +282 -0
- package/src/components/__tests__/__snapshots__/Markdown.spec.js.snap +210 -0
- package/src/components/index.js +4 -0
- package/src/styles/Markdown.less +82 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@truedat/core",
|
|
3
|
-
"version": "8.3.
|
|
3
|
+
"version": "8.3.4",
|
|
4
4
|
"description": "Truedat Web Core",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"module": "src/index.js",
|
|
@@ -41,20 +41,29 @@
|
|
|
41
41
|
"rootMode": "upward"
|
|
42
42
|
}
|
|
43
43
|
]
|
|
44
|
-
}
|
|
44
|
+
},
|
|
45
|
+
"transformIgnorePatterns": [
|
|
46
|
+
"/node_modules/(?!marked|turndown|@tiptap)/"
|
|
47
|
+
]
|
|
45
48
|
},
|
|
46
49
|
"devDependencies": {
|
|
47
50
|
"@testing-library/dom": "^10.4.0",
|
|
48
51
|
"@testing-library/jest-dom": "^6.6.3",
|
|
49
52
|
"@testing-library/react": "^16.3.0",
|
|
50
53
|
"@testing-library/user-event": "^14.6.1",
|
|
51
|
-
"@truedat/test": "8.3.
|
|
54
|
+
"@truedat/test": "8.3.4",
|
|
52
55
|
"identity-obj-proxy": "^3.0.0",
|
|
53
56
|
"jest": "^29.7.0",
|
|
54
57
|
"redux-saga-test-plan": "^4.0.6"
|
|
55
58
|
},
|
|
56
59
|
"dependencies": {
|
|
57
60
|
"@apollo/client": "^3.13.8",
|
|
61
|
+
"@tiptap/core": "^3.20.0",
|
|
62
|
+
"@tiptap/extension-heading": "^3.20.0",
|
|
63
|
+
"@tiptap/extension-link": "^3.20.0",
|
|
64
|
+
"@tiptap/pm": "^3.20.0",
|
|
65
|
+
"@tiptap/react": "^3.20.0",
|
|
66
|
+
"@tiptap/starter-kit": "^3.20.0",
|
|
58
67
|
"@xyflow/react": "^12.6.4",
|
|
59
68
|
"axios": "^1.13.5",
|
|
60
69
|
"elkjs": "^0.10.0",
|
|
@@ -63,6 +72,7 @@
|
|
|
63
72
|
"is-hotkey": "^0.2.0",
|
|
64
73
|
"is-url": "^1.2.4",
|
|
65
74
|
"lodash": "^4.17.21",
|
|
75
|
+
"marked": "^17.0.3",
|
|
66
76
|
"moment": "^2.30.1",
|
|
67
77
|
"path-to-regexp": "^8.2.0",
|
|
68
78
|
"prop-types": "^15.8.1",
|
|
@@ -83,7 +93,8 @@
|
|
|
83
93
|
"semantic-ui-react": "^3.0.0-beta.2",
|
|
84
94
|
"slate": "^0.47.9",
|
|
85
95
|
"slate-react": "^0.22.10",
|
|
86
|
-
"swr": "^2.3.3"
|
|
96
|
+
"swr": "^2.3.3",
|
|
97
|
+
"turndown": "^7.2.2"
|
|
87
98
|
},
|
|
88
|
-
"gitHead": "
|
|
99
|
+
"gitHead": "6c7f4ef08bac69f8b642fa788ebb426a5a103314"
|
|
89
100
|
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useCallback } from "react";
|
|
2
|
+
import PropTypes from "prop-types";
|
|
3
|
+
import { useIntl } from "react-intl";
|
|
4
|
+
import { useEditor, EditorContent } from "@tiptap/react";
|
|
5
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
6
|
+
import Link from "@tiptap/extension-link";
|
|
7
|
+
import Heading from "@tiptap/extension-heading";
|
|
8
|
+
import { Menu, Icon } from "semantic-ui-react";
|
|
9
|
+
import { FormattedMessage } from "react-intl";
|
|
10
|
+
import { marked } from "marked";
|
|
11
|
+
import TurndownService from "turndown";
|
|
12
|
+
import TextPromptModal from "./TextPromptModal";
|
|
13
|
+
|
|
14
|
+
const turndownService = new TurndownService({ headingStyle: "atx" });
|
|
15
|
+
const HEADING_LEVELS = [2, 3];
|
|
16
|
+
const extensions = [
|
|
17
|
+
StarterKit.configure({
|
|
18
|
+
heading: false,
|
|
19
|
+
link: false,
|
|
20
|
+
}),
|
|
21
|
+
Heading.configure({
|
|
22
|
+
levels: HEADING_LEVELS,
|
|
23
|
+
}),
|
|
24
|
+
Link.configure({
|
|
25
|
+
openOnClick: false,
|
|
26
|
+
HTMLAttributes: {
|
|
27
|
+
rel: "noopener noreferrer",
|
|
28
|
+
target: "_blank",
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const validateUrl = (value, allowEmpty = false) => {
|
|
34
|
+
if (!value || value.trim() === "") {
|
|
35
|
+
return {
|
|
36
|
+
valid: allowEmpty,
|
|
37
|
+
error: allowEmpty ? null : "markdown.editor.invalidUrl",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// URLs con espacios no son válidas
|
|
42
|
+
if (value.includes(" ")) {
|
|
43
|
+
return { valid: false, error: "markdown.editor.invalidUrl" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Regex más estricto: requiere dominio.tld válido
|
|
47
|
+
// - Dominio: al menos 2 caracteres antes del último punto
|
|
48
|
+
// - TLD: 2-6 letras
|
|
49
|
+
const parts = value.split(".");
|
|
50
|
+
if (parts.length < 2) {
|
|
51
|
+
return { valid: false, error: "markdown.editor.invalidUrl" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tld = parts[parts.length - 1].split("/")[0];
|
|
55
|
+
const domain = parts.slice(0, -1).join(".");
|
|
56
|
+
|
|
57
|
+
// Validar TLD: 2-6 letras
|
|
58
|
+
if (!/^[a-z]{2,6}$/i.test(tld)) {
|
|
59
|
+
return { valid: false, error: "markdown.editor.invalidUrl" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validar dominio: al menos 4 caracteres si el TLD tiene 3 letras o menos
|
|
63
|
+
// Esto evita www.peo, www.xyz, etc. pero permite example.com
|
|
64
|
+
if (tld.length <= 3 && domain.length < 4) {
|
|
65
|
+
return { valid: false, error: "markdown.editor.invalidUrl" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validar dominio: al menos 2 caracteres, sin empezar/terminar con punto o guión
|
|
69
|
+
if (domain.length < 2 || domain.startsWith("-") || domain.endsWith("-") || domain.endsWith(".")) {
|
|
70
|
+
return { valid: false, error: "markdown.editor.invalidUrl" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const urlWithProtocol = value.match(/^https?:\/\//i) ? value : `https://${value}`;
|
|
74
|
+
return { valid: true, value: urlWithProtocol };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function markdownToHtml(markdown) {
|
|
78
|
+
if (!markdown || (typeof markdown === "string" && markdown.trim() === "")) {
|
|
79
|
+
return "<p></p>";
|
|
80
|
+
}
|
|
81
|
+
const html = marked.parse(markdown, { async: false });
|
|
82
|
+
return html.replace(/<a (?=[^>]*href=)/gi, `<a target="_blank" rel="noopener noreferrer" `);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function htmlToMarkdown(html) {
|
|
86
|
+
if (!html || (typeof html === "string" && html.trim() === "")) {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
return turndownService.turndown(html);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function MarkdownReader({ content = "" }) {
|
|
93
|
+
const html = markdownToHtml(content);
|
|
94
|
+
if (!content || (typeof content === "string" && content.trim() === "")) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
className="markdown-reader"
|
|
100
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
MarkdownReader.propTypes = {
|
|
106
|
+
content: PropTypes.string,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function MarkdownEditor({
|
|
110
|
+
value = "",
|
|
111
|
+
onChange = null,
|
|
112
|
+
name = null,
|
|
113
|
+
label = null,
|
|
114
|
+
placeholder = "",
|
|
115
|
+
disabled = false,
|
|
116
|
+
}) {
|
|
117
|
+
const { formatMessage } = useIntl();
|
|
118
|
+
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
|
119
|
+
const [linkUrl, setLinkUrl] = useState("");
|
|
120
|
+
const [hasSelection, setHasSelection] = useState(false);
|
|
121
|
+
const lastEmittedMarkdownRef = useRef(value);
|
|
122
|
+
const editorRef = useRef(null);
|
|
123
|
+
const initialContent = markdownToHtml(value);
|
|
124
|
+
|
|
125
|
+
const editor = useEditor({
|
|
126
|
+
extensions,
|
|
127
|
+
content: initialContent,
|
|
128
|
+
editable: !disabled,
|
|
129
|
+
immediatelyRender: false,
|
|
130
|
+
editorProps: {
|
|
131
|
+
handlePaste(view, event) {
|
|
132
|
+
const editorInstance = editorRef.current;
|
|
133
|
+
if (!editorInstance) return false;
|
|
134
|
+
const text = event.clipboardData?.getData("text/plain");
|
|
135
|
+
if (!text || !text.trim()) return false;
|
|
136
|
+
const html = markdownToHtml(text);
|
|
137
|
+
editorInstance.commands.insertContent(html, { emitUpdate: true });
|
|
138
|
+
return true;
|
|
139
|
+
},
|
|
140
|
+
handleDoubleClick: (view) => {
|
|
141
|
+
const { state } = editor;
|
|
142
|
+
const { from } = state.selection;
|
|
143
|
+
const marks = state.doc.resolve(from).marks();
|
|
144
|
+
const linkMark = marks.find((mark) => mark.type.name === "link");
|
|
145
|
+
|
|
146
|
+
if (linkMark) {
|
|
147
|
+
const href = linkMark.attrs.href;
|
|
148
|
+
openLinkModal(href);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return false;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
onUpdate: ({ editor: ed }) => {
|
|
156
|
+
const html = ed.getHTML();
|
|
157
|
+
const markdown = htmlToMarkdown(html);
|
|
158
|
+
lastEmittedMarkdownRef.current = markdown;
|
|
159
|
+
if (onChange) {
|
|
160
|
+
onChange(null, { name, value: markdown });
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
editorRef.current = editor;
|
|
167
|
+
}, [editor]);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!editor) return;
|
|
171
|
+
const currentValue = value ?? "";
|
|
172
|
+
if (currentValue !== lastEmittedMarkdownRef.current) {
|
|
173
|
+
lastEmittedMarkdownRef.current = currentValue;
|
|
174
|
+
editor.commands.setContent(markdownToHtml(currentValue), {
|
|
175
|
+
emitUpdate: false,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}, [value, editor]);
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!editor) return;
|
|
182
|
+
editor.setEditable(!disabled);
|
|
183
|
+
}, [disabled, editor]);
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!editor) return;
|
|
187
|
+
const updateSelection = () => {
|
|
188
|
+
const { from, to } = editor.state.selection;
|
|
189
|
+
setHasSelection(from !== to);
|
|
190
|
+
};
|
|
191
|
+
updateSelection();
|
|
192
|
+
editor.on("selectionUpdate", updateSelection);
|
|
193
|
+
return () => {
|
|
194
|
+
editor.off("selectionUpdate", updateSelection);
|
|
195
|
+
};
|
|
196
|
+
}, [editor]);
|
|
197
|
+
|
|
198
|
+
const openLinkModal = useCallback((url = null) => {
|
|
199
|
+
let linkUrl;
|
|
200
|
+
|
|
201
|
+
if (url !== null) {
|
|
202
|
+
linkUrl = url.replace(/^https?:\/\//i, "");
|
|
203
|
+
} else {
|
|
204
|
+
const previousUrl = editor.getAttributes("link").href || "";
|
|
205
|
+
linkUrl = previousUrl.replace(/^https?:\/\//i, "");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
setLinkUrl(linkUrl);
|
|
209
|
+
setLinkModalOpen(true);
|
|
210
|
+
}, [editor]);
|
|
211
|
+
|
|
212
|
+
const handleLinkAccept = useCallback(({ valid, value: url }) => {
|
|
213
|
+
if (!valid) return;
|
|
214
|
+
|
|
215
|
+
if (!url || url.trim() === "") {
|
|
216
|
+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
217
|
+
setLinkModalOpen(false);
|
|
218
|
+
setLinkUrl("");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
|
223
|
+
setLinkModalOpen(false);
|
|
224
|
+
setLinkUrl("");
|
|
225
|
+
}, [editor]);
|
|
226
|
+
|
|
227
|
+
const handleLinkCancel = useCallback(() => {
|
|
228
|
+
setLinkModalOpen(false);
|
|
229
|
+
setLinkUrl("");
|
|
230
|
+
}, []);
|
|
231
|
+
|
|
232
|
+
const validateLinkUrl = useCallback((value) => {
|
|
233
|
+
const hasPreviousLink = editor?.isActive("link");
|
|
234
|
+
return validateUrl(value, hasPreviousLink);
|
|
235
|
+
}, [editor]);
|
|
236
|
+
|
|
237
|
+
if (!editor) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div className="markdown-editor">
|
|
243
|
+
{label ? (
|
|
244
|
+
<div className="field">
|
|
245
|
+
<label>{label}</label>
|
|
246
|
+
</div>
|
|
247
|
+
) : null}
|
|
248
|
+
<Menu size="small" attached="top" className="markdown-editor-toolbar">
|
|
249
|
+
<Menu.Item
|
|
250
|
+
active={editor.isActive("bold")}
|
|
251
|
+
onMouseDown={(e) => {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
editor.chain().focus().toggleBold().run();
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
<Icon name="bold" />
|
|
257
|
+
</Menu.Item>
|
|
258
|
+
<Menu.Item
|
|
259
|
+
active={editor.isActive("italic")}
|
|
260
|
+
onMouseDown={(e) => {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
editor.chain().focus().toggleItalic().run();
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<Icon name="italic" />
|
|
266
|
+
</Menu.Item>
|
|
267
|
+
<Menu.Item
|
|
268
|
+
onMouseDown={(e) => {
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
<Icon name="heading" size="large" />
|
|
274
|
+
</Menu.Item>
|
|
275
|
+
<Menu.Item
|
|
276
|
+
onMouseDown={(e) => {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
editor.chain().focus().toggleHeading({ level: 3 }).run();
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
<Icon name="heading" size="small" />
|
|
282
|
+
</Menu.Item>
|
|
283
|
+
<Menu.Item
|
|
284
|
+
active={editor.isActive("bulletList")}
|
|
285
|
+
onMouseDown={(e) => {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
editor.chain().focus().toggleBulletList().run();
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
<Icon name="list ul" />
|
|
291
|
+
</Menu.Item>
|
|
292
|
+
<Menu.Item
|
|
293
|
+
active={editor.isActive("orderedList")}
|
|
294
|
+
onMouseDown={(e) => {
|
|
295
|
+
e.preventDefault();
|
|
296
|
+
editor.chain().focus().toggleOrderedList().run();
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
<Icon name="list ol" />
|
|
300
|
+
</Menu.Item>
|
|
301
|
+
<Menu.Item
|
|
302
|
+
active={editor.isActive("link")}
|
|
303
|
+
disabled={!hasSelection && !editor.isActive("link")}
|
|
304
|
+
onMouseDown={(e) => {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
if (hasSelection || editor.isActive("link")) {
|
|
307
|
+
openLinkModal();
|
|
308
|
+
}
|
|
309
|
+
}}
|
|
310
|
+
>
|
|
311
|
+
<Icon name="linkify" />
|
|
312
|
+
</Menu.Item>
|
|
313
|
+
</Menu>
|
|
314
|
+
<div className="markdown-editor-content">
|
|
315
|
+
<EditorContent editor={editor} placeholder={placeholder} />
|
|
316
|
+
</div>
|
|
317
|
+
<TextPromptModal
|
|
318
|
+
open={linkModalOpen}
|
|
319
|
+
onClose={handleLinkCancel}
|
|
320
|
+
onAccept={handleLinkAccept}
|
|
321
|
+
validate={validateLinkUrl}
|
|
322
|
+
initialValue={linkUrl}
|
|
323
|
+
allowEmpty={editor.isActive("link")}
|
|
324
|
+
header={
|
|
325
|
+
<>
|
|
326
|
+
<Icon name="linkify" />
|
|
327
|
+
<FormattedMessage id="markdown.editor.insertLink" />
|
|
328
|
+
</>
|
|
329
|
+
}
|
|
330
|
+
label={formatMessage({ id: "markdown.editor.urlLabel", defaultMessage: "URL" })}
|
|
331
|
+
placeholder="https://"
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
MarkdownEditor.propTypes = {
|
|
338
|
+
disabled: PropTypes.bool,
|
|
339
|
+
label: PropTypes.string,
|
|
340
|
+
name: PropTypes.string,
|
|
341
|
+
onChange: PropTypes.func,
|
|
342
|
+
placeholder: PropTypes.string,
|
|
343
|
+
value: PropTypes.string,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
export { markdownToHtml, validateUrl };
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import PropTypes from "prop-types";
|
|
3
|
+
import { Modal, Input, Button, Form } from "semantic-ui-react";
|
|
4
|
+
import { FormattedMessage } from "react-intl";
|
|
5
|
+
|
|
6
|
+
export const TextPromptModal = ({
|
|
7
|
+
open,
|
|
8
|
+
onClose,
|
|
9
|
+
onAccept,
|
|
10
|
+
header,
|
|
11
|
+
label,
|
|
12
|
+
placeholder,
|
|
13
|
+
initialValue = "",
|
|
14
|
+
validate,
|
|
15
|
+
trigger,
|
|
16
|
+
size = "small",
|
|
17
|
+
errorMessageId = "form.validation.invalid",
|
|
18
|
+
showSecondaryInput = false,
|
|
19
|
+
secondaryLabel,
|
|
20
|
+
secondaryPlaceholder,
|
|
21
|
+
secondaryInitialValue = "",
|
|
22
|
+
acceptButtonLabel = "actions.save",
|
|
23
|
+
cancelButtonLabel = "actions.cancel",
|
|
24
|
+
allowEmpty = false,
|
|
25
|
+
}) => {
|
|
26
|
+
const [value, setValue] = useState(initialValue);
|
|
27
|
+
const [secondaryValue, setSecondaryValue] = useState(secondaryInitialValue);
|
|
28
|
+
const [error, setError] = useState(null);
|
|
29
|
+
const [isValid, setIsValid] = useState(false);
|
|
30
|
+
const inputRef = useRef(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (open) {
|
|
34
|
+
setValue(initialValue);
|
|
35
|
+
setSecondaryValue(secondaryInitialValue);
|
|
36
|
+
setError(null);
|
|
37
|
+
const isEmpty = !initialValue || initialValue.trim() === "";
|
|
38
|
+
|
|
39
|
+
if (isEmpty) {
|
|
40
|
+
setIsValid(true);
|
|
41
|
+
} else if (!validate) {
|
|
42
|
+
setIsValid(true);
|
|
43
|
+
} else {
|
|
44
|
+
const result = validate(initialValue);
|
|
45
|
+
setIsValid(result.valid);
|
|
46
|
+
}
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
if (inputRef.current) {
|
|
49
|
+
inputRef.current.focus();
|
|
50
|
+
}
|
|
51
|
+
}, 100);
|
|
52
|
+
}
|
|
53
|
+
}, [open, initialValue, secondaryInitialValue, validate, allowEmpty]);
|
|
54
|
+
|
|
55
|
+
const handleSubmit = () => {
|
|
56
|
+
if (!isValid) return;
|
|
57
|
+
|
|
58
|
+
const result = {
|
|
59
|
+
valid: true,
|
|
60
|
+
value,
|
|
61
|
+
...(showSecondaryInput && { secondaryValue }),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
onAccept(result);
|
|
65
|
+
setValue(initialValue);
|
|
66
|
+
setSecondaryValue(secondaryInitialValue);
|
|
67
|
+
setError(null);
|
|
68
|
+
onClose();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleCancel = () => {
|
|
72
|
+
setValue(initialValue);
|
|
73
|
+
setSecondaryValue(secondaryInitialValue);
|
|
74
|
+
setError(null);
|
|
75
|
+
onClose();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const handleChange = (e, { value: newValue }) => {
|
|
79
|
+
setValue(newValue);
|
|
80
|
+
|
|
81
|
+
const isEmpty = !newValue || newValue.trim() === "";
|
|
82
|
+
|
|
83
|
+
if (isEmpty) {
|
|
84
|
+
setIsValid(true);
|
|
85
|
+
setError(null);
|
|
86
|
+
} else if (!validate) {
|
|
87
|
+
setIsValid(true);
|
|
88
|
+
setError(null);
|
|
89
|
+
} else {
|
|
90
|
+
const result = validate(newValue);
|
|
91
|
+
setIsValid(result.valid);
|
|
92
|
+
setError(result.valid ? null : result.error);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleSecondaryChange = (e, { value: newValue }) => {
|
|
97
|
+
setSecondaryValue(newValue);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const handleKeyDown = (e) => {
|
|
101
|
+
if (e.key === "Enter" && isValid) {
|
|
102
|
+
handleSubmit();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const renderError = () => {
|
|
107
|
+
if (!error) return null;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="ui error message" style={{ display: "block" }}>
|
|
111
|
+
<FormattedMessage id={error} />
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const inputId = "text-prompt-modal-input";
|
|
117
|
+
const secondaryInputId = "text-prompt-modal-secondary-input";
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Modal
|
|
121
|
+
open={open}
|
|
122
|
+
onClose={handleCancel}
|
|
123
|
+
size={size}
|
|
124
|
+
trigger={trigger}
|
|
125
|
+
closeOnDimmerClick={false}
|
|
126
|
+
closeOnDocumentClick={false}
|
|
127
|
+
closeOnEscape={true}
|
|
128
|
+
>
|
|
129
|
+
<Modal.Header>{header}</Modal.Header>
|
|
130
|
+
<Modal.Content>
|
|
131
|
+
<Form error={!!error}>
|
|
132
|
+
<Form.Field error={!!error}>
|
|
133
|
+
{label && <label htmlFor={inputId}>{label}</label>}
|
|
134
|
+
<Input
|
|
135
|
+
id={inputId}
|
|
136
|
+
fluid
|
|
137
|
+
placeholder={placeholder}
|
|
138
|
+
value={value}
|
|
139
|
+
onChange={handleChange}
|
|
140
|
+
onKeyDown={handleKeyDown}
|
|
141
|
+
error={!!error}
|
|
142
|
+
ref={inputRef}
|
|
143
|
+
/>
|
|
144
|
+
{renderError()}
|
|
145
|
+
</Form.Field>
|
|
146
|
+
{showSecondaryInput && (
|
|
147
|
+
<Form.Field>
|
|
148
|
+
{secondaryLabel && (
|
|
149
|
+
<label htmlFor={secondaryInputId}>{secondaryLabel}</label>
|
|
150
|
+
)}
|
|
151
|
+
<Input
|
|
152
|
+
id={secondaryInputId}
|
|
153
|
+
fluid
|
|
154
|
+
placeholder={secondaryPlaceholder}
|
|
155
|
+
value={secondaryValue}
|
|
156
|
+
onChange={handleSecondaryChange}
|
|
157
|
+
onKeyDown={handleKeyDown}
|
|
158
|
+
/>
|
|
159
|
+
</Form.Field>
|
|
160
|
+
)}
|
|
161
|
+
</Form>
|
|
162
|
+
</Modal.Content>
|
|
163
|
+
<Modal.Actions>
|
|
164
|
+
<Button onClick={handleCancel}>
|
|
165
|
+
<FormattedMessage id={cancelButtonLabel} />
|
|
166
|
+
</Button>
|
|
167
|
+
<Button primary onClick={handleSubmit} disabled={!isValid}>
|
|
168
|
+
<FormattedMessage id={acceptButtonLabel} />
|
|
169
|
+
</Button>
|
|
170
|
+
</Modal.Actions>
|
|
171
|
+
</Modal>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
TextPromptModal.propTypes = {
|
|
176
|
+
open: PropTypes.bool,
|
|
177
|
+
onClose: PropTypes.func.isRequired,
|
|
178
|
+
onAccept: PropTypes.func.isRequired,
|
|
179
|
+
header: PropTypes.node.isRequired,
|
|
180
|
+
label: PropTypes.node,
|
|
181
|
+
placeholder: PropTypes.string,
|
|
182
|
+
initialValue: PropTypes.string,
|
|
183
|
+
validate: PropTypes.func,
|
|
184
|
+
trigger: PropTypes.element,
|
|
185
|
+
size: PropTypes.string,
|
|
186
|
+
errorMessageId: PropTypes.string,
|
|
187
|
+
showSecondaryInput: PropTypes.bool,
|
|
188
|
+
secondaryLabel: PropTypes.node,
|
|
189
|
+
secondaryPlaceholder: PropTypes.string,
|
|
190
|
+
secondaryInitialValue: PropTypes.string,
|
|
191
|
+
acceptButtonLabel: PropTypes.string,
|
|
192
|
+
cancelButtonLabel: PropTypes.string,
|
|
193
|
+
allowEmpty: PropTypes.bool,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
TextPromptModal.defaultProps = {
|
|
197
|
+
open: false,
|
|
198
|
+
label: "",
|
|
199
|
+
placeholder: "",
|
|
200
|
+
initialValue: "",
|
|
201
|
+
validate: null,
|
|
202
|
+
trigger: null,
|
|
203
|
+
size: "small",
|
|
204
|
+
errorMessageId: "form.validation.invalid",
|
|
205
|
+
showSecondaryInput: false,
|
|
206
|
+
secondaryLabel: "",
|
|
207
|
+
secondaryPlaceholder: "",
|
|
208
|
+
secondaryInitialValue: "",
|
|
209
|
+
acceptButtonLabel: "actions.save",
|
|
210
|
+
cancelButtonLabel: "actions.cancel",
|
|
211
|
+
allowEmpty: false,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export default TextPromptModal;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { waitFor } from "@testing-library/react";
|
|
2
|
+
import { render } from "@truedat/test/render";
|
|
3
|
+
import { MarkdownReader, MarkdownEditor, markdownToHtml, validateUrl } from "../Markdown";
|
|
4
|
+
|
|
5
|
+
describe("markdownToHtml", () => {
|
|
6
|
+
it("returns empty paragraph for empty or whitespace input", () => {
|
|
7
|
+
expect(markdownToHtml("")).toBe("<p></p>");
|
|
8
|
+
expect(markdownToHtml(" ")).toBe("<p></p>");
|
|
9
|
+
expect(markdownToHtml(null)).toBe("<p></p>");
|
|
10
|
+
expect(markdownToHtml(undefined)).toBe("<p></p>");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("converts markdown to html", () => {
|
|
14
|
+
expect(markdownToHtml("**bold**")).toContain("<strong>bold</strong>");
|
|
15
|
+
expect(markdownToHtml("*italic*")).toContain("<em>italic</em>");
|
|
16
|
+
expect(markdownToHtml("## Title")).toContain("<h2>");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("generates a correct <a> element for markdown links", () => {
|
|
20
|
+
const html = markdownToHtml("[Example link](https://example.com/path)");
|
|
21
|
+
const parser = new DOMParser();
|
|
22
|
+
const doc = parser.parseFromString(html, "text/html");
|
|
23
|
+
const anchor = doc.querySelector("a");
|
|
24
|
+
expect(anchor).not.toBeNull();
|
|
25
|
+
expect(anchor.getAttribute("href")).toBe("https://example.com/path");
|
|
26
|
+
expect(anchor.getAttribute("target")).toBe("_blank");
|
|
27
|
+
expect(anchor.getAttribute("rel")).toBe("noopener noreferrer");
|
|
28
|
+
expect(anchor.textContent).toBe("Example link");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("validateUrl", () => {
|
|
33
|
+
it("returns invalid for empty string when allowEmpty is false", () => {
|
|
34
|
+
expect(validateUrl("")).toEqual({ valid: false, error: "markdown.editor.invalidUrl" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns valid for empty string when allowEmpty is true", () => {
|
|
38
|
+
expect(validateUrl("", true)).toEqual({ valid: true, error: null });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns valid for URL without protocol", () => {
|
|
42
|
+
const result = validateUrl("example.com");
|
|
43
|
+
expect(result.valid).toBe(true);
|
|
44
|
+
expect(result.value).toBe("https://example.com");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns valid for URL with http", () => {
|
|
48
|
+
const result = validateUrl("http://example.com");
|
|
49
|
+
expect(result.valid).toBe(true);
|
|
50
|
+
expect(result.value).toBe("http://example.com");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns valid for URL with https", () => {
|
|
54
|
+
const result = validateUrl("https://example.com");
|
|
55
|
+
expect(result.valid).toBe(true);
|
|
56
|
+
expect(result.value).toBe("https://example.com");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns valid for URL with path", () => {
|
|
60
|
+
const result = validateUrl("https://example.com/path/to/resource");
|
|
61
|
+
expect(result.valid).toBe(true);
|
|
62
|
+
expect(result.value).toBe("https://example.com/path/to/resource");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns invalid for URL with TLD too short", () => {
|
|
66
|
+
const result = validateUrl("www.peo");
|
|
67
|
+
expect(result.valid).toBe(false);
|
|
68
|
+
expect(result.error).toBe("markdown.editor.invalidUrl");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns invalid for single word without dots", () => {
|
|
72
|
+
const result = validateUrl("not-a-url");
|
|
73
|
+
expect(result.valid).toBe(false);
|
|
74
|
+
expect(result.error).toBe("markdown.editor.invalidUrl");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns invalid for URL with spaces", () => {
|
|
78
|
+
const result = validateUrl("https://exam ple.com");
|
|
79
|
+
expect(result.valid).toBe(false);
|
|
80
|
+
expect(result.error).toBe("markdown.editor.invalidUrl");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("<MarkdownReader />", () => {
|
|
85
|
+
it("returns null when content is empty", () => {
|
|
86
|
+
const { container } = render(<MarkdownReader content="" />);
|
|
87
|
+
expect(container.firstChild).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns null when content is only whitespace", () => {
|
|
91
|
+
const { container } = render(<MarkdownReader content=" " />);
|
|
92
|
+
expect(container.firstChild).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders markdown content as html", () => {
|
|
96
|
+
const { container } = render(<MarkdownReader content="**Hello** world" />);
|
|
97
|
+
const reader = container.querySelector(".markdown-reader");
|
|
98
|
+
expect(reader).toBeInTheDocument();
|
|
99
|
+
expect(reader.innerHTML).toContain("<strong>Hello</strong>");
|
|
100
|
+
expect(reader.innerHTML).toContain("world");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("matches the latest snapshot with content", () => {
|
|
104
|
+
const rendered = render(
|
|
105
|
+
<MarkdownReader content="## Title\n\n- item 1\n- item 2\n\n[link](https://example.com)" />
|
|
106
|
+
);
|
|
107
|
+
expect(rendered.container).toMatchSnapshot();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("<MarkdownEditor />", () => {
|
|
112
|
+
it("matches the latest snapshot when editor is ready", async () => {
|
|
113
|
+
const rendered = render(<MarkdownEditor />);
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(rendered.container.querySelector(".markdown-editor")).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
expect(rendered.container).toMatchSnapshot();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("matches the latest snapshot with label and value", async () => {
|
|
121
|
+
const rendered = render(
|
|
122
|
+
<MarkdownEditor label="Description" value="**Bold** text" />
|
|
123
|
+
);
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(rendered.container.querySelector(".markdown-editor")).toBeInTheDocument();
|
|
126
|
+
});
|
|
127
|
+
expect(rendered.container).toMatchSnapshot();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("renders editable area when editor is ready", async () => {
|
|
131
|
+
const rendered = render(
|
|
132
|
+
<MarkdownEditor name="description" onChange={() => { }} value="" />
|
|
133
|
+
);
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(rendered.container.querySelector(".markdown-editor")).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
const editable = rendered.container.querySelector(".tiptap");
|
|
138
|
+
expect(editable).toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { screen, fireEvent } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import messages from "@truedat/core/messages";
|
|
4
|
+
import { render } from "@truedat/test/render";
|
|
5
|
+
import TextPromptModal from "../TextPromptModal";
|
|
6
|
+
|
|
7
|
+
const renderWithIntl = (component) => {
|
|
8
|
+
return render(component, { messages });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("<TextPromptModal />", () => {
|
|
12
|
+
const defaultProps = {
|
|
13
|
+
open: true,
|
|
14
|
+
onClose: jest.fn(),
|
|
15
|
+
onAccept: jest.fn(),
|
|
16
|
+
header: "Test Header",
|
|
17
|
+
label: "Test Label",
|
|
18
|
+
placeholder: "Test placeholder",
|
|
19
|
+
initialValue: "",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("render", () => {
|
|
27
|
+
it("renders modal header when open is true", () => {
|
|
28
|
+
renderWithIntl(<TextPromptModal {...defaultProps} />);
|
|
29
|
+
expect(screen.getByText("Test Header")).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders input with correct label", () => {
|
|
33
|
+
renderWithIntl(<TextPromptModal {...defaultProps} />);
|
|
34
|
+
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders with initial value", () => {
|
|
38
|
+
renderWithIntl(
|
|
39
|
+
<TextPromptModal {...defaultProps} initialValue="initial value" />,
|
|
40
|
+
);
|
|
41
|
+
expect(screen.getByRole("textbox")).toHaveValue("initial value");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("renders Cancel and Save buttons", () => {
|
|
45
|
+
renderWithIntl(<TextPromptModal {...defaultProps} />);
|
|
46
|
+
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
|
47
|
+
expect(screen.getByText("Save")).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders Save button enabled when empty (accepts empty as valid)", () => {
|
|
51
|
+
renderWithIntl(<TextPromptModal {...defaultProps} initialValue="" />);
|
|
52
|
+
expect(screen.getByText("Save")).not.toBeDisabled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders Save button enabled when has initial value and no validate function", () => {
|
|
56
|
+
renderWithIntl(
|
|
57
|
+
<TextPromptModal {...defaultProps} initialValue="some value" />,
|
|
58
|
+
);
|
|
59
|
+
expect(screen.getByText("Save")).not.toBeDisabled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders Save button enabled when allowEmpty is true", () => {
|
|
63
|
+
renderWithIntl(
|
|
64
|
+
<TextPromptModal {...defaultProps} allowEmpty={true} initialValue="" />,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText("Save")).not.toBeDisabled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("interaction", () => {
|
|
72
|
+
it("calls onClose when Cancel button is clicked", () => {
|
|
73
|
+
renderWithIntl(<TextPromptModal {...defaultProps} />);
|
|
74
|
+
|
|
75
|
+
fireEvent.click(screen.getByText("Cancel"));
|
|
76
|
+
|
|
77
|
+
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("calls onAccept when Accept button is clicked", () => {
|
|
81
|
+
renderWithIntl(
|
|
82
|
+
<TextPromptModal {...defaultProps} initialValue="test value" />,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
fireEvent.click(screen.getByText("Save"));
|
|
86
|
+
|
|
87
|
+
expect(defaultProps.onAccept).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(defaultProps.onAccept).toHaveBeenCalledWith({
|
|
89
|
+
valid: true,
|
|
90
|
+
value: "test value",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("calls onAccept when Enter key is pressed", () => {
|
|
95
|
+
renderWithIntl(
|
|
96
|
+
<TextPromptModal {...defaultProps} initialValue="test value" />,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
|
100
|
+
|
|
101
|
+
expect(defaultProps.onAccept).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("calls onAccept when Enter key is pressed and value is empty", () => {
|
|
105
|
+
renderWithIntl(<TextPromptModal {...defaultProps} initialValue="" />);
|
|
106
|
+
|
|
107
|
+
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
|
108
|
+
|
|
109
|
+
expect(defaultProps.onAccept).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(defaultProps.onAccept).toHaveBeenCalledWith({
|
|
111
|
+
valid: true,
|
|
112
|
+
value: "",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("updates input value when user types", () => {
|
|
117
|
+
renderWithIntl(<TextPromptModal {...defaultProps} />);
|
|
118
|
+
|
|
119
|
+
fireEvent.change(screen.getByRole("textbox"), {
|
|
120
|
+
target: { value: "new value" },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(screen.getByRole("textbox")).toHaveValue("new value");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("validation", () => {
|
|
128
|
+
it("shows error message when validation fails", () => {
|
|
129
|
+
const validate = jest.fn(() => ({
|
|
130
|
+
valid: false,
|
|
131
|
+
error: "form.validation.invalid",
|
|
132
|
+
}));
|
|
133
|
+
const onAccept = jest.fn();
|
|
134
|
+
|
|
135
|
+
renderWithIntl(
|
|
136
|
+
<TextPromptModal
|
|
137
|
+
{...defaultProps}
|
|
138
|
+
validate={validate}
|
|
139
|
+
onAccept={onAccept}
|
|
140
|
+
initialValue="invalid-value"
|
|
141
|
+
/>,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// El botón está disabled porque la validación falla
|
|
145
|
+
expect(screen.getByText("Save")).toBeDisabled();
|
|
146
|
+
expect(onAccept).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("calls onAccept with value when validation passes", () => {
|
|
150
|
+
const validate = jest
|
|
151
|
+
.fn()
|
|
152
|
+
.mockReturnValue({ valid: true, value: "test" });
|
|
153
|
+
const onAccept = jest.fn();
|
|
154
|
+
|
|
155
|
+
renderWithIntl(
|
|
156
|
+
<TextPromptModal
|
|
157
|
+
{...defaultProps}
|
|
158
|
+
validate={validate}
|
|
159
|
+
onAccept={onAccept}
|
|
160
|
+
initialValue="test"
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
fireEvent.click(screen.getByText("Save"));
|
|
165
|
+
|
|
166
|
+
expect(onAccept).toHaveBeenCalledWith({
|
|
167
|
+
valid: true,
|
|
168
|
+
value: "test",
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("shows error in real-time when validation fails", () => {
|
|
173
|
+
const validate = jest.fn(() => ({
|
|
174
|
+
valid: false,
|
|
175
|
+
error: "form.validation.invalid",
|
|
176
|
+
}));
|
|
177
|
+
renderWithIntl(
|
|
178
|
+
<TextPromptModal
|
|
179
|
+
{...defaultProps}
|
|
180
|
+
validate={validate}
|
|
181
|
+
initialValue="invalid-value"
|
|
182
|
+
/>,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// El botón está disabled porque la validación falla
|
|
186
|
+
expect(screen.getByText("Save")).toBeDisabled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("enables Save button when validation passes", () => {
|
|
190
|
+
const validate = jest
|
|
191
|
+
.fn()
|
|
192
|
+
.mockReturnValue({ valid: true, value: "valid-url" });
|
|
193
|
+
renderWithIntl(
|
|
194
|
+
<TextPromptModal
|
|
195
|
+
{...defaultProps}
|
|
196
|
+
validate={validate}
|
|
197
|
+
initialValue="valid-url"
|
|
198
|
+
/>,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// El botón está enabled porque la validación pasa
|
|
202
|
+
expect(screen.getByText("Save")).not.toBeDisabled();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("secondary input", () => {
|
|
207
|
+
it("renders secondary input when showSecondaryInput is true", () => {
|
|
208
|
+
renderWithIntl(
|
|
209
|
+
<TextPromptModal
|
|
210
|
+
{...defaultProps}
|
|
211
|
+
showSecondaryInput={true}
|
|
212
|
+
secondaryLabel="Secondary Label"
|
|
213
|
+
/>,
|
|
214
|
+
);
|
|
215
|
+
expect(screen.getAllByRole("textbox").length).toBe(2);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not render secondary input when showSecondaryInput is false", () => {
|
|
219
|
+
renderWithIntl(<TextPromptModal {...defaultProps} />);
|
|
220
|
+
expect(screen.getAllByRole("textbox").length).toBe(1);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns secondaryValue in onAccept when showSecondaryInput is true", () => {
|
|
224
|
+
const onAccept = jest.fn();
|
|
225
|
+
renderWithIntl(
|
|
226
|
+
<TextPromptModal
|
|
227
|
+
{...defaultProps}
|
|
228
|
+
showSecondaryInput={true}
|
|
229
|
+
secondaryLabel="Secondary"
|
|
230
|
+
onAccept={onAccept}
|
|
231
|
+
initialValue="primary value"
|
|
232
|
+
/>,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const inputs = screen.getAllByRole("textbox");
|
|
236
|
+
fireEvent.change(inputs[1], { target: { value: "secondary value" } });
|
|
237
|
+
fireEvent.click(screen.getByText("Save"));
|
|
238
|
+
|
|
239
|
+
expect(onAccept).toHaveBeenCalledWith({
|
|
240
|
+
valid: true,
|
|
241
|
+
value: "primary value",
|
|
242
|
+
secondaryValue: "secondary value",
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("reset", () => {
|
|
248
|
+
it("resets value when modal opens with different initialValue", () => {
|
|
249
|
+
const { rerender } = renderWithIntl(
|
|
250
|
+
<TextPromptModal {...defaultProps} initialValue="initial" />,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
rerender(
|
|
254
|
+
<TextPromptModal
|
|
255
|
+
{...defaultProps}
|
|
256
|
+
open={false}
|
|
257
|
+
initialValue="initial"
|
|
258
|
+
/>,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
rerender(
|
|
262
|
+
<TextPromptModal
|
|
263
|
+
{...defaultProps}
|
|
264
|
+
open={true}
|
|
265
|
+
initialValue="new value"
|
|
266
|
+
/>,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
expect(screen.getByRole("textbox")).toHaveValue("new value");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("trigger", () => {
|
|
274
|
+
it("renders trigger element when provided", () => {
|
|
275
|
+
const trigger = <button type="button">Open Modal</button>;
|
|
276
|
+
renderWithIntl(
|
|
277
|
+
<TextPromptModal {...defaultProps} trigger={trigger} open={false} />,
|
|
278
|
+
);
|
|
279
|
+
expect(screen.getByText("Open Modal")).toBeInTheDocument();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
|
+
|
|
3
|
+
exports[`<MarkdownEditor /> matches the latest snapshot when editor is ready 1`] = `
|
|
4
|
+
<div>
|
|
5
|
+
<div
|
|
6
|
+
class="markdown-editor"
|
|
7
|
+
>
|
|
8
|
+
<div
|
|
9
|
+
class="ui small top attached markdown-editor-toolbar menu"
|
|
10
|
+
>
|
|
11
|
+
<div
|
|
12
|
+
class="item"
|
|
13
|
+
>
|
|
14
|
+
<i
|
|
15
|
+
aria-hidden="true"
|
|
16
|
+
class="bold icon"
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
<div
|
|
20
|
+
class="item"
|
|
21
|
+
>
|
|
22
|
+
<i
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
class="italic icon"
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
<div
|
|
28
|
+
class="item"
|
|
29
|
+
>
|
|
30
|
+
<i
|
|
31
|
+
aria-hidden="true"
|
|
32
|
+
class="heading large icon"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
<div
|
|
36
|
+
class="item"
|
|
37
|
+
>
|
|
38
|
+
<i
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
class="heading small icon"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
<div
|
|
44
|
+
class="item"
|
|
45
|
+
>
|
|
46
|
+
<i
|
|
47
|
+
aria-hidden="true"
|
|
48
|
+
class="list ul icon"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
<div
|
|
52
|
+
class="item"
|
|
53
|
+
>
|
|
54
|
+
<i
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
class="list ol icon"
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
<div
|
|
60
|
+
class="disabled item"
|
|
61
|
+
>
|
|
62
|
+
<i
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
class="linkify icon"
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div
|
|
69
|
+
class="markdown-editor-content"
|
|
70
|
+
>
|
|
71
|
+
<div
|
|
72
|
+
placeholder=""
|
|
73
|
+
>
|
|
74
|
+
<div
|
|
75
|
+
class="tiptap ProseMirror"
|
|
76
|
+
contenteditable="true"
|
|
77
|
+
role="textbox"
|
|
78
|
+
tabindex="0"
|
|
79
|
+
translate="no"
|
|
80
|
+
>
|
|
81
|
+
<p>
|
|
82
|
+
<br
|
|
83
|
+
class="ProseMirror-trailingBreak"
|
|
84
|
+
/>
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
exports[`<MarkdownEditor /> matches the latest snapshot with label and value 1`] = `
|
|
94
|
+
<div>
|
|
95
|
+
<div
|
|
96
|
+
class="markdown-editor"
|
|
97
|
+
>
|
|
98
|
+
<div
|
|
99
|
+
class="field"
|
|
100
|
+
>
|
|
101
|
+
<label>
|
|
102
|
+
Description
|
|
103
|
+
</label>
|
|
104
|
+
</div>
|
|
105
|
+
<div
|
|
106
|
+
class="ui small top attached markdown-editor-toolbar menu"
|
|
107
|
+
>
|
|
108
|
+
<div
|
|
109
|
+
class="active item"
|
|
110
|
+
>
|
|
111
|
+
<i
|
|
112
|
+
aria-hidden="true"
|
|
113
|
+
class="bold icon"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
<div
|
|
117
|
+
class="item"
|
|
118
|
+
>
|
|
119
|
+
<i
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
class="italic icon"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
<div
|
|
125
|
+
class="item"
|
|
126
|
+
>
|
|
127
|
+
<i
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
class="heading large icon"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
<div
|
|
133
|
+
class="item"
|
|
134
|
+
>
|
|
135
|
+
<i
|
|
136
|
+
aria-hidden="true"
|
|
137
|
+
class="heading small icon"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
<div
|
|
141
|
+
class="item"
|
|
142
|
+
>
|
|
143
|
+
<i
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
class="list ul icon"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
<div
|
|
149
|
+
class="item"
|
|
150
|
+
>
|
|
151
|
+
<i
|
|
152
|
+
aria-hidden="true"
|
|
153
|
+
class="list ol icon"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
<div
|
|
157
|
+
class="disabled item"
|
|
158
|
+
>
|
|
159
|
+
<i
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
class="linkify icon"
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
<div
|
|
166
|
+
class="markdown-editor-content"
|
|
167
|
+
>
|
|
168
|
+
<div
|
|
169
|
+
placeholder=""
|
|
170
|
+
>
|
|
171
|
+
<div
|
|
172
|
+
class="tiptap ProseMirror"
|
|
173
|
+
contenteditable="true"
|
|
174
|
+
role="textbox"
|
|
175
|
+
tabindex="0"
|
|
176
|
+
translate="no"
|
|
177
|
+
>
|
|
178
|
+
<p>
|
|
179
|
+
<strong>
|
|
180
|
+
Bold
|
|
181
|
+
</strong>
|
|
182
|
+
text
|
|
183
|
+
</p>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
`;
|
|
190
|
+
|
|
191
|
+
exports[`<MarkdownReader /> matches the latest snapshot with content 1`] = `
|
|
192
|
+
<div>
|
|
193
|
+
<div
|
|
194
|
+
class="markdown-reader"
|
|
195
|
+
>
|
|
196
|
+
<h2>
|
|
197
|
+
Title\\n\\n- item 1\\n- item 2\\n\\n
|
|
198
|
+
<a
|
|
199
|
+
href="https://example.com"
|
|
200
|
+
rel="noopener noreferrer"
|
|
201
|
+
target="_blank"
|
|
202
|
+
>
|
|
203
|
+
link
|
|
204
|
+
</a>
|
|
205
|
+
</h2>
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
`;
|
package/src/components/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import HierarchyNodeFinder from "./HierarchyNodeFinder";
|
|
|
30
30
|
import HierarchySelector from "./HierarchySelector";
|
|
31
31
|
import HistoryBackButton from "./HistoryBackButton";
|
|
32
32
|
import IngestMenu from "./IngestMenu";
|
|
33
|
+
import TextPromptModal from "./TextPromptModal";
|
|
33
34
|
import LanguagesTabs from "./LanguagesTabs";
|
|
34
35
|
import LineageMenu from "./LineageMenu";
|
|
35
36
|
import Loading from "./Loading";
|
|
@@ -58,6 +59,8 @@ import TreeSelector from "./TreeSelector";
|
|
|
58
59
|
import Unauthorized from "./Unauthorized";
|
|
59
60
|
import UploadModal from "./UploadModal";
|
|
60
61
|
|
|
62
|
+
export { MarkdownEditor, MarkdownReader } from "./Markdown";
|
|
63
|
+
|
|
61
64
|
export {
|
|
62
65
|
ActiveRoute,
|
|
63
66
|
AdminMenu,
|
|
@@ -91,6 +94,7 @@ export {
|
|
|
91
94
|
HierarchySelector,
|
|
92
95
|
HistoryBackButton,
|
|
93
96
|
IngestMenu,
|
|
97
|
+
TextPromptModal,
|
|
94
98
|
LanguagesTabs,
|
|
95
99
|
LineageMenu,
|
|
96
100
|
Loading,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
.markdown-editor {
|
|
2
|
+
width: 100%;
|
|
3
|
+
height: 100%;
|
|
4
|
+
min-height: 120px;
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
margin-bottom: 1rem;
|
|
8
|
+
|
|
9
|
+
&:focus-within {
|
|
10
|
+
outline: none;
|
|
11
|
+
|
|
12
|
+
.markdown-editor-toolbar.ui.menu {
|
|
13
|
+
border: 1px solid #85b7d9;
|
|
14
|
+
border-radius: 0.28571429rem 0.28571429rem 0 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.markdown-editor-content {
|
|
18
|
+
border-color: #85b7d9;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.markdown-editor-toolbar.ui.menu {
|
|
23
|
+
background-color: #f3f4f5 !important;
|
|
24
|
+
border: 1px solid rgba(34, 36, 38, 0.15);
|
|
25
|
+
border-radius: 0.28571429rem 0.28571429rem 0 0;
|
|
26
|
+
border-bottom: 1px solid rgba(34, 36, 38, 0.15) !important;
|
|
27
|
+
flex-shrink: 0;
|
|
28
|
+
|
|
29
|
+
.item {
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.markdown-editor-content {
|
|
35
|
+
flex: 1;
|
|
36
|
+
min-height: 0;
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
width: 100%;
|
|
40
|
+
min-width: 100%;
|
|
41
|
+
min-height: 120px;
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
border: 1px solid rgba(34, 36, 38, 0.15);
|
|
44
|
+
border-top: none;
|
|
45
|
+
border-radius: 0 0 0.28571429rem 0.28571429rem;
|
|
46
|
+
padding: 8px 10px;
|
|
47
|
+
|
|
48
|
+
>div {
|
|
49
|
+
flex: 1;
|
|
50
|
+
min-height: 0;
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.tiptap {
|
|
56
|
+
width: 100%;
|
|
57
|
+
min-width: 100%;
|
|
58
|
+
min-height: 100%;
|
|
59
|
+
box-sizing: border-box;
|
|
60
|
+
flex: 1;
|
|
61
|
+
|
|
62
|
+
&:focus {
|
|
63
|
+
outline: none;
|
|
64
|
+
box-shadow: none;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.ui {
|
|
71
|
+
.form {
|
|
72
|
+
.markdown-editor .field {
|
|
73
|
+
margin-bottom: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.ui[class*="top attached"].menu {
|
|
77
|
+
margin-top: 0
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
}
|