@truedat/core 8.3.3 → 8.3.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/core",
3
- "version": "8.3.3",
3
+ "version": "8.3.5",
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.3",
54
+ "@truedat/test": "8.3.5",
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": "4659d78d53d18027bcfb8f8cdb221378e78d7ef6"
99
+ "gitHead": "12d1969c3403861d360fbdc24fd270ab1fece070"
89
100
  }
@@ -0,0 +1,368 @@
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
+ if (value.includes(" ")) {
42
+ return { valid: false, error: "markdown.editor.invalidUrl" };
43
+ }
44
+
45
+ const parts = value.split(".");
46
+ if (parts.length < 2) {
47
+ return { valid: false, error: "markdown.editor.invalidUrl" };
48
+ }
49
+
50
+ const tld = parts[parts.length - 1].split("/")[0];
51
+ const domain = parts.slice(0, -1).join(".");
52
+
53
+ if (!/^[a-z]{2,6}$/i.test(tld)) {
54
+ return { valid: false, error: "markdown.editor.invalidUrl" };
55
+ }
56
+
57
+ if (tld.length <= 3 && domain.length < 4) {
58
+ return { valid: false, error: "markdown.editor.invalidUrl" };
59
+ }
60
+
61
+ if (
62
+ domain.length < 2 ||
63
+ domain.startsWith("-") ||
64
+ domain.endsWith("-") ||
65
+ domain.endsWith(".")
66
+ ) {
67
+ return { valid: false, error: "markdown.editor.invalidUrl" };
68
+ }
69
+
70
+ const urlWithProtocol = value.match(/^https?:\/\//i)
71
+ ? value
72
+ : `https://${value}`;
73
+
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
+ return marked
82
+ .parse(markdown, { async: false })
83
+ .replace(
84
+ /<a (?=[^>]*href=)/gi,
85
+ `<a target="_blank" rel="noopener noreferrer" `,
86
+ )
87
+ .replace(
88
+ /href="(?!https?:\/\/|mailto:)([^"]+)"/gi,
89
+ (match, url) => `href="https://${url}"`,
90
+ );
91
+ }
92
+
93
+ function htmlToMarkdown(html) {
94
+ if (!html || (typeof html === "string" && html.trim() === "")) {
95
+ return "";
96
+ }
97
+ return turndownService.turndown(html);
98
+ }
99
+
100
+ export function MarkdownReader({ content = "" }) {
101
+ const html = markdownToHtml(content);
102
+ if (!content || (typeof content === "string" && content.trim() === "")) {
103
+ return null;
104
+ }
105
+ return (
106
+ <div
107
+ className="markdown-reader"
108
+ dangerouslySetInnerHTML={{ __html: html }}
109
+ />
110
+ );
111
+ }
112
+
113
+ MarkdownReader.propTypes = {
114
+ content: PropTypes.string,
115
+ };
116
+
117
+ export function MarkdownEditor({
118
+ value = "",
119
+ onChange = null,
120
+ name = null,
121
+ label = null,
122
+ placeholder = "",
123
+ disabled = false,
124
+ }) {
125
+ const { formatMessage } = useIntl();
126
+ const [linkModalOpen, setLinkModalOpen] = useState(false);
127
+ const [linkUrl, setLinkUrl] = useState("");
128
+ const [hasSelection, setHasSelection] = useState(false);
129
+ const lastEmittedMarkdownRef = useRef(value);
130
+ const editorRef = useRef(null);
131
+ const initialContent = markdownToHtml(value);
132
+
133
+ const editor = useEditor({
134
+ extensions,
135
+ content: initialContent,
136
+ editable: !disabled,
137
+ immediatelyRender: false,
138
+ editorProps: {
139
+ handlePaste(view, event) {
140
+ const editorInstance = editorRef.current;
141
+ if (!editorInstance) return false;
142
+ const text = event.clipboardData?.getData("text/plain");
143
+ if (!text || !text.trim()) return false;
144
+ const html = markdownToHtml(text);
145
+ editorInstance.commands.insertContent(html, { emitUpdate: true });
146
+ return true;
147
+ },
148
+ handleDoubleClick: (view) => {
149
+ const { state } = editor;
150
+ const { from } = state.selection;
151
+ const marks = state.doc.resolve(from).marks();
152
+ const linkMark = marks.find((mark) => mark.type.name === "link");
153
+
154
+ if (linkMark) {
155
+ const href = linkMark.attrs.href;
156
+ openLinkModal(href);
157
+ return true;
158
+ }
159
+
160
+ return false;
161
+ },
162
+ },
163
+ onUpdate: ({ editor: ed }) => {
164
+ const html = ed.getHTML();
165
+ const markdown = htmlToMarkdown(html);
166
+ lastEmittedMarkdownRef.current = markdown;
167
+ if (onChange) {
168
+ onChange(null, { name, value: markdown });
169
+ }
170
+ },
171
+ });
172
+
173
+ useEffect(() => {
174
+ editorRef.current = editor;
175
+ }, [editor]);
176
+
177
+ useEffect(() => {
178
+ if (!editor) return;
179
+ const currentValue = value ?? "";
180
+ if (currentValue !== lastEmittedMarkdownRef.current) {
181
+ lastEmittedMarkdownRef.current = currentValue;
182
+ editor.commands.setContent(markdownToHtml(currentValue), {
183
+ emitUpdate: false,
184
+ });
185
+ }
186
+ }, [value, editor]);
187
+
188
+ useEffect(() => {
189
+ if (!editor) return;
190
+ editor.setEditable(!disabled);
191
+ }, [disabled, editor]);
192
+
193
+ useEffect(() => {
194
+ if (!editor) return;
195
+ const updateSelection = () => {
196
+ const { from, to } = editor.state.selection;
197
+ setHasSelection(from !== to);
198
+ };
199
+ updateSelection();
200
+ editor.on("selectionUpdate", updateSelection);
201
+ return () => {
202
+ editor.off("selectionUpdate", updateSelection);
203
+ };
204
+ }, [editor]);
205
+
206
+ const openLinkModal = useCallback(
207
+ (url = null) => {
208
+ const previousUrl = editor.getAttributes("link").href || "";
209
+ const tempUrl = url !== null ? url : previousUrl;
210
+ const linkUrl = tempUrl.replace(/^https?:\/\//i, "");
211
+
212
+ setLinkUrl(linkUrl);
213
+ setLinkModalOpen(true);
214
+ },
215
+ [editor],
216
+ );
217
+
218
+ const handleLinkAccept = useCallback(
219
+ ({ valid, value: url }) => {
220
+ if (!valid) return;
221
+
222
+ if (!url || url.trim() === "") {
223
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
224
+ setLinkModalOpen(false);
225
+ setLinkUrl("");
226
+ return;
227
+ }
228
+
229
+ const finalUrl = url.startsWith("http") ? url : `https://${url}`;
230
+
231
+ editor
232
+ .chain()
233
+ .focus()
234
+ .extendMarkRange("link")
235
+ .setLink({ href: finalUrl })
236
+ .run();
237
+ setLinkModalOpen(false);
238
+ setLinkUrl("");
239
+ },
240
+ [editor],
241
+ );
242
+
243
+ const handleLinkCancel = useCallback(() => {
244
+ setLinkModalOpen(false);
245
+ setLinkUrl("");
246
+ }, []);
247
+
248
+ const validateLinkUrl = useCallback(
249
+ (value) => {
250
+ const hasPreviousLink = editor?.isActive("link");
251
+ return validateUrl(value, hasPreviousLink);
252
+ },
253
+ [editor],
254
+ );
255
+
256
+ if (!editor) {
257
+ return null;
258
+ }
259
+
260
+ return (
261
+ <div className="markdown-editor">
262
+ {label ? (
263
+ <div className="field">
264
+ <label>{label}</label>
265
+ </div>
266
+ ) : null}
267
+ <Menu size="small" attached="top" className="markdown-editor-toolbar">
268
+ <Menu.Item
269
+ active={editor.isActive("bold")}
270
+ onMouseDown={(e) => {
271
+ e.preventDefault();
272
+ editor.chain().focus().toggleBold().run();
273
+ }}
274
+ >
275
+ <Icon name="bold" />
276
+ </Menu.Item>
277
+ <Menu.Item
278
+ active={editor.isActive("italic")}
279
+ onMouseDown={(e) => {
280
+ e.preventDefault();
281
+ editor.chain().focus().toggleItalic().run();
282
+ }}
283
+ >
284
+ <Icon name="italic" />
285
+ </Menu.Item>
286
+ <Menu.Item
287
+ onMouseDown={(e) => {
288
+ e.preventDefault();
289
+ editor.chain().focus().toggleHeading({ level: 2 }).run();
290
+ }}
291
+ >
292
+ <Icon name="heading" size="large" />
293
+ </Menu.Item>
294
+ <Menu.Item
295
+ onMouseDown={(e) => {
296
+ e.preventDefault();
297
+ editor.chain().focus().toggleHeading({ level: 3 }).run();
298
+ }}
299
+ >
300
+ <Icon name="heading" size="small" />
301
+ </Menu.Item>
302
+ <Menu.Item
303
+ active={editor.isActive("bulletList")}
304
+ onMouseDown={(e) => {
305
+ e.preventDefault();
306
+ editor.chain().focus().toggleBulletList().run();
307
+ }}
308
+ >
309
+ <Icon name="list ul" />
310
+ </Menu.Item>
311
+ <Menu.Item
312
+ active={editor.isActive("orderedList")}
313
+ onMouseDown={(e) => {
314
+ e.preventDefault();
315
+ editor.chain().focus().toggleOrderedList().run();
316
+ }}
317
+ >
318
+ <Icon name="list ol" />
319
+ </Menu.Item>
320
+ <Menu.Item
321
+ active={editor.isActive("link")}
322
+ disabled={!hasSelection && !editor.isActive("link")}
323
+ onMouseDown={(e) => {
324
+ e.preventDefault();
325
+ if (hasSelection || editor.isActive("link")) {
326
+ openLinkModal();
327
+ }
328
+ }}
329
+ >
330
+ <Icon name="linkify" />
331
+ </Menu.Item>
332
+ </Menu>
333
+ <div className="markdown-editor-content">
334
+ <EditorContent editor={editor} placeholder={placeholder} />
335
+ </div>
336
+ <TextPromptModal
337
+ open={linkModalOpen}
338
+ onClose={handleLinkCancel}
339
+ onAccept={handleLinkAccept}
340
+ validate={validateLinkUrl}
341
+ initialValue={linkUrl}
342
+ allowEmpty={editor.isActive("link")}
343
+ header={
344
+ <>
345
+ <Icon name="linkify" />
346
+ <FormattedMessage id="markdown.editor.insertLink" />
347
+ </>
348
+ }
349
+ label={formatMessage({
350
+ id: "markdown.editor.urlLabel",
351
+ defaultMessage: "URL",
352
+ })}
353
+ placeholder="https://"
354
+ />
355
+ </div>
356
+ );
357
+ }
358
+
359
+ MarkdownEditor.propTypes = {
360
+ disabled: PropTypes.bool,
361
+ label: PropTypes.string,
362
+ name: PropTypes.string,
363
+ onChange: PropTypes.func,
364
+ placeholder: PropTypes.string,
365
+ value: PropTypes.string,
366
+ };
367
+
368
+ 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
+ `;
@@ -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
+ }