@developer_tribe/react-builder 1.2.18 → 1.2.19

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.
Files changed (36) hide show
  1. package/dist/index.cjs.js +1 -1
  2. package/dist/index.cjs.js.map +1 -1
  3. package/dist/index.esm.js +1 -1
  4. package/dist/index.esm.js.map +1 -1
  5. package/dist/index.web.cjs.js +6 -6
  6. package/dist/index.web.cjs.js.map +1 -1
  7. package/dist/index.web.esm.js +3 -3
  8. package/dist/index.web.esm.js.map +1 -1
  9. package/dist/pages/ProjectDebug.d.ts +9 -1
  10. package/dist/pages/ProjectMigrationPage.d.ts +3 -1
  11. package/dist/pages/ProjectValidationPage.d.ts +3 -2
  12. package/dist/styles.css +1 -1
  13. package/dist/utils/applyJsonTransform.d.ts +13 -0
  14. package/dist/utils/repairNodeKeys.d.ts +11 -0
  15. package/dist/utils/safeJsonStringify.d.ts +1 -0
  16. package/dist/utils/wrapNodeInMain.d.ts +2 -0
  17. package/package.json +1 -1
  18. package/src/RenderPage.tsx +17 -46
  19. package/src/assets/meta.json +1 -1
  20. package/src/build-components/PaywallProvider/PaywallProvider.tsx +2 -3
  21. package/src/build-components/Text/Text.tsx +4 -9
  22. package/src/components/AttributesEditorPanel.tsx +13 -1
  23. package/src/components/Builder.tsx +19 -5
  24. package/src/components/EditorHeader.tsx +16 -6
  25. package/src/components/JsonTextEditor.tsx +41 -0
  26. package/src/pages/DebugJsonPage.tsx +24 -3
  27. package/src/pages/ProjectDebug.tsx +66 -28
  28. package/src/pages/ProjectMigrationPage.tsx +15 -0
  29. package/src/pages/ProjectPage.tsx +160 -23
  30. package/src/pages/ProjectValidationPage.tsx +64 -1
  31. package/src/styles/layout/_project-validation.scss +29 -0
  32. package/src/utils/__special_exceptions.ts +9 -3
  33. package/src/utils/applyJsonTransform.ts +19 -0
  34. package/src/utils/repairNodeKeys.ts +90 -0
  35. package/src/utils/safeJsonStringify.ts +18 -0
  36. package/src/utils/wrapNodeInMain.ts +67 -0
@@ -11,6 +11,8 @@ import { useLogRender } from '../utils/useLogRender';
11
11
  import { getDefaultsForType, getPatternByType } from '../utils/patterns';
12
12
  import { AddComponentModal } from '../modals/AddComponentModal';
13
13
  import { BuilderButton } from './BuilderButton';
14
+ import { generateRandomKeyForNode } from '../utils/generateRandomKeyForNode';
15
+ import { collectNodeKeys } from '../utils/repairNodeKeys';
14
16
 
15
17
  type BuilderEditorProps = {
16
18
  data: Node;
@@ -156,6 +158,7 @@ export function Builder({
156
158
  }: BuilderEditorProps) {
157
159
  useLogRender('Builder');
158
160
  const [isAddModalOpen, setIsAddModalOpen] = useState(false);
161
+ const usedKeys = useMemo(() => collectNodeKeys(data), [data]);
159
162
  const breadcrumbPath = useMemo(() => {
160
163
  const path = findNodePath(data, current);
161
164
  if (path.length) return path;
@@ -190,7 +193,8 @@ export function Builder({
190
193
 
191
194
  const handleAddChild = useCallback(
192
195
  (type: string) => {
193
- const nextChild = createDefaultNode(type);
196
+ const nextUsedKeys = new Set(usedKeys);
197
+ const nextChild = createDefaultNode(type, nextUsedKeys);
194
198
 
195
199
  // Root (or selection) can be empty/null-ish: allow creating the first node.
196
200
  if (isNodeNullOrUndefined(current)) {
@@ -247,7 +251,7 @@ export function Builder({
247
251
  setData(updatedRoot);
248
252
  setCurrent(updatedParent);
249
253
  },
250
- [current, data, setData, setCurrent],
254
+ [current, data, setData, setCurrent, usedKeys],
251
255
  );
252
256
 
253
257
  const allowedChildTypes = useMemo(
@@ -383,17 +387,26 @@ export function Builder({
383
387
  return root;
384
388
  }
385
389
 
386
- function createDefaultNode(type: string): NodeData<NodeDefaultAttribute> {
390
+ function createDefaultNode(
391
+ type: string,
392
+ nextUsedKeys: Set<string>,
393
+ ): NodeData<NodeDefaultAttribute> {
387
394
  const pattern = getPatternByType(type)?.pattern;
388
395
  const defaults = getDefaultsForType(type) ?? {};
389
396
  const childrenSchema = pattern?.children as unknown;
397
+ let key = '';
398
+ do {
399
+ key = generateRandomKeyForNode(type);
400
+ } while (nextUsedKeys.has(key));
401
+ nextUsedKeys.add(key);
390
402
 
391
403
  // Special-case: CarouselProvider MUST contain a Carousel container inside the viewport
392
404
  // otherwise embla-carousel will crash (it expects viewport.firstChild.children).
393
405
  if (type === 'CarouselProvider') {
394
406
  return {
395
407
  type,
396
- children: createDefaultNode('Carousel'),
408
+ key,
409
+ children: createDefaultNode('Carousel', nextUsedKeys),
397
410
  attributes: { ...defaults },
398
411
  } as NodeData<NodeDefaultAttribute>;
399
412
  }
@@ -412,13 +425,14 @@ export function Builder({
412
425
  children = null;
413
426
  } else if (typeof childrenSchema === 'string') {
414
427
  // Specific child type like 'CarouselItem' – seed with one child to match the pattern.
415
- children = [createDefaultNode(childrenSchema)];
428
+ children = [createDefaultNode(childrenSchema, nextUsedKeys)];
416
429
  } else {
417
430
  children = null;
418
431
  }
419
432
 
420
433
  return {
421
434
  type,
435
+ key,
422
436
  children,
423
437
  attributes: { ...defaults },
424
438
  } as NodeData<NodeDefaultAttribute>;
@@ -3,6 +3,7 @@ import type { Device } from '../types/Device';
3
3
  import type { Node } from '../types/Node';
4
4
  import { copyNode } from '../utils/copyNode';
5
5
  import { getDevices } from '../utils/getDevices';
6
+ import { collectNodeKeys, repairNodeKeys } from '../utils/repairNodeKeys';
6
7
  import { useRenderStore } from '../store';
7
8
  import { useLogRender } from '../utils/useLogRender';
8
9
  import { DeviceButton } from './DeviceButton';
@@ -120,7 +121,9 @@ export function EditorHeader({
120
121
  if (!current || !editorData || !setEditorData) return;
121
122
  if (!copiedNode) return;
122
123
  const cloned = JSON.parse(JSON.stringify(copiedNode)) as Node;
123
- const updated = replaceNode(editorData, current, cloned);
124
+ const usedKeys = collectNodeKeys(editorData);
125
+ const repaired = repairNodeKeys(cloned, usedKeys);
126
+ const updated = replaceNode(editorData, current, repaired);
124
127
  useRenderStore.setState({
125
128
  copiedNode: null,
126
129
  });
@@ -129,7 +132,7 @@ export function EditorHeader({
129
132
  // Important: selection is stored by reference. After replacing `current` in the tree,
130
133
  // we must point selection to the new (cloned) node reference to keep "current node"
131
134
  // in sync with what’s rendered/edited.
132
- setCurrent(cloned);
135
+ setCurrent(repaired);
133
136
  };
134
137
 
135
138
  const cloneNode = (node: Node): Node =>
@@ -137,7 +140,7 @@ export function EditorHeader({
137
140
 
138
141
  const handleReplaceFromSample = (sample: Project) => {
139
142
  if (!setEditorData) return;
140
- const next = cloneNode(sample.data);
143
+ const next = repairNodeKeys(cloneNode(sample.data));
141
144
  setEditorData(next);
142
145
  setCurrent(next);
143
146
  if (sample.appConfig) setAppConfig(sample.appConfig);
@@ -148,6 +151,7 @@ export function EditorHeader({
148
151
  const handlePasteFromSample = (sample: Project) => {
149
152
  if (!current || !editorData || !setEditorData) return;
150
153
  const incoming = cloneNode(sample.data);
154
+ const usedKeys = collectNodeKeys(editorData);
151
155
 
152
156
  const isRecord = (v: unknown): v is Record<string, unknown> =>
153
157
  typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -181,13 +185,19 @@ export function EditorHeader({
181
185
  toast.error('Sample has no children to paste');
182
186
  return;
183
187
  }
188
+ const repairedPasteNodes = pasteNodes.map((node) =>
189
+ repairNodeKeys(node, usedKeys),
190
+ );
184
191
  let nextChildren: Node;
185
192
  if (!prevChildren) {
186
- nextChildren = pasteNodes.length === 1 ? pasteNodes[0] : pasteNodes;
193
+ nextChildren =
194
+ repairedPasteNodes.length === 1
195
+ ? repairedPasteNodes[0]
196
+ : repairedPasteNodes;
187
197
  } else if (Array.isArray(prevChildren)) {
188
- nextChildren = [...prevChildren, ...pasteNodes];
198
+ nextChildren = [...prevChildren, ...repairedPasteNodes];
189
199
  } else {
190
- nextChildren = [prevChildren, ...pasteNodes];
200
+ nextChildren = [prevChildren, ...repairedPasteNodes];
191
201
  }
192
202
 
193
203
  const nextNode: Node = { ...current, children: nextChildren } as Node;
@@ -1,4 +1,6 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react';
2
+ import type { Node } from '../types/Node';
3
+ import { wrapNodeInMain } from '../utils/wrapNodeInMain';
2
4
 
3
5
  type JsonTextEditorProps = {
4
6
  value: unknown;
@@ -74,6 +76,34 @@ export function JsonTextEditor({
74
76
  }
75
77
  };
76
78
 
79
+ const handleWrapInMain = () => {
80
+ try {
81
+ const parsed = JSON.parse(text) as unknown;
82
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
83
+ typeof v === 'object' && v !== null && !Array.isArray(v);
84
+
85
+ // Support both:
86
+ // - node JSON (wrap root)
87
+ // - project JSON (wrap `data`)
88
+ const nextValue =
89
+ isRecord(parsed) && 'data' in parsed
90
+ ? {
91
+ ...(parsed as any),
92
+ data: wrapNodeInMain((parsed as any).data as Node),
93
+ }
94
+ : wrapNodeInMain(parsed as Node);
95
+
96
+ setText(JSON.stringify(nextValue, null, 2));
97
+ setParseError(null);
98
+ setApplyError(null);
99
+ setParsedValue(nextValue);
100
+ // Intentionally NOT calling onChange here:
101
+ // user can review diff and press "Apply" explicitly.
102
+ } catch (e) {
103
+ setParseError(e instanceof Error ? e.message : 'Invalid JSON');
104
+ }
105
+ };
106
+
77
107
  const headerLabel = rootName ? `${rootName}.json` : 'data.json';
78
108
 
79
109
  return (
@@ -108,6 +138,17 @@ export function JsonTextEditor({
108
138
  >
109
139
  Format
110
140
  </button>
141
+ {!readOnly && (
142
+ <button
143
+ type="button"
144
+ className="editor-button"
145
+ onClick={handleWrapInMain}
146
+ disabled={!onChange}
147
+ title={onChange ? 'Wrap root in Main and apply' : 'Read only'}
148
+ >
149
+ Wrap in Main
150
+ </button>
151
+ )}
111
152
  {!readOnly && (
112
153
  <button
113
154
  type="button"
@@ -5,6 +5,7 @@ import { Checkbox } from '../components/Checkbox';
5
5
  import { JsonTextEditor } from '../components/JsonTextEditor';
6
6
  import { analyseAndProccess } from '../utils/analyseNode';
7
7
  import { logRenderStore } from '../utils/logRenderStore';
8
+ import { useRenderStore } from '../store';
8
9
 
9
10
  export type DebugJsonPageProps = {
10
11
  data: Node | null | undefined;
@@ -36,6 +37,7 @@ export function DebugJsonPage({
36
37
  setAppConfig,
37
38
  logLabel,
38
39
  }: DebugJsonPageProps) {
40
+ const setCurrent = useRenderStore((s) => s.setCurrent);
39
41
  const canTogglePreviewMode = typeof setPreviewMode === 'function';
40
42
  const canToggleTheme =
41
43
  typeof setAppConfig === 'function' && typeof appConfig?.theme === 'string';
@@ -43,6 +45,19 @@ export function DebugJsonPage({
43
45
  typeof setAppConfig === 'function' &&
44
46
  typeof (appConfig as any)?.isRtl !== 'undefined';
45
47
 
48
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
49
+ typeof v === 'object' && v !== null && !Array.isArray(v);
50
+
51
+ const extractNode = (value: unknown): Node => {
52
+ // Allow both:
53
+ // - raw Node JSON
54
+ // - Project wrapper JSON { name, version, data: Node }
55
+ if (isRecord(value) && 'data' in value) {
56
+ return (value as any).data as Node;
57
+ }
58
+ return value as Node;
59
+ };
60
+
46
61
  return (
47
62
  <>
48
63
  <div className="modal__header localication-modal__header">
@@ -123,9 +138,15 @@ export function DebugJsonPage({
123
138
  <JsonTextEditor
124
139
  rootName="node"
125
140
  value={data ?? ({} as any)}
126
- onChange={(next) =>
127
- setData(analyseAndProccess(next as Node) as Node)
128
- }
141
+ onChange={(next) => {
142
+ const nodeCandidate = extractNode(next);
143
+ const processed = analyseAndProccess(
144
+ nodeCandidate as Node,
145
+ ) as Node;
146
+ setData(processed);
147
+ // Keep selection in sync with the new root.
148
+ setCurrent(processed);
149
+ }}
129
150
  className="localication-modal__json-editor"
130
151
  />
131
152
  </div>
@@ -5,25 +5,7 @@ import type { Node } from '../types/Node';
5
5
  import type { Product } from '../paywall/types/paywall-types';
6
6
  import type { PaywallBenefits } from '../paywall/types/benefits';
7
7
  import { useSyncHtmlThemeClass } from '../hooks/useSyncHtmlThemeClass';
8
-
9
- function safeStringify(value: unknown): string {
10
- try {
11
- const seen = new WeakSet<object>();
12
- return JSON.stringify(
13
- value,
14
- (_key, v) => {
15
- if (typeof v === 'object' && v !== null) {
16
- if (seen.has(v as object)) return '[Circular]';
17
- seen.add(v as object);
18
- }
19
- return v;
20
- },
21
- 2,
22
- );
23
- } catch (e) {
24
- return `<< Unable to stringify value: ${String(e)} >>`;
25
- }
26
- }
8
+ import { safeJsonStringify } from '../utils/safeJsonStringify';
27
9
 
28
10
  async function copyTextToClipboard(text: string): Promise<boolean> {
29
11
  try {
@@ -68,6 +50,24 @@ function isObject(value: unknown): value is Record<string, unknown> {
68
50
  return typeof value === 'object' && value !== null && !Array.isArray(value);
69
51
  }
70
52
 
53
+ function extractNodeFromRawData(rawData: unknown): unknown {
54
+ // Support both:
55
+ // - raw node JSON (root has `type`)
56
+ // - full project JSON (root has `data`)
57
+ if (isObject(rawData)) {
58
+ if (typeof rawData.type === 'string' && rawData.type.trim()) return rawData;
59
+ const maybeData = rawData.data;
60
+ if (
61
+ isObject(maybeData) &&
62
+ typeof maybeData.type === 'string' &&
63
+ maybeData.type.trim()
64
+ ) {
65
+ return maybeData;
66
+ }
67
+ }
68
+ return rawData;
69
+ }
70
+
71
71
  function getNodeType(value: unknown): string | null {
72
72
  if (!isObject(value)) return null;
73
73
  const t = value.type;
@@ -132,6 +132,14 @@ export type ProjectDebugProps = {
132
132
  benefits: PaywallBenefits;
133
133
  canvasBg?: string;
134
134
  belowName?: ReactNode;
135
+ jsonEditor?: {
136
+ value: string;
137
+ onChange: (next: string) => void;
138
+ error?: string | null;
139
+ onSave?: () => void;
140
+ saveDisabled?: boolean;
141
+ saveLabel?: string;
142
+ };
135
143
  };
136
144
 
137
145
  export function ProjectDebug({
@@ -143,6 +151,7 @@ export function ProjectDebug({
143
151
  benefits,
144
152
  canvasBg,
145
153
  belowName,
154
+ jsonEditor,
146
155
  }: ProjectDebugProps) {
147
156
  useSyncHtmlThemeClass();
148
157
  const [previewError, setPreviewError] = useState<{
@@ -155,16 +164,18 @@ export function ProjectDebug({
155
164
  'idle',
156
165
  );
157
166
 
158
- const json = useMemo(() => safeStringify(rawData), [rawData]);
159
- const previewData = rawData as Node;
160
- const rootType = useMemo(() => getNodeType(rawData), [rawData]);
167
+ const json = useMemo(() => safeJsonStringify(rawData), [rawData]);
168
+ const jsonToCopy = jsonEditor ? jsonEditor.value : json;
169
+ const nodeRoot = useMemo(() => extractNodeFromRawData(rawData), [rawData]);
170
+ const previewData = nodeRoot as Node;
171
+ const rootType = useMemo(() => getNodeType(nodeRoot), [nodeRoot]);
161
172
  const parsedValidation = useMemo(
162
173
  () => parseValidationPrefix(validationError),
163
174
  [validationError],
164
175
  );
165
176
  const validationContext = useMemo(
166
- () => resolveNodeTypeAtPath(rawData, parsedValidation.path),
167
- [rawData, parsedValidation.path],
177
+ () => resolveNodeTypeAtPath(nodeRoot, parsedValidation.path),
178
+ [nodeRoot, parsedValidation.path],
168
179
  );
169
180
 
170
181
  const validationSummary = useMemo(() => {
@@ -241,7 +252,7 @@ export function ProjectDebug({
241
252
  type="button"
242
253
  className="editor-button"
243
254
  onClick={async () => {
244
- const ok = await copyTextToClipboard(json);
255
+ const ok = await copyTextToClipboard(jsonToCopy);
245
256
  if (!ok) return;
246
257
  setJsonCopyState('copied');
247
258
  window.setTimeout(() => setJsonCopyState('idle'), 900);
@@ -249,13 +260,40 @@ export function ProjectDebug({
249
260
  >
250
261
  {jsonCopyState === 'copied' ? 'Copied JSON' : 'Copy JSON'}
251
262
  </button>
263
+ {jsonEditor?.onSave && (
264
+ <button
265
+ type="button"
266
+ className="editor-button"
267
+ disabled={jsonEditor.saveDisabled}
268
+ onClick={() => jsonEditor.onSave?.()}
269
+ >
270
+ {jsonEditor.saveLabel ?? 'Save'}
271
+ </button>
272
+ )}
252
273
  </div>
253
274
  {belowName && (
254
275
  <div className="rb-project-debug__below-name">{belowName}</div>
255
276
  )}
256
- <pre className="rb-project-debug__code" tabIndex={0}>
257
- {json}
258
- </pre>
277
+ {jsonEditor ? (
278
+ <>
279
+ <textarea
280
+ className="rb-project-debug__code-editor"
281
+ value={jsonEditor.value}
282
+ onChange={(e) => jsonEditor.onChange(e.target.value)}
283
+ spellCheck={false}
284
+ aria-label="Edit JSON"
285
+ />
286
+ {jsonEditor.error ? (
287
+ <div className="rb-project-debug__json-error" role="alert">
288
+ {jsonEditor.error}
289
+ </div>
290
+ ) : null}
291
+ </>
292
+ ) : (
293
+ <pre className="rb-project-debug__code" tabIndex={0}>
294
+ {json}
295
+ </pre>
296
+ )}
259
297
  </section>
260
298
 
261
299
  <section
@@ -9,6 +9,7 @@ export type ProjectMigrationPageProps = {
9
9
 
10
10
  projectVersion: string;
11
11
  requiredVersion: string;
12
+ showFixVersionMeta?: boolean;
12
13
 
13
14
  pendingMigrations: Array<{
14
15
  id: string;
@@ -25,6 +26,7 @@ export type ProjectMigrationPageProps = {
25
26
  migrating?: boolean;
26
27
  onContinueWithoutValidation?: () => void;
27
28
  onMigrateNow?: () => void;
29
+ onFixVersionMeta?: () => void;
28
30
  };
29
31
 
30
32
  export function ProjectMigrationPage({
@@ -32,6 +34,7 @@ export function ProjectMigrationPage({
32
34
  rawData,
33
35
  projectVersion,
34
36
  requiredVersion,
37
+ showFixVersionMeta,
35
38
  pendingMigrations,
36
39
  products,
37
40
  benefits,
@@ -40,6 +43,7 @@ export function ProjectMigrationPage({
40
43
  migrating,
41
44
  onContinueWithoutValidation,
42
45
  onMigrateNow,
46
+ onFixVersionMeta,
43
47
  }: ProjectMigrationPageProps) {
44
48
  const message = `Migration required: project version ${projectVersion} is lower than ${requiredVersion}.`;
45
49
 
@@ -78,6 +82,17 @@ export function ProjectMigrationPage({
78
82
  {migrating ? 'Migrating…' : 'Migrate now'}
79
83
  </button>
80
84
 
85
+ {showFixVersionMeta && (
86
+ <button
87
+ type="button"
88
+ className="editor-button"
89
+ disabled={!!migrating}
90
+ onClick={() => onFixVersionMeta?.()}
91
+ >
92
+ Fix version meta
93
+ </button>
94
+ )}
95
+
81
96
  <button
82
97
  type="button"
83
98
  className="editor-button"