@developer_tribe/react-builder 1.2.17 → 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.
- package/dist/build-components/BackgroundImage/BackgroundImageProps.generated.d.ts +1 -1
- package/dist/build-components/Button/ButtonProps.generated.d.ts +1 -1
- package/dist/build-components/Carousel/CarouselProps.generated.d.ts +1 -1
- package/dist/build-components/CarouselButtons/CarouselButtonsProps.generated.d.ts +1 -1
- package/dist/build-components/CarouselDots/CarouselDotsProps.generated.d.ts +1 -1
- package/dist/build-components/CarouselItem/CarouselItemProps.generated.d.ts +1 -1
- package/dist/build-components/CarouselProvider/CarouselProviderProps.generated.d.ts +1 -1
- package/dist/build-components/Image/ImageProps.generated.d.ts +1 -1
- package/dist/build-components/Main/MainProps.generated.d.ts +1 -1
- package/dist/build-components/Onboard/OnboardProps.generated.d.ts +1 -1
- package/dist/build-components/OnboardButton/OnboardButtonProps.generated.d.ts +1 -1
- package/dist/build-components/OnboardButtons/OnboardButtonsProps.generated.d.ts +1 -1
- package/dist/build-components/OnboardDot/OnboardDotProps.generated.d.ts +1 -1
- package/dist/build-components/OnboardItem/OnboardItemProps.generated.d.ts +1 -1
- package/dist/build-components/OnboardProvider/OnboardProviderProps.generated.d.ts +1 -1
- package/dist/build-components/PaywallOptions/PaywallOptionsProps.generated.d.ts +1 -1
- package/dist/build-components/PaywallProvider/PaywallProviderProps.generated.d.ts +1 -1
- package/dist/build-components/RadioButton/RadioButtonProps.generated.d.ts +1 -1
- package/dist/build-components/Text/TextProps.generated.d.ts +1 -1
- package/dist/build-components/View/ViewProps.generated.d.ts +0 -2
- package/dist/build-components/patterns.generated.d.ts +38 -40
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +2 -2
- package/dist/index.esm.js.map +1 -1
- package/dist/index.web.cjs.js +6 -6
- package/dist/index.web.cjs.js.map +1 -1
- package/dist/index.web.esm.js +2 -2
- package/dist/index.web.esm.js.map +1 -1
- package/dist/pages/ProjectDebug.d.ts +9 -1
- package/dist/pages/ProjectMigrationPage.d.ts +3 -1
- package/dist/pages/ProjectValidationPage.d.ts +3 -2
- package/dist/styles.css +1 -1
- package/dist/utils/applyJsonTransform.d.ts +13 -0
- package/dist/utils/repairNodeKeys.d.ts +11 -0
- package/dist/utils/safeJsonStringify.d.ts +1 -0
- package/dist/utils/wrapNodeInMain.d.ts +2 -0
- package/package.json +1 -1
- package/src/RenderPage.tsx +17 -70
- package/src/assets/meta.json +1 -1
- package/src/build-components/BackgroundImage/BackgroundImageProps.generated.ts +1 -1
- package/src/build-components/Button/ButtonProps.generated.ts +1 -1
- package/src/build-components/Carousel/CarouselProps.generated.ts +1 -1
- package/src/build-components/CarouselButtons/CarouselButtonsProps.generated.ts +1 -1
- package/src/build-components/CarouselDots/CarouselDotsProps.generated.ts +1 -1
- package/src/build-components/CarouselItem/CarouselItemProps.generated.ts +1 -1
- package/src/build-components/CarouselProvider/CarouselProviderProps.generated.ts +1 -1
- package/src/build-components/Image/ImageProps.generated.ts +1 -1
- package/src/build-components/Main/MainProps.generated.ts +1 -1
- package/src/build-components/Onboard/OnboardProps.generated.ts +1 -1
- package/src/build-components/OnboardButton/OnboardButtonProps.generated.ts +1 -1
- package/src/build-components/OnboardButtons/OnboardButtonsProps.generated.ts +1 -1
- package/src/build-components/OnboardDot/OnboardDotProps.generated.ts +1 -1
- package/src/build-components/OnboardItem/OnboardItemProps.generated.ts +1 -1
- package/src/build-components/OnboardProvider/OnboardProviderProps.generated.ts +1 -1
- package/src/build-components/PaywallOptions/PaywallOptionsProps.generated.ts +1 -1
- package/src/build-components/PaywallProvider/PaywallProvider.tsx +2 -3
- package/src/build-components/PaywallProvider/PaywallProviderProps.generated.ts +1 -1
- package/src/build-components/RadioButton/RadioButtonProps.generated.ts +1 -1
- package/src/build-components/Text/Text.tsx +4 -9
- package/src/build-components/Text/TextProps.generated.ts +1 -1
- package/src/build-components/View/ViewProps.generated.ts +0 -2
- package/src/build-components/View/pattern.json +0 -2
- package/src/build-components/patterns.generated.ts +38 -40
- package/src/components/AttributesEditorPanel.tsx +13 -1
- package/src/components/Builder.tsx +19 -5
- package/src/components/BuilderProvider.tsx +0 -9
- package/src/components/EditorHeader.tsx +16 -6
- package/src/components/JsonTextEditor.tsx +41 -0
- package/src/mockOS/managers/navigationManager.ts +0 -3
- package/src/pages/DebugJsonPage.tsx +24 -3
- package/src/pages/ProjectDebug.tsx +66 -28
- package/src/pages/ProjectMigrationPage.tsx +15 -7
- package/src/pages/ProjectPage.tsx +160 -30
- package/src/pages/ProjectValidationPage.tsx +64 -1
- package/src/styles/layout/_project-validation.scss +29 -0
- package/src/utils/__special_exceptions.ts +9 -3
- package/src/utils/applyJsonTransform.ts +19 -0
- package/src/utils/repairNodeKeys.ts +90 -0
- package/src/utils/safeJsonStringify.ts +18 -0
- package/src/utils/wrapNodeInMain.ts +67 -0
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
import type { Fonts } from '../types/Fonts';
|
|
37
37
|
import { useProjectFonts } from '../hooks/useProjectFonts';
|
|
38
38
|
import { resolveProjectForSave } from './projectPageUtils';
|
|
39
|
+
import { getDefaultProject } from '../utils/getDefaultProject';
|
|
40
|
+
import { CURRENT_PROJECT_VERSION } from '../migrations/migratePipe';
|
|
39
41
|
export type ProjectPageProps = {
|
|
40
42
|
project: Project;
|
|
41
43
|
onSaveProject: (project: Project) => void;
|
|
@@ -75,10 +77,6 @@ export function ProjectPage({
|
|
|
75
77
|
typography.fonts.find((f) => f?.isMain)?.name ??
|
|
76
78
|
typography.fonts[0]?.name;
|
|
77
79
|
useProjectFonts({ fonts: typography.fonts, appFont: resolvedAppFont });
|
|
78
|
-
const resolvedName = name ?? project.name;
|
|
79
|
-
const resolvedProjectColors = projectColors ?? project.projectColors;
|
|
80
|
-
const isEmptyProjectData =
|
|
81
|
-
isNodeNullOrUndefined(project.data) || isEmptyObject(project.data);
|
|
82
80
|
// useRenderStore will be removed
|
|
83
81
|
const {
|
|
84
82
|
current,
|
|
@@ -103,10 +101,15 @@ export function ProjectPage({
|
|
|
103
101
|
}));
|
|
104
102
|
const resolvedAppConfig = appConfig ?? storeAppConfig ?? defaultAppConfig;
|
|
105
103
|
const [overrideProject, setOverrideProject] = useState<Project | null>(null);
|
|
104
|
+
const activeProject = overrideProject ?? project;
|
|
105
|
+
const resolvedName = name ?? activeProject.name;
|
|
106
|
+
const resolvedProjectColors = projectColors ?? activeProject.projectColors;
|
|
107
|
+
const isEmptyProjectData =
|
|
108
|
+
isNodeNullOrUndefined(activeProject.data) ||
|
|
109
|
+
isEmptyObject(activeProject.data);
|
|
106
110
|
const [editorData, setEditorData] = useState<Node>(() => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return analyseAndProccess({ type: 'Main', children: [] }) as Node;
|
|
111
|
+
// Empty project: keep data null-ish, show empty state.
|
|
112
|
+
return null;
|
|
110
113
|
});
|
|
111
114
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
112
115
|
const [validationErrorStack, setValidationErrorStack] = useState<
|
|
@@ -221,7 +224,7 @@ export function ProjectPage({
|
|
|
221
224
|
setMinLoadingDelayDone(false);
|
|
222
225
|
const timer = setTimeout(() => setMinLoadingDelayDone(true), 1000);
|
|
223
226
|
return () => clearTimeout(timer);
|
|
224
|
-
}, [
|
|
227
|
+
}, [activeProject.data]);
|
|
225
228
|
|
|
226
229
|
useEffect(() => {
|
|
227
230
|
try {
|
|
@@ -234,7 +237,7 @@ export function ProjectPage({
|
|
|
234
237
|
setValidationError(null);
|
|
235
238
|
setValidationErrorStack(null);
|
|
236
239
|
// Version gate: if project is older than the current schema, show migration UI.
|
|
237
|
-
const pipe = getMigrationPipe(
|
|
240
|
+
const pipe = getMigrationPipe(activeProject);
|
|
238
241
|
if (!bypassValidation && pipe.required) {
|
|
239
242
|
setMigrationGate(pipe);
|
|
240
243
|
setEditorData(null);
|
|
@@ -245,13 +248,17 @@ export function ProjectPage({
|
|
|
245
248
|
if (bypassValidation) {
|
|
246
249
|
// Best-effort: let the user continue with the raw data even if invalid.
|
|
247
250
|
// This may still crash the preview, but it unblocks users for debugging.
|
|
248
|
-
setEditorData(
|
|
249
|
-
setCurrent(
|
|
251
|
+
setEditorData(activeProject.data as unknown as Node);
|
|
252
|
+
setCurrent(activeProject.data as unknown as Node);
|
|
250
253
|
return;
|
|
251
254
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
if (isEmptyProjectData) {
|
|
256
|
+
setEditorData(null);
|
|
257
|
+
setCurrent(null);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const inputNode: Node = activeProject.data as Node;
|
|
255
262
|
|
|
256
263
|
const processed = analyseAndProccess(inputNode);
|
|
257
264
|
if (!processed) return;
|
|
@@ -268,7 +275,7 @@ export function ProjectPage({
|
|
|
268
275
|
setEditorData(null);
|
|
269
276
|
setCurrent(null);
|
|
270
277
|
}
|
|
271
|
-
}, [
|
|
278
|
+
}, [activeProject, activeProject.data, bypassValidation, setCurrent]);
|
|
272
279
|
|
|
273
280
|
const showLoading =
|
|
274
281
|
!isEmptyProjectData && (editorData === null || !minLoadingDelayDone);
|
|
@@ -289,13 +296,6 @@ export function ProjectPage({
|
|
|
289
296
|
<EditorHeader
|
|
290
297
|
onSaveProject={() => {
|
|
291
298
|
try {
|
|
292
|
-
console.info('[ProjectPage] onSaveProject clicked', {
|
|
293
|
-
name: project.name,
|
|
294
|
-
hasOverrideProject: !!overrideProject,
|
|
295
|
-
hasEditorData: !!editorData,
|
|
296
|
-
hasResolvedProjectColors: !!resolvedProjectColors,
|
|
297
|
-
bypassValidation,
|
|
298
|
-
});
|
|
299
299
|
logger.info('ProjectPage', 'save project', { name: project.name });
|
|
300
300
|
if (onSaveProjectColors && resolvedProjectColors) {
|
|
301
301
|
onSaveProjectColors(resolvedProjectColors);
|
|
@@ -329,9 +329,10 @@ export function ProjectPage({
|
|
|
329
329
|
{migrationGate ? (
|
|
330
330
|
<ProjectMigrationPage
|
|
331
331
|
name={resolvedName}
|
|
332
|
-
rawData={
|
|
332
|
+
rawData={activeProject}
|
|
333
333
|
projectVersion={migrationGate.projectVersion}
|
|
334
334
|
requiredVersion={migrationGate.requiredVersion}
|
|
335
|
+
showFixVersionMeta={migrationGate.projectVersion === '0.0.0'}
|
|
335
336
|
pendingMigrations={migrationGate.pending.map((m) => ({
|
|
336
337
|
id: m.id,
|
|
337
338
|
title: m.title,
|
|
@@ -345,15 +346,37 @@ export function ProjectPage({
|
|
|
345
346
|
onContinueWithoutValidation={() => {
|
|
346
347
|
setBypassValidation(true);
|
|
347
348
|
setMigrationGate(null);
|
|
348
|
-
setEditorData(
|
|
349
|
-
setCurrent(
|
|
349
|
+
setEditorData(activeProject.data as unknown as Node);
|
|
350
|
+
setCurrent(activeProject.data as unknown as Node);
|
|
350
351
|
setMinLoadingDelayDone(true);
|
|
351
352
|
}}
|
|
352
353
|
onMigrateNow={() => {
|
|
353
354
|
try {
|
|
354
355
|
setIsMigrating(true);
|
|
356
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
357
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
358
|
+
const isNodeLike = (v: unknown): v is { type: string } =>
|
|
359
|
+
isRecord(v) &&
|
|
360
|
+
typeof v.type === 'string' &&
|
|
361
|
+
v.type.trim().length > 0;
|
|
362
|
+
|
|
363
|
+
// If the incoming "project" is actually a raw Node (no version),
|
|
364
|
+
// wrap it into a valid Project shape before migrating/saving.
|
|
365
|
+
const projectForMigration: Project =
|
|
366
|
+
isNodeLike(activeProject) &&
|
|
367
|
+
!(
|
|
368
|
+
isRecord(activeProject) &&
|
|
369
|
+
typeof (activeProject as any).version === 'string'
|
|
370
|
+
)
|
|
371
|
+
? getDefaultProject({
|
|
372
|
+
name: `imported-${Math.random().toString(36).slice(2, 8)}`,
|
|
373
|
+
version: CURRENT_PROJECT_VERSION,
|
|
374
|
+
data: activeProject as unknown as Node,
|
|
375
|
+
})
|
|
376
|
+
: activeProject;
|
|
377
|
+
|
|
355
378
|
const { project: migratedProject } =
|
|
356
|
-
runProjectMigrations(
|
|
379
|
+
runProjectMigrations(projectForMigration);
|
|
357
380
|
onSaveProject(migratedProject);
|
|
358
381
|
setOverrideProject(migratedProject);
|
|
359
382
|
setBypassValidation(true);
|
|
@@ -365,11 +388,61 @@ export function ProjectPage({
|
|
|
365
388
|
setIsMigrating(false);
|
|
366
389
|
}
|
|
367
390
|
}}
|
|
391
|
+
onFixVersionMeta={() => {
|
|
392
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
393
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
394
|
+
const isNodeLike = (v: unknown): v is { type: string } =>
|
|
395
|
+
isRecord(v) &&
|
|
396
|
+
typeof v.type === 'string' &&
|
|
397
|
+
v.type.trim().length > 0;
|
|
398
|
+
const isAllowedProjectType = (
|
|
399
|
+
v: unknown,
|
|
400
|
+
): v is 'paywall' | 'onboard' | 'other' =>
|
|
401
|
+
v === 'paywall' || v === 'onboard' || v === 'other';
|
|
402
|
+
|
|
403
|
+
const fixedName =
|
|
404
|
+
typeof (activeProject as any)?.name === 'string' &&
|
|
405
|
+
String((activeProject as any).name).trim()
|
|
406
|
+
? String((activeProject as any).name).trim()
|
|
407
|
+
: `imported-${Math.random().toString(36).slice(2, 8)}`;
|
|
408
|
+
|
|
409
|
+
const activeAny = (
|
|
410
|
+
isRecord(activeProject) ? activeProject : null
|
|
411
|
+
) as Record<string, unknown> | null;
|
|
412
|
+
const nodeCandidate = (
|
|
413
|
+
activeAny && 'data' in activeAny
|
|
414
|
+
? (activeAny as any).data
|
|
415
|
+
: isNodeLike(activeProject)
|
|
416
|
+
? (activeProject as unknown as Node)
|
|
417
|
+
: null
|
|
418
|
+
) as Node | null;
|
|
419
|
+
|
|
420
|
+
const fixedProject = getDefaultProject({
|
|
421
|
+
name: fixedName,
|
|
422
|
+
version: CURRENT_PROJECT_VERSION,
|
|
423
|
+
data: nodeCandidate,
|
|
424
|
+
appConfig: (activeAny as any)?.appConfig,
|
|
425
|
+
projectColors: (activeAny as any)?.projectColors,
|
|
426
|
+
type: isAllowedProjectType((activeAny as any)?.type)
|
|
427
|
+
? ((activeAny as any).type as any)
|
|
428
|
+
: undefined,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// This action only fixes project metadata. It intentionally does NOT
|
|
432
|
+
// validate/normalize node data (it might still be invalid).
|
|
433
|
+
onSaveProject(fixedProject);
|
|
434
|
+
setOverrideProject(fixedProject);
|
|
435
|
+
setBypassValidation(false);
|
|
436
|
+
setMigrationGate(null);
|
|
437
|
+
setValidationError(null);
|
|
438
|
+
setValidationErrorStack(null);
|
|
439
|
+
toast.success('Fixed version meta');
|
|
440
|
+
}}
|
|
368
441
|
/>
|
|
369
442
|
) : validationError ? (
|
|
370
443
|
<ProjectValidationPage
|
|
371
444
|
name={resolvedName}
|
|
372
|
-
rawData={
|
|
445
|
+
rawData={activeProject}
|
|
373
446
|
validationError={validationError}
|
|
374
447
|
validationErrorStack={validationErrorStack ?? undefined}
|
|
375
448
|
products={products}
|
|
@@ -379,10 +452,67 @@ export function ProjectPage({
|
|
|
379
452
|
setBypassValidation(true);
|
|
380
453
|
setValidationError(null);
|
|
381
454
|
setValidationErrorStack(null);
|
|
382
|
-
setEditorData(
|
|
383
|
-
setCurrent(
|
|
455
|
+
setEditorData(activeProject.data as unknown as Node);
|
|
456
|
+
setCurrent(activeProject.data as unknown as Node);
|
|
384
457
|
setMinLoadingDelayDone(true);
|
|
385
458
|
}}
|
|
459
|
+
onSaveEditedRawData={(nextRawData) => {
|
|
460
|
+
try {
|
|
461
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
462
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
463
|
+
|
|
464
|
+
// Accept both:
|
|
465
|
+
// - a raw Node (what the app uses internally)
|
|
466
|
+
// - a full Project-like wrapper { name, version, data: Node }
|
|
467
|
+
const parsed = nextRawData;
|
|
468
|
+
let nodeCandidate: unknown = parsed;
|
|
469
|
+
let nextName: string | undefined;
|
|
470
|
+
let nextVersion: string | undefined;
|
|
471
|
+
|
|
472
|
+
if (isRecord(parsed) && 'data' in parsed) {
|
|
473
|
+
nodeCandidate = (parsed as Record<string, unknown>).data;
|
|
474
|
+
const maybeName = (parsed as Record<string, unknown>).name;
|
|
475
|
+
const maybeVersion = (parsed as Record<string, unknown>)
|
|
476
|
+
.version;
|
|
477
|
+
if (typeof maybeName === 'string') nextName = maybeName;
|
|
478
|
+
if (typeof maybeVersion === 'string')
|
|
479
|
+
nextVersion = maybeVersion;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const processed = analyseAndProccess(nodeCandidate as Node);
|
|
483
|
+
if (!processed) throw new Error('Node is not valid');
|
|
484
|
+
|
|
485
|
+
const nextProject: Project = {
|
|
486
|
+
...activeProject,
|
|
487
|
+
...(nextName ? { name: nextName } : null),
|
|
488
|
+
...(nextVersion ? { version: nextVersion } : null),
|
|
489
|
+
data: nodeCandidate as Project['data'],
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
onSaveProject(nextProject);
|
|
493
|
+
setOverrideProject(nextProject);
|
|
494
|
+
setBypassValidation(false);
|
|
495
|
+
setValidationError(null);
|
|
496
|
+
setValidationErrorStack(null);
|
|
497
|
+
setEditorData(processed);
|
|
498
|
+
setCurrent(processed);
|
|
499
|
+
setMinLoadingDelayDone(true);
|
|
500
|
+
toast.success('Saved');
|
|
501
|
+
} catch (e) {
|
|
502
|
+
logger.error(
|
|
503
|
+
'ProjectPage',
|
|
504
|
+
'save JSON from validation failed',
|
|
505
|
+
e,
|
|
506
|
+
);
|
|
507
|
+
setValidationError(
|
|
508
|
+
e instanceof Error ? e.message : 'Node is not valid',
|
|
509
|
+
);
|
|
510
|
+
setValidationErrorStack(
|
|
511
|
+
e instanceof Error ? (e.stack ?? null) : null,
|
|
512
|
+
);
|
|
513
|
+
toast.error('Save failed');
|
|
514
|
+
}
|
|
515
|
+
}}
|
|
386
516
|
/>
|
|
387
517
|
) : (
|
|
388
518
|
<>
|
|
@@ -484,7 +614,7 @@ export function ProjectPage({
|
|
|
484
614
|
</div>
|
|
485
615
|
)}
|
|
486
616
|
{/* NOTE: In React Native, `products` should come from an IAP wrapper (e.g. `react-native-iap`). */}
|
|
487
|
-
{!showLoading &&
|
|
617
|
+
{!showLoading && (
|
|
488
618
|
<RenderPage
|
|
489
619
|
data={editorData}
|
|
490
620
|
name={resolvedName}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode, useEffect, useState } from 'react';
|
|
2
2
|
import type { Product } from '../paywall/types/paywall-types';
|
|
3
3
|
import type { PaywallBenefits } from '../paywall/types/benefits';
|
|
4
4
|
import { ProjectDebug } from './ProjectDebug';
|
|
5
|
+
import { safeJsonStringify } from '../utils/safeJsonStringify';
|
|
6
|
+
import type { Node } from '../types/Node';
|
|
7
|
+
import { wrapNodeInMain } from '../utils/wrapNodeInMain';
|
|
5
8
|
|
|
6
9
|
export type ProjectValidationPageProps = {
|
|
7
10
|
name: string;
|
|
@@ -13,6 +16,7 @@ export type ProjectValidationPageProps = {
|
|
|
13
16
|
canvasBg?: string;
|
|
14
17
|
belowName?: ReactNode;
|
|
15
18
|
onContinueWithoutValidation?: () => void;
|
|
19
|
+
onSaveEditedRawData?: (nextRawData: unknown) => void;
|
|
16
20
|
};
|
|
17
21
|
|
|
18
22
|
export function ProjectValidationPage({
|
|
@@ -25,7 +29,40 @@ export function ProjectValidationPage({
|
|
|
25
29
|
canvasBg,
|
|
26
30
|
belowName,
|
|
27
31
|
onContinueWithoutValidation,
|
|
32
|
+
onSaveEditedRawData,
|
|
28
33
|
}: ProjectValidationPageProps) {
|
|
34
|
+
const [jsonText, setJsonText] = useState<string>(() =>
|
|
35
|
+
safeJsonStringify(rawData),
|
|
36
|
+
);
|
|
37
|
+
const [jsonParseError, setJsonParseError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
const wrapInMain = () => {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(jsonText) as unknown;
|
|
42
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
43
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
44
|
+
|
|
45
|
+
const nodeCandidate: unknown =
|
|
46
|
+
isRecord(parsed) && 'data' in parsed ? (parsed as any).data : parsed;
|
|
47
|
+
|
|
48
|
+
const wrapped = wrapNodeInMain(nodeCandidate as Node);
|
|
49
|
+
const nextValue =
|
|
50
|
+
isRecord(parsed) && 'data' in parsed
|
|
51
|
+
? { ...(parsed as any), data: wrapped }
|
|
52
|
+
: wrapped;
|
|
53
|
+
|
|
54
|
+
setJsonText(JSON.stringify(nextValue, null, 2));
|
|
55
|
+
setJsonParseError(null);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
setJsonParseError(e instanceof Error ? e.message : String(e));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
setJsonText(safeJsonStringify(rawData));
|
|
63
|
+
setJsonParseError(null);
|
|
64
|
+
}, [rawData]);
|
|
65
|
+
|
|
29
66
|
return (
|
|
30
67
|
<ProjectDebug
|
|
31
68
|
name={name}
|
|
@@ -35,6 +72,25 @@ export function ProjectValidationPage({
|
|
|
35
72
|
products={products}
|
|
36
73
|
benefits={benefits}
|
|
37
74
|
canvasBg={canvasBg}
|
|
75
|
+
jsonEditor={{
|
|
76
|
+
value: jsonText,
|
|
77
|
+
onChange: (next) => {
|
|
78
|
+
setJsonText(next);
|
|
79
|
+
setJsonParseError(null);
|
|
80
|
+
},
|
|
81
|
+
error: jsonParseError,
|
|
82
|
+
onSave: () => {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(jsonText) as unknown;
|
|
85
|
+
setJsonParseError(null);
|
|
86
|
+
onSaveEditedRawData?.(parsed);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
setJsonParseError(e instanceof Error ? e.message : String(e));
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
saveDisabled: !onSaveEditedRawData,
|
|
92
|
+
saveLabel: 'Save JSON',
|
|
93
|
+
}}
|
|
38
94
|
belowName={
|
|
39
95
|
<>
|
|
40
96
|
<div className="rb-project-validation__actions">
|
|
@@ -45,6 +101,13 @@ export function ProjectValidationPage({
|
|
|
45
101
|
>
|
|
46
102
|
Continue without validation
|
|
47
103
|
</button>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
className="editor-button"
|
|
107
|
+
onClick={() => wrapInMain()}
|
|
108
|
+
>
|
|
109
|
+
Wrap in Main
|
|
110
|
+
</button>
|
|
48
111
|
</div>
|
|
49
112
|
{belowName}
|
|
50
113
|
</>
|
|
@@ -163,6 +163,35 @@
|
|
|
163
163
|
background: colors.$canvasColor;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
.rb-project-debug__code-editor {
|
|
167
|
+
@include card;
|
|
168
|
+
width: 100%;
|
|
169
|
+
margin: 0;
|
|
170
|
+
padding: sizes.$spaceCozy;
|
|
171
|
+
border: 1px solid colors.$borderColor;
|
|
172
|
+
font-family:
|
|
173
|
+
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
|
174
|
+
'Courier New', monospace;
|
|
175
|
+
font-size: 12px;
|
|
176
|
+
line-height: 1.5;
|
|
177
|
+
white-space: pre;
|
|
178
|
+
overflow: auto;
|
|
179
|
+
resize: vertical;
|
|
180
|
+
min-height: 220px;
|
|
181
|
+
max-height: calc(100vh - 60px - 160px);
|
|
182
|
+
background: colors.$canvasColor;
|
|
183
|
+
color: colors.$textColor;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.rb-project-debug__json-error {
|
|
187
|
+
margin: sizes.$spaceTight 0 0 0;
|
|
188
|
+
font-size: 12px;
|
|
189
|
+
line-height: 1.35;
|
|
190
|
+
color: colors.$dangerColor;
|
|
191
|
+
font-weight: 600;
|
|
192
|
+
word-break: break-word;
|
|
193
|
+
}
|
|
194
|
+
|
|
166
195
|
/* Legacy selectors kept for backward compatibility (no-op if unused) */
|
|
167
196
|
.rb-project-validation__error {
|
|
168
197
|
margin: 0 0 sizes.$spaceCozy 0;
|
|
@@ -69,9 +69,15 @@ export function normalizeNodeForValidation(
|
|
|
69
69
|
|
|
70
70
|
const recordData = node as NodeData<NodeDefaultAttribute>;
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
let attributes: unknown = recordData.attributes;
|
|
73
|
+
// Common persisted mistake: attributes saved as an empty array (`[]`).
|
|
74
|
+
// Treat it as "no attributes" rather than failing validation.
|
|
75
|
+
if (Array.isArray(attributes) && attributes.length === 0) {
|
|
76
|
+
attributes = {};
|
|
77
|
+
}
|
|
78
|
+
attributes = isPlainObject(attributes)
|
|
79
|
+
? (normalizeUnknownValue(attributes) as Record<string, unknown>)
|
|
80
|
+
: attributes;
|
|
75
81
|
|
|
76
82
|
const children =
|
|
77
83
|
recordData.children !== undefined
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ApplyJsonTransformResult =
|
|
2
|
+
| { ok: true; nextText: string; nextValue: unknown }
|
|
3
|
+
| { ok: false; error: string };
|
|
4
|
+
|
|
5
|
+
export function applyJsonTransform(args: {
|
|
6
|
+
text: string;
|
|
7
|
+
transform: (value: unknown) => unknown;
|
|
8
|
+
indent?: number;
|
|
9
|
+
}): ApplyJsonTransformResult {
|
|
10
|
+
const { text, transform, indent = 2 } = args;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(text) as unknown;
|
|
13
|
+
const nextValue = transform(parsed);
|
|
14
|
+
const nextText = JSON.stringify(nextValue, null, indent);
|
|
15
|
+
return { ok: true, nextText, nextValue };
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Node, NodeData } from '../types/Node';
|
|
2
|
+
import { generateRandomKeyForNode } from './generateRandomKeyForNode';
|
|
3
|
+
|
|
4
|
+
function isNodeDataLike(value: unknown): value is NodeData {
|
|
5
|
+
return (
|
|
6
|
+
typeof value === 'object' &&
|
|
7
|
+
value !== null &&
|
|
8
|
+
!Array.isArray(value) &&
|
|
9
|
+
'type' in (value as any) &&
|
|
10
|
+
'children' in (value as any)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeKey(value: unknown): string | undefined {
|
|
15
|
+
if (typeof value !== 'string') return undefined;
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectNodeKeysInto(node: Node, keys: Set<string>) {
|
|
21
|
+
if (node === null || node === undefined) return;
|
|
22
|
+
if (typeof node === 'string') return;
|
|
23
|
+
if (Array.isArray(node)) {
|
|
24
|
+
node.forEach((child) => collectNodeKeysInto(child, keys));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (!isNodeDataLike(node)) return;
|
|
28
|
+
|
|
29
|
+
const key = normalizeKey(node.key);
|
|
30
|
+
if (key) keys.add(key);
|
|
31
|
+
collectNodeKeysInto(node.children as Node, keys);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function collectNodeKeys(root: Node): Set<string> {
|
|
35
|
+
const keys = new Set<string>();
|
|
36
|
+
collectNodeKeysInto(root, keys);
|
|
37
|
+
return keys;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function generateUniqueKey(type: string, usedKeys: Set<string>): string {
|
|
41
|
+
let next = '';
|
|
42
|
+
do {
|
|
43
|
+
next = generateRandomKeyForNode(type || 'Node');
|
|
44
|
+
} while (usedKeys.has(next));
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ensures that all NodeData-like records in the tree have unique non-empty `key`s.
|
|
50
|
+
* - Missing keys are generated.
|
|
51
|
+
* - Duplicate keys (against `usedKeys`) are repaired by generating a new unique key.
|
|
52
|
+
* - Non NodeData-like objects are left untouched.
|
|
53
|
+
*
|
|
54
|
+
* `usedKeys` is mutated by design (to track uniqueness across recursion).
|
|
55
|
+
*/
|
|
56
|
+
export function repairNodeKeys(
|
|
57
|
+
root: Node,
|
|
58
|
+
usedKeys: Set<string> = new Set(),
|
|
59
|
+
): Node {
|
|
60
|
+
if (root === null || root === undefined) return root;
|
|
61
|
+
if (typeof root === 'string') return root;
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(root)) {
|
|
64
|
+
let changed = false;
|
|
65
|
+
const next: Node[] = root.map((child): Node => {
|
|
66
|
+
const repaired: Node = repairNodeKeys(child, usedKeys);
|
|
67
|
+
if (repaired !== child) changed = true;
|
|
68
|
+
return repaired;
|
|
69
|
+
});
|
|
70
|
+
return changed ? next : root;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!isNodeDataLike(root)) return root;
|
|
74
|
+
|
|
75
|
+
const prev = root as NodeData;
|
|
76
|
+
const prevKey = normalizeKey(prev.key);
|
|
77
|
+
const needsNewKey = !prevKey || usedKeys.has(prevKey);
|
|
78
|
+
const nextKey = needsNewKey
|
|
79
|
+
? generateUniqueKey(prev.type, usedKeys)
|
|
80
|
+
: prevKey;
|
|
81
|
+
usedKeys.add(nextKey);
|
|
82
|
+
|
|
83
|
+
const prevChildren = prev.children as Node;
|
|
84
|
+
const nextChildren = repairNodeKeys(prevChildren, usedKeys) as Node;
|
|
85
|
+
|
|
86
|
+
if (nextKey !== prevKey || nextChildren !== prevChildren) {
|
|
87
|
+
return { ...prev, key: nextKey, children: nextChildren } as Node;
|
|
88
|
+
}
|
|
89
|
+
return root;
|
|
90
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function safeJsonStringify(value: unknown): string {
|
|
2
|
+
try {
|
|
3
|
+
const seen = new WeakSet<object>();
|
|
4
|
+
return JSON.stringify(
|
|
5
|
+
value,
|
|
6
|
+
(_key, v) => {
|
|
7
|
+
if (typeof v === 'object' && v !== null) {
|
|
8
|
+
if (seen.has(v as object)) return '[Circular]';
|
|
9
|
+
seen.add(v as object);
|
|
10
|
+
}
|
|
11
|
+
return v;
|
|
12
|
+
},
|
|
13
|
+
2,
|
|
14
|
+
);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
return `<< Unable to stringify value: ${String(e)} >>`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Node, NodeData } from '../types/Node';
|
|
2
|
+
|
|
3
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
4
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isNodeData(value: unknown): value is NodeData {
|
|
8
|
+
return (
|
|
9
|
+
isRecord(value) && typeof value.type === 'string' && 'children' in value
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isMainLike(node: NodeData): boolean {
|
|
14
|
+
const t = node.type;
|
|
15
|
+
// Only treat actual Main nodes as "already wrapped".
|
|
16
|
+
// Some payloads may incorrectly carry `isMain: true` on non-Main nodes.
|
|
17
|
+
return t === 'Main' || t === 'main';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stripNestedIsMain(node: Node, isRoot: boolean): Node {
|
|
21
|
+
if (node == null || typeof node === 'string') return node;
|
|
22
|
+
|
|
23
|
+
if (Array.isArray(node)) {
|
|
24
|
+
let changed = false;
|
|
25
|
+
const next = node.map((c) => {
|
|
26
|
+
const out = stripNestedIsMain(c, false);
|
|
27
|
+
if (out !== c) changed = true;
|
|
28
|
+
return out;
|
|
29
|
+
});
|
|
30
|
+
return changed ? next : node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!isNodeData(node)) return node;
|
|
34
|
+
|
|
35
|
+
const prevChildren = node.children as Node;
|
|
36
|
+
const nextChildren = stripNestedIsMain(prevChildren, false);
|
|
37
|
+
const shouldDemote = node.isMain === true && !isRoot;
|
|
38
|
+
|
|
39
|
+
if (!shouldDemote && nextChildren === prevChildren) return node;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
...(node as any),
|
|
43
|
+
...(shouldDemote ? { isMain: false } : null),
|
|
44
|
+
children: nextChildren,
|
|
45
|
+
} as NodeData;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function wrapNodeInMain(node: Node): NodeData {
|
|
49
|
+
if (isNodeData(node) && isMainLike(node)) {
|
|
50
|
+
// Ensure no nested nodes keep `isMain: true`.
|
|
51
|
+
const cleaned = stripNestedIsMain(node, true) as NodeData;
|
|
52
|
+
// Keep root as main.
|
|
53
|
+
if (cleaned.isMain !== true) return { ...(cleaned as any), isMain: true };
|
|
54
|
+
return cleaned;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cleanedChild = stripNestedIsMain(node, false);
|
|
58
|
+
const children: Node =
|
|
59
|
+
node === null || typeof node === 'undefined' ? [] : cleanedChild;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
type: 'Main',
|
|
63
|
+
isMain: true,
|
|
64
|
+
attributes: {},
|
|
65
|
+
children,
|
|
66
|
+
};
|
|
67
|
+
}
|