diff --git a/README.md b/README.md
index 2e9a7f8..c03b130 100644
--- a/README.md
+++ b/README.md
@@ -263,7 +263,9 @@ Note: Variables are optional, but they are a powerful way to make your pages dyn
- `onVariablesChange`: Optional callback triggered when the variables change, providing the updated variables. Can be used to persist the variable state to a database.
- `panelConfig`: Optional. An object to customize the different panels of the UI Builder (e.g., nav bar, editor panel, props panel). If not provided, a default configuration is used. This allows for fine-grained control over the editor's appearance and layout.
- `persistLayerStore`: Optional boolean (defaults to `true`). Determines whether the editor's state (layers and their configurations) is persisted in the browser's local storage across sessions. Set to `false` to disable local storage persistence, useful if you are managing state entirely through `initialLayers` and `onChange`.
-- `editVariables`: Optional boolean (defaults to `true`). Controls whether users can edit variables in the Variables panel. When `true`, users can add, edit, and delete variables. When `false`, the Variables panel becomes read-only, hiding the "Add Variable" button and the edit/delete buttons on individual variables. Useful when you want to provide a read-only variables experience or manage variables entirely through `initialVariables`.
+- `allowVariableEditing`: Optional boolean (defaults to `true`). Controls whether users can edit variables in the Variables panel. When `true`, users can add, edit, and delete variables. When `false`, the Variables panel becomes read-only, hiding the "Add Variable" button and the edit/delete buttons on individual variables. Useful when you want to provide a read-only variables experience or manage variables entirely through `initialVariables`.
+- `allowPagesCreation`: Optional boolean (defaults to `true`). Controls whether users can create new pages in the editor. When `true`, users can add new pages to the editor. When `false`, the Pages panel becomes read-only, hiding the "Add Page" button. Useful when you want to provide a read-only pages experience or manage pages entirely through `initialLayers`.
+- `allowPagesDeletion`: Optional boolean (defaults to `true`). Controls whether users can delete pages in the editor. When `true`, users can delete pages from the editor. When `false`, the Pages panel becomes read-only, hiding the "Delete Page" button. Useful when you want to provide a read-only pages experience or manage pages entirely through `initialLayers`.
## Rendering from Serialized Layer Data
@@ -373,6 +375,77 @@ export const myComponentRegistry: ComponentRegistry = {
//
```
+### Example with Default Variable Bindings
+
+Here's a practical example showing how to use `defaultVariableBindings` to create components that automatically bind to system variables:
+
+```tsx
+import { z } from 'zod';
+import { UserProfile } from '@/components/ui/user-profile';
+import { BrandedButton } from '@/components/ui/branded-button';
+import { ComponentRegistry } from "@/components/ui/ui-builder/types";
+
+// First, define your variables (these would typically come from your app's state)
+const systemVariables = [
+ { id: 'user-id-var', name: 'currentUserId', type: 'string', defaultValue: 'user123' },
+ { id: 'user-name-var', name: 'currentUserName', type: 'string', defaultValue: 'John Doe' },
+ { id: 'brand-color-var', name: 'primaryBrandColor', type: 'string', defaultValue: '#3b82f6' },
+ { id: 'company-name-var', name: 'companyName', type: 'string', defaultValue: 'Acme Corp' },
+];
+
+// Define components with automatic variable bindings
+const myComponentRegistry: ComponentRegistry = {
+ UserProfile: {
+ component: UserProfile,
+ schema: z.object({
+ userId: z.string().default(''),
+ displayName: z.string().default('Anonymous'),
+ showAvatar: z.boolean().default(true),
+ }),
+ from: "@/components/ui/user-profile",
+ // Automatically bind user data when component is added
+ defaultVariableBindings: [
+ { propName: 'userId', variableId: 'user-id-var', immutable: true }, // System data - can't be changed
+ { propName: 'displayName', variableId: 'user-name-var', immutable: false }, // Can be overridden
+ ],
+ },
+
+ BrandedButton: {
+ component: BrandedButton,
+ schema: z.object({
+ text: z.string().default('Click me'),
+ brandColor: z.string().default('#000000'),
+ companyName: z.string().default('Company'),
+ }),
+ from: "@/components/ui/branded-button",
+ // Automatically apply branding when component is added
+ defaultVariableBindings: [
+ { propName: 'brandColor', variableId: 'brand-color-var', immutable: true }, // Brand consistency
+ { propName: 'companyName', variableId: 'company-name-var', immutable: true }, // Brand consistency
+ // 'text' is not bound, allowing content editors to customize button text
+ ],
+ },
+};
+
+// Usage in your app
+const App = () => {
+ return (
+
+ );
+};
+```
+
+In this example:
+- **UserProfile** components automatically bind to current user data, with `userId` locked (immutable) for security
+- **BrandedButton** components automatically inherit brand colors and company name, ensuring visual consistency
+- Content editors can still customize the button text, but can't accidentally break branding
+- The immutable bindings prevent accidental unbinding of critical system or brand data
+
**Component Definition Fields:**
- `component`: **Required**. The React component function or class.
@@ -391,6 +464,23 @@ export const myComponentRegistry: ComponentRegistry = {
* Implementing conditional logic (e.g., showing/hiding a field based on another prop's value).
- The example uses `classNameFieldOverrides` and `childrenFieldOverrides` from `@/lib/ui-builder/registry/form-field-overrides` to provide standardized handling for common props like `className` (using a auto suggest text input) and `children` (using a custom component). You can create your own override functions or objects.
- `defaultChildren`: Optional. Default children to use when a new instance of this component is added to the canvas. For example setting initial text on a span component.
+- `defaultVariableBindings`: Optional. An array of default variable bindings to apply when a new instance of this component is added to the canvas. This enables automatic binding of component properties to variables, with optional immutability controls.
+ - Each binding object contains:
+ * `propName`: The name of the component property to bind
+ * `variableId`: The ID of the variable to bind to this property
+ * `immutable`: Optional boolean (defaults to `false`). When `true`, prevents users from unbinding this variable in the UI, ensuring the binding remains intact
+ - **Use cases for immutable bindings:**
+ * **System-level data**: Bind user ID, tenant ID, or other system variables that shouldn't be changed by content editors
+ * **Branding consistency**: Lock brand colors, logos, or company names to maintain visual consistency
+ * **Security**: Prevent modification of security-related variables like permissions or access levels
+ * **Template integrity**: Ensure critical template variables remain bound in white-label or multi-tenant scenarios
+ - Example:
+ ```tsx
+ defaultVariableBindings: [
+ { propName: 'userEmail', variableId: 'user-email-var', immutable: true },
+ { propName: 'welcomeMessage', variableId: 'welcome-msg-var', immutable: false }
+ ]
+ ```
### Customizing the Page Config Panel Tabs
@@ -538,7 +628,6 @@ npm run test
## Roadmap
-- [ ] Config options to make pages and variables immutable
- [ ] Add variable binding to layer children and not just props
- [ ] Improve DX. End to end type safety.
- [ ] Documentation site for UI Builder with more hands-on examples
diff --git a/__tests__/layer-menu.test.tsx b/__tests__/layer-menu.test.tsx
new file mode 100644
index 0000000..ff2c626
--- /dev/null
+++ b/__tests__/layer-menu.test.tsx
@@ -0,0 +1,265 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { LayerMenu } from "@/components/ui/ui-builder/internal/layer-menu";
+import { useLayerStore } from "@/lib/ui-builder/store/layer-store";
+import { useEditorStore } from "@/lib/ui-builder/store/editor-store";
+import { ComponentLayer } from "@/components/ui/ui-builder/types";
+import { z } from "zod";
+
+// Mock dependencies
+jest.mock("@/lib/ui-builder/store/layer-store", () => ({
+ useLayerStore: jest.fn(),
+}));
+
+jest.mock("@/lib/ui-builder/store/editor-store", () => ({
+ useEditorStore: jest.fn(),
+}));
+
+// Mock the AddComponentsPopover
+jest.mock("@/components/ui/ui-builder/internal/add-component-popover", () => ({
+ AddComponentsPopover: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+}));
+
+const mockedUseLayerStore = useLayerStore as jest.MockedFunction;
+const mockedUseEditorStore = useEditorStore as jest.MockedFunction;
+
+const mockRegistry = {
+ "test-component": {
+ name: "Test Component",
+ schema: z.object({
+ children: z.array(z.any()).optional(),
+ }),
+ props: {},
+ },
+ "page": {
+ name: "Page",
+ schema: z.object({
+ children: z.array(z.any()).optional(),
+ }),
+ props: {},
+ },
+};
+
+const mockComponentLayer: ComponentLayer = {
+ id: "test-layer-1",
+ name: "Test Layer",
+ type: "test-component",
+ props: {},
+ children: [],
+};
+
+const mockPageLayer: ComponentLayer = {
+ id: "test-page-1",
+ name: "Test Page",
+ type: "page",
+ props: {},
+ children: [],
+};
+
+describe("LayerMenu", () => {
+ const defaultProps = {
+ layerId: "test-layer-1",
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 400,
+ zIndex: 1000,
+ onClose: jest.fn(),
+ handleDuplicateComponent: jest.fn(),
+ handleDeleteComponent: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Default mock setup for component layer
+ mockedUseLayerStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector({
+ findLayerById: jest.fn().mockReturnValue(mockComponentLayer),
+ isLayerAPage: jest.fn().mockReturnValue(false),
+ } as any);
+ }
+ return null;
+ });
+
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector({
+ registry: mockRegistry,
+ allowPagesCreation: true,
+ allowPagesDeletion: true,
+ } as any);
+ }
+ return null;
+ });
+ });
+
+ describe("permission controls for component layers", () => {
+ it("should show both duplicate and delete buttons for component layers regardless of page permissions", () => {
+ // Override with strict page permissions
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector({
+ registry: mockRegistry,
+ allowPagesCreation: false,
+ allowPagesDeletion: false,
+ } as any);
+ }
+ return null;
+ });
+
+ render( );
+
+ // Should show both buttons for component layers regardless of page permissions
+ // Find buttons by their SVG icons
+ const duplicateIcon = document.querySelector('svg.lucide-copy');
+ const deleteIcon = document.querySelector('svg.lucide-trash');
+
+ expect(duplicateIcon).toBeInTheDocument();
+ expect(deleteIcon).toBeInTheDocument();
+ });
+ });
+
+ describe("permission controls for page layers", () => {
+ beforeEach(() => {
+ // Setup for page layer
+ mockedUseLayerStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector({
+ findLayerById: jest.fn().mockReturnValue(mockPageLayer),
+ isLayerAPage: jest.fn().mockReturnValue(true),
+ } as any);
+ }
+ return null;
+ });
+ });
+
+ it("should hide duplicate button when allowPagesCreation is false", () => {
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector({
+ registry: mockRegistry,
+ allowPagesCreation: false,
+ allowPagesDeletion: true,
+ } as any);
+ }
+ return null;
+ });
+
+ render( );
+
+ const duplicateIcon = document.querySelector('svg.lucide-copy');
+ const deleteIcon = document.querySelector('svg.lucide-trash');
+
+ expect(duplicateIcon).not.toBeInTheDocument();
+ expect(deleteIcon).toBeInTheDocument();
+ });
+
+ it("should hide delete button when allowPagesDeletion is false", () => {
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector({
+ registry: mockRegistry,
+ allowPagesCreation: true,
+ allowPagesDeletion: false,
+ } as any);
+ }
+ return null;
+ });
+
+ render( );
+
+ const duplicateIcon = document.querySelector('svg.lucide-copy');
+ const deleteIcon = document.querySelector('svg.lucide-trash');
+
+ expect(duplicateIcon).toBeInTheDocument();
+ expect(deleteIcon).not.toBeInTheDocument();
+ });
+
+ it("should hide both buttons when both permissions are false", () => {
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector({
+ registry: mockRegistry,
+ allowPagesCreation: false,
+ allowPagesDeletion: false,
+ } as any);
+ }
+ return null;
+ });
+
+ render( );
+
+ const duplicateIcon = document.querySelector('svg.lucide-copy');
+ const deleteIcon = document.querySelector('svg.lucide-trash');
+
+ expect(duplicateIcon).not.toBeInTheDocument();
+ expect(deleteIcon).not.toBeInTheDocument();
+ });
+
+ it("should show both buttons when both permissions are true", () => {
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector({
+ registry: mockRegistry,
+ allowPagesCreation: true,
+ allowPagesDeletion: true,
+ } as any);
+ }
+ return null;
+ });
+
+ render( );
+
+ const duplicateIcon = document.querySelector('svg.lucide-copy');
+ const deleteIcon = document.querySelector('svg.lucide-trash');
+
+ expect(duplicateIcon).toBeInTheDocument();
+ expect(deleteIcon).toBeInTheDocument();
+ });
+ });
+
+ describe("interaction handling", () => {
+ it("should call handleDuplicateComponent when duplicate button is clicked", () => {
+ const mockHandleDuplicate = jest.fn();
+
+ render( );
+
+ const duplicateIcon = document.querySelector('svg.lucide-copy');
+ expect(duplicateIcon).toBeInTheDocument();
+
+ if (duplicateIcon?.parentElement) {
+ fireEvent.click(duplicateIcon.parentElement);
+ expect(mockHandleDuplicate).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ it("should call handleDeleteComponent when delete button is clicked", () => {
+ const mockHandleDelete = jest.fn();
+
+ render( );
+
+ const deleteIcon = document.querySelector('svg.lucide-trash');
+ expect(deleteIcon).toBeInTheDocument();
+
+ if (deleteIcon?.parentElement) {
+ fireEvent.click(deleteIcon.parentElement);
+ expect(mockHandleDelete).toHaveBeenCalledTimes(1);
+ }
+ });
+ });
+
+ describe("basic rendering", () => {
+ it("should render the layer menu with correct positioning", () => {
+ const { container } = render( );
+
+ // Check that the component renders successfully
+ const menuContainer = container.querySelector('div.fixed');
+ expect(menuContainer).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/__tests__/layer-store-variables.test.tsx b/__tests__/layer-store-variables.test.tsx
index 08cfb50..353ad8a 100644
--- a/__tests__/layer-store-variables.test.tsx
+++ b/__tests__/layer-store-variables.test.tsx
@@ -409,4 +409,94 @@ describe('Layer Store - Variables', () => {
expect(result.current.variables).toHaveLength(1);
});
});
+
+ describe('Immutable Bindings', () => {
+ it('should prevent unbinding immutable variable bindings', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add a layer and variable
+ act(() => {
+ result.current.addComponentLayer('Button', result.current.selectedPageId!);
+ result.current.addVariable('userName', 'string', 'John Doe');
+ });
+
+ const layerId = result.current.findLayersForPageId(result.current.selectedPageId!)![0].id;
+ const variableId = result.current.variables[0].id;
+
+ // Bind the label prop to the variable and mark as immutable
+ act(() => {
+ result.current.bindPropToVariable(layerId, 'label', variableId);
+ // Set immutable binding using the test helper
+ result.current.setImmutableBinding(layerId, 'label', true);
+ });
+
+ const layer = result.current.findLayerById(layerId) as ComponentLayer;
+ expect(layer.props.label).toEqual({ __variableRef: variableId });
+ expect(result.current.isBindingImmutable(layerId, 'label')).toBe(true);
+
+ // Try to unbind immutable binding (should fail)
+ act(() => {
+ result.current.unbindPropFromVariable(layerId, 'label');
+ });
+
+ // Binding should still exist
+ const layerAfterUnbind = result.current.findLayerById(layerId) as ComponentLayer;
+ expect(layerAfterUnbind.props.label).toEqual({ __variableRef: variableId });
+ });
+
+ it('should allow unbinding mutable variable bindings', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add a layer and variable
+ act(() => {
+ result.current.addComponentLayer('Button', result.current.selectedPageId!);
+ result.current.addVariable('userName', 'string', 'John Doe');
+ });
+
+ const layerId = result.current.findLayersForPageId(result.current.selectedPageId!)![0].id;
+ const variableId = result.current.variables[0].id;
+
+ // Bind the label prop to the variable (mutable by default)
+ act(() => {
+ result.current.bindPropToVariable(layerId, 'label', variableId);
+ });
+
+ const layer = result.current.findLayerById(layerId) as ComponentLayer;
+ expect(layer.props.label).toEqual({ __variableRef: variableId });
+ expect(result.current.isBindingImmutable(layerId, 'label')).toBe(false);
+
+ // Unbind mutable binding (should succeed)
+ act(() => {
+ result.current.unbindPropFromVariable(layerId, 'label');
+ });
+
+ // Binding should be removed and default value set
+ const layerAfterUnbind = result.current.findLayerById(layerId) as ComponentLayer;
+ expect(layerAfterUnbind.props.label).toBe('Click me'); // Default from schema
+ });
+
+ it('should correctly track immutable bindings across layer operations', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add a layer and variable
+ act(() => {
+ result.current.addComponentLayer('Button', result.current.selectedPageId!);
+ result.current.addVariable('userName', 'string', 'John Doe');
+ });
+
+ const layerId = result.current.findLayersForPageId(result.current.selectedPageId!)![0].id;
+ const variableId = result.current.variables[0].id;
+
+ // Bind and mark as immutable
+ act(() => {
+ result.current.bindPropToVariable(layerId, 'label', variableId);
+ // Set immutable binding using the test helper
+ result.current.setImmutableBinding(layerId, 'label', true);
+ });
+
+ expect(result.current.isBindingImmutable(layerId, 'label')).toBe(true);
+ expect(result.current.isBindingImmutable(layerId, 'nonExistentProp')).toBe(false);
+ expect(result.current.isBindingImmutable('nonExistentLayer', 'label')).toBe(false);
+ });
+ });
});
\ No newline at end of file
diff --git a/__tests__/layer-store.test.tsx b/__tests__/layer-store.test.tsx
index 309ea68..efc7dee 100644
--- a/__tests__/layer-store.test.tsx
+++ b/__tests__/layer-store.test.tsx
@@ -34,6 +34,32 @@ describe('LayerStore', () => {
from: '@/components/ui/textarea',
component: () => null,
},
+ Card: {
+ schema: z.object({
+ title: z.string().default('Card Title'),
+ }),
+ from: '@/components/ui/card',
+ component: () => null,
+ // Test with string defaultChildren
+ defaultChildren: 'Default card content',
+ },
+ Container: {
+ schema: z.object({
+ className: z.string().default('container'),
+ }),
+ from: '@/components/ui/container',
+ component: () => null,
+ // Test with array defaultChildren
+ defaultChildren: [
+ {
+ id: 'default-child-1',
+ type: 'Button',
+ name: 'Default Button',
+ props: { label: 'Default' },
+ children: [],
+ }
+ ],
+ },
// Add other components as needed with appropriate Zod schemas
}
});
@@ -95,6 +121,45 @@ describe('LayerStore', () => {
expect((result.current.pages[0].children[0] as ComponentLayer).type).toBe('Input');
expect((result.current.pages[0].children[1] as ComponentLayer).type).toBe('Button');
});
+
+ it('should add a component with string defaultChildren', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ act(() => {
+ result.current.addComponentLayer('Card', '1');
+ });
+
+ const newLayer = result.current.pages[0].children[0] as ComponentLayer;
+ expect(newLayer.type).toBe('Card');
+ expect(newLayer.children).toBe('Default card content');
+ });
+
+ it('should add a component with array defaultChildren', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ act(() => {
+ result.current.addComponentLayer('Container', '1');
+ });
+
+ const newLayer = result.current.pages[0].children[0] as ComponentLayer;
+ expect(newLayer.type).toBe('Container');
+ expect(Array.isArray(newLayer.children)).toBe(true);
+ expect(newLayer.children).toHaveLength(1);
+ expect((newLayer.children[0] as ComponentLayer).type).toBe('Button');
+ // Should have a different ID than the default one
+ expect((newLayer.children[0] as ComponentLayer).id).not.toBe('default-child-1');
+ });
+
+ it('should handle components without defaultChildren', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ act(() => {
+ result.current.addComponentLayer('Button', '1');
+ });
+
+ const newLayer = result.current.pages[0].children[0] as ComponentLayer;
+ expect(newLayer.children).toEqual([]);
+ });
});
describe('addPageLayer', () => {
@@ -568,14 +633,12 @@ describe('LayerStore', () => {
it('should select an existing layer', () => {
const { result } = renderHook(() => useLayerStore());
- // Add a layer
act(() => {
result.current.addComponentLayer('Button', '1');
});
const layerId = (result.current.pages[0].children[0] as ComponentLayer).id;
- // Select the layer
act(() => {
result.current.selectLayer(layerId);
});
@@ -583,6 +646,16 @@ describe('LayerStore', () => {
expect(result.current.selectedLayerId).toBe(layerId);
});
+ it('should select a page when layerId equals selectedPageId', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ act(() => {
+ result.current.selectLayer('1');
+ });
+
+ expect(result.current.selectedLayerId).toBe('1');
+ });
+
it('should not select a non-existent layer', () => {
const { result } = renderHook(() => useLayerStore());
@@ -735,5 +808,581 @@ describe('LayerStore', () => {
expect(result.current.pages).toEqual(initialPages);
expect(result.current.selectedPageId).toBe(initialPages[0].id);
});
+
+ it('should initialize with custom selectedPageId and selectedLayerId', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ const newPages: ComponentLayer[] = [
+ {
+ id: '2',
+ type: '_page_',
+ name: 'Page 2',
+ props: { className: 'page-2' },
+ children: [
+ {
+ id: 'layer-1',
+ type: 'Button',
+ name: 'Button 1',
+ props: { label: 'Click' },
+ children: [],
+ }
+ ],
+ },
+ {
+ id: '3',
+ type: '_page_',
+ name: 'Page 3',
+ props: { className: 'page-3' },
+ children: [],
+ },
+ ];
+
+ act(() => {
+ result.current.initialize(newPages, '3', 'layer-1');
+ });
+
+ expect(result.current.pages).toEqual(newPages);
+ expect(result.current.selectedPageId).toBe('3');
+ expect(result.current.selectedLayerId).toBe('layer-1');
+ });
+
+ it('should initialize with variables', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ const newPages: ComponentLayer[] = [
+ {
+ id: '2',
+ type: '_page_',
+ name: 'Page',
+ props: { className: 'page' },
+ children: [],
+ },
+ ];
+
+ const variables = [
+ { id: 'var-1', name: 'testVar', type: 'string' as const, defaultValue: 'test' }
+ ];
+
+ act(() => {
+ result.current.initialize(newPages, '2', undefined, variables);
+ });
+
+ expect(result.current.variables).toEqual(variables);
+ });
+ });
+
+ describe('Edge Cases and Error Handling', () => {
+ it('should handle edge case with findLayerById when layer has no children property', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add a regular page with type 'div' (not '_page_')
+ act(() => {
+ result.current.addPageLayer('Test Page');
+ });
+
+ // Get the new page ID
+ const newPageId = result.current.pages[1].id;
+
+ // Select the new page
+ act(() => {
+ result.current.selectPage(newPageId);
+ });
+
+ // findLayersForPageId should handle pages with type 'div' correctly
+ const layers = result.current.findLayersForPageId(newPageId);
+ expect(layers).toEqual([]);
+ });
+
+ it('should handle when registry does not have schema for component type', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add a component type that doesn't exist in registry
+ useEditorStore.setState({
+ registry: {
+ ...useEditorStore.getState().registry,
+ UnknownComponent: {
+ schema: z.object({}), // Empty schema
+ from: '@/components/ui/unknown',
+ component: () => null,
+ }
+ }
+ });
+
+ act(() => {
+ result.current.addComponentLayer('UnknownComponent', '1');
+ });
+
+ const newLayer = result.current.pages[0].children[0] as ComponentLayer;
+ expect(newLayer.type).toBe('UnknownComponent');
+ expect(newLayer.props).toEqual({});
+ });
+
+ it('should handle unbindPropFromVariable when layer is not found', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ console.warn = jest.fn();
+
+ act(() => {
+ result.current.unbindPropFromVariable('non-existent-layer', 'prop');
+ });
+
+ expect(console.warn).toHaveBeenCalledWith('Layer with ID non-existent-layer not found.');
+ });
+
+ it('should handle unbindPropFromVariable when schema has no default value', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add component with schema but no default for a specific prop
+ useEditorStore.setState({
+ registry: {
+ ...useEditorStore.getState().registry,
+ CustomComponent: {
+ schema: z.object({
+ customProp: z.string().optional(), // No default
+ }),
+ from: '@/components/ui/custom',
+ component: () => null,
+ }
+ }
+ });
+
+ act(() => {
+ result.current.addComponentLayer('CustomComponent', '1');
+ });
+
+ const layerId = (result.current.pages[0].children[0] as ComponentLayer).id;
+
+ act(() => {
+ result.current.unbindPropFromVariable(layerId, 'nonExistentProp');
+ });
+
+ const layer = result.current.findLayerById(layerId) as ComponentLayer;
+ expect(layer.props.nonExistentProp).toBe("");
+ });
+
+ it('should handle removeVariable with prop that has no schema entry', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add component with incomplete schema
+ useEditorStore.setState({
+ registry: {
+ ...useEditorStore.getState().registry,
+ PartialComponent: {
+ schema: z.object({
+ knownProp: z.string().default('known'),
+ }),
+ from: '@/components/ui/partial',
+ component: () => null,
+ }
+ }
+ });
+
+ act(() => {
+ result.current.addComponentLayer('PartialComponent', '1');
+ result.current.addVariable('testVar', 'string', 'test');
+ });
+
+ const layerId = (result.current.pages[0].children[0] as ComponentLayer).id;
+ const variableId = result.current.variables[0].id;
+
+ // Manually set a prop that's not in the schema
+ act(() => {
+ result.current.updateLayer(layerId, { unknownProp: { __variableRef: variableId } });
+ });
+
+ // Remove the variable - unknownProp should be deleted since it has no schema default
+ act(() => {
+ result.current.removeVariable(variableId);
+ });
+
+ const layer = result.current.findLayerById(layerId) as ComponentLayer;
+ expect(layer.props.unknownProp).toBeUndefined();
+ });
+ });
+
+ describe('Store Persistence Configuration', () => {
+ it('should handle localStorage operations when persistLayerStoreConfig is disabled', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Mock localStorage
+ const mockGetItem = jest.spyOn(Storage.prototype, 'getItem');
+ const mockSetItem = jest.spyOn(Storage.prototype, 'setItem');
+
+ // Set persistLayerStoreConfig to false
+ useEditorStore.setState({ persistLayerStoreConfig: false });
+
+ // Try to add a layer (which would normally trigger persistence)
+ act(() => {
+ result.current.addComponentLayer('Button', '1');
+ });
+
+ // localStorage should not be called when persistence is disabled
+ // Note: This is hard to test directly without accessing the internal storage mechanism
+ // The test mainly ensures the code doesn't break when persistence is disabled
+
+ mockGetItem.mockRestore();
+ mockSetItem.mockRestore();
+ });
+
+ it('should test localStorage getItem when persistLayerStoreConfig is enabled', async () => {
+ // Set persistLayerStoreConfig to true
+ useEditorStore.setState({ persistLayerStoreConfig: true });
+
+ // Mock localStorage with a stored value
+ const mockGetItem = jest.spyOn(Storage.prototype, 'getItem');
+ mockGetItem.mockReturnValue(JSON.stringify({
+ state: {
+ pages: [{ id: 'stored-page', type: 'div', name: 'Stored Page', props: {}, children: [] }],
+ selectedPageId: 'stored-page',
+ selectedLayerId: null,
+ variables: []
+ },
+ version: 4
+ }));
+
+ // Note: Testing localStorage directly is difficult with Zustand persist middleware
+ // This test ensures the mock is set up correctly
+ const storedValue = localStorage.getItem('layer-store');
+ expect(storedValue).toBeTruthy();
+
+ mockGetItem.mockRestore();
+ });
+
+ it('should test localStorage setItem when persistLayerStoreConfig is enabled', () => {
+ // Set persistLayerStoreConfig to true
+ useEditorStore.setState({ persistLayerStoreConfig: true });
+
+ const mockSetItem = jest.spyOn(Storage.prototype, 'setItem');
+
+ // Create a new store instance to trigger persistence
+ const { result } = renderHook(() => useLayerStore());
+
+ act(() => {
+ result.current.addComponentLayer('Button', '1');
+ });
+
+ // The test ensures setItem can be called without errors
+ expect(() => {
+ localStorage.setItem('test-key', 'test-value');
+ }).not.toThrow();
+
+ mockSetItem.mockRestore();
+ });
+
+ it('should test localStorage removeItem when persistLayerStoreConfig is enabled', () => {
+ // Set persistLayerStoreConfig to true
+ useEditorStore.setState({ persistLayerStoreConfig: true });
+
+ const mockRemoveItem = jest.spyOn(Storage.prototype, 'removeItem');
+
+ // The test ensures removeItem can be called without errors
+ expect(() => {
+ localStorage.removeItem('test-key');
+ }).not.toThrow();
+
+ mockRemoveItem.mockRestore();
+ });
+ });
+
+ describe('Additional Variable Edge Cases', () => {
+ it('should handle binding a prop when layer has no existing props', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add a minimal component
+ act(() => {
+ result.current.addComponentLayer('Button', '1');
+ result.current.addVariable('testVar', 'string', 'test value');
+ });
+
+ const layerId = (result.current.pages[0].children[0] as ComponentLayer).id;
+ const variableId = result.current.variables[0].id;
+
+ // Bind a new prop
+ act(() => {
+ result.current.bindPropToVariable(layerId, 'newProp', variableId);
+ });
+
+ const layer = result.current.findLayerById(layerId) as ComponentLayer;
+ expect(layer.props.newProp).toEqual({ __variableRef: variableId });
+ });
+
+ it('should handle unbinding when layer has complex schema', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Add a component with complex schema
+ useEditorStore.setState({
+ registry: {
+ ...useEditorStore.getState().registry,
+ ComplexComponent: {
+ schema: z.object({
+ nested: z.object({
+ prop: z.string().default('nested default')
+ }).default({ prop: 'nested default' })
+ }),
+ from: '@/components/ui/complex',
+ component: () => null,
+ }
+ }
+ });
+
+ act(() => {
+ result.current.addComponentLayer('ComplexComponent', '1');
+ });
+
+ const layerId = (result.current.pages[0].children[0] as ComponentLayer).id;
+
+ // Try to unbind a prop that uses nested schema
+ act(() => {
+ result.current.unbindPropFromVariable(layerId, 'nested');
+ });
+
+ const layer = result.current.findLayerById(layerId) as ComponentLayer;
+ expect(layer.props.nested).toEqual({ prop: 'nested default' });
+ });
+
+ it('should handle removeVariable when no registry schema exists', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Temporarily remove registry to test edge case
+ const originalRegistry = useEditorStore.getState().registry;
+ useEditorStore.setState({ registry: {} });
+
+ act(() => {
+ // Add a layer manually (bypassing registry check)
+ useLayerStore.setState({
+ pages: [{
+ id: '1',
+ type: 'div',
+ name: 'Page 1',
+ props: { className: 'p-4' },
+ children: [{
+ id: 'manual-layer',
+ type: 'UnknownType',
+ name: 'Unknown',
+ props: { someProp: { __variableRef: 'var-1' } },
+ children: []
+ }]
+ }],
+ variables: [{ id: 'var-1', name: 'testVar', type: 'string', defaultValue: 'test' }]
+ });
+ });
+
+ // Remove the variable when no schema exists
+ act(() => {
+ result.current.removeVariable('var-1');
+ });
+
+ const layer = result.current.pages[0].children[0] as ComponentLayer;
+ expect(layer.props.someProp).toBeUndefined();
+
+ // Restore registry
+ useEditorStore.setState({ registry: originalRegistry });
+ });
+ });
+
+ describe('Default Variable Bindings', () => {
+ beforeEach(() => {
+ // Add variables to the store
+ const { result } = renderHook(() => useLayerStore());
+ act(() => {
+ result.current.addVariable('userName', 'string', 'John Doe');
+ result.current.addVariable('userAge', 'number', 25);
+ });
+
+ // Update registry with components that have default variable bindings
+ useEditorStore.setState({
+ registry: {
+ ...useEditorStore.getState().registry,
+ ComponentWithDefaultBindings: {
+ schema: z.object({
+ title: z.string().default('Default Title'),
+ description: z.string().default('Default Description'),
+ count: z.number().default(0),
+ }),
+ from: '@/components/ui/component-with-default-bindings',
+ component: () => null,
+ defaultVariableBindings: [
+ { propName: 'title', variableId: 'var-id-1', immutable: true },
+ { propName: 'description', variableId: 'var-id-2', immutable: false },
+ ],
+ },
+ ComponentWithoutBindings: {
+ schema: z.object({
+ text: z.string().default('Default Text'),
+ }),
+ from: '@/components/ui/component-without-bindings',
+ component: () => null,
+ },
+ }
+ });
+ });
+
+ it('should apply default variable bindings when adding a component', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Manually set variable IDs to match what we expect in the binding definitions
+ act(() => {
+ const variables = result.current.variables;
+ if (variables.length >= 2) {
+ // Update the registry to use actual variable IDs
+ const registry = useEditorStore.getState().registry;
+ useEditorStore.setState({
+ registry: {
+ ...registry,
+ ComponentWithDefaultBindings: {
+ ...registry.ComponentWithDefaultBindings,
+ defaultVariableBindings: [
+ { propName: 'title', variableId: variables[0].id, immutable: true },
+ { propName: 'description', variableId: variables[1].id, immutable: false },
+ ],
+ },
+ }
+ });
+
+ result.current.addComponentLayer('ComponentWithDefaultBindings', '1');
+ }
+ });
+
+ const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
+ expect(addedLayer.type).toBe('ComponentWithDefaultBindings');
+
+ // Check that variable bindings were applied
+ const variables = result.current.variables;
+ expect(addedLayer.props.title).toEqual({ __variableRef: variables[0].id });
+ expect(addedLayer.props.description).toEqual({ __variableRef: variables[1].id });
+
+ // Check that immutable bindings were tracked
+ expect(result.current.isBindingImmutable(addedLayer.id, 'title')).toBe(true);
+ expect(result.current.isBindingImmutable(addedLayer.id, 'description')).toBe(false);
+ });
+
+ it('should not apply bindings for non-existent variables', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Use registry with non-existent variable IDs
+ useEditorStore.setState({
+ registry: {
+ ...useEditorStore.getState().registry,
+ ComponentWithInvalidBindings: {
+ schema: z.object({
+ title: z.string().default('Default Title'),
+ }),
+ from: '@/components/ui/component-with-invalid-bindings',
+ component: () => null,
+ defaultVariableBindings: [
+ { propName: 'title', variableId: 'non-existent-var', immutable: true },
+ ],
+ },
+ }
+ });
+
+ act(() => {
+ result.current.addComponentLayer('ComponentWithInvalidBindings', '1');
+ });
+
+ const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
+
+ // Should use default value from schema, not variable binding
+ expect(addedLayer.props.title).toBe('Default Title');
+ expect(result.current.isBindingImmutable(addedLayer.id, 'title')).toBe(false);
+ });
+
+ it('should handle components without default variable bindings', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ act(() => {
+ result.current.addComponentLayer('ComponentWithoutBindings', '1');
+ });
+
+ const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
+ expect(addedLayer.type).toBe('ComponentWithoutBindings');
+ expect(addedLayer.props.text).toBe('Default Text');
+ expect(result.current.isBindingImmutable(addedLayer.id, 'text')).toBe(false);
+ });
+
+ it('should prevent unbinding immutable variable bindings', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Set up component with immutable binding
+ act(() => {
+ const variables = result.current.variables;
+ if (variables.length >= 1) {
+ useEditorStore.setState({
+ registry: {
+ ...useEditorStore.getState().registry,
+ ComponentWithDefaultBindings: {
+ ...useEditorStore.getState().registry.ComponentWithDefaultBindings,
+ defaultVariableBindings: [
+ { propName: 'title', variableId: variables[0].id, immutable: true },
+ ],
+ },
+ }
+ });
+
+ result.current.addComponentLayer('ComponentWithDefaultBindings', '1');
+ }
+ });
+
+ const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
+
+ // Verify binding exists
+ expect(addedLayer.props.title).toEqual({ __variableRef: result.current.variables[0].id });
+ expect(result.current.isBindingImmutable(addedLayer.id, 'title')).toBe(true);
+
+ // Try to unbind immutable binding (should fail)
+ act(() => {
+ result.current.unbindPropFromVariable(addedLayer.id, 'title');
+ });
+
+ // Binding should still exist
+ const layerAfterUnbind = result.current.findLayerById(addedLayer.id) as ComponentLayer;
+ expect(layerAfterUnbind.props.title).toEqual({ __variableRef: result.current.variables[0].id });
+ });
+
+ it('should allow unbinding mutable variable bindings', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ // Set up component with mutable binding
+ act(() => {
+ const variables = result.current.variables;
+ if (variables.length >= 1) {
+ useEditorStore.setState({
+ registry: {
+ ...useEditorStore.getState().registry,
+ ComponentWithDefaultBindings: {
+ ...useEditorStore.getState().registry.ComponentWithDefaultBindings,
+ defaultVariableBindings: [
+ { propName: 'description', variableId: variables[0].id, immutable: false },
+ ],
+ },
+ }
+ });
+
+ result.current.addComponentLayer('ComponentWithDefaultBindings', '1');
+ }
+ });
+
+ const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
+
+ // Verify binding exists
+ expect(addedLayer.props.description).toEqual({ __variableRef: result.current.variables[0].id });
+ expect(result.current.isBindingImmutable(addedLayer.id, 'description')).toBe(false);
+
+ // Unbind mutable binding (should succeed)
+ act(() => {
+ result.current.unbindPropFromVariable(addedLayer.id, 'description');
+ });
+
+ // Binding should be removed and default value set
+ const layerAfterUnbind = result.current.findLayerById(addedLayer.id) as ComponentLayer;
+ expect(layerAfterUnbind.props.description).toBe('Default Description');
+ });
+
+ it('should correctly report binding immutability', () => {
+ const { result } = renderHook(() => useLayerStore());
+
+ expect(result.current.isBindingImmutable('non-existent-layer', 'prop')).toBe(false);
+ expect(result.current.isBindingImmutable('layer-id', 'non-existent-prop')).toBe(false);
+ });
});
});
\ No newline at end of file
diff --git a/__tests__/props-panel.test.tsx b/__tests__/props-panel.test.tsx
index b365a8c..f67d14a 100644
--- a/__tests__/props-panel.test.tsx
+++ b/__tests__/props-panel.test.tsx
@@ -1,12 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
-import { render, screen, waitFor, fireEvent } from "@testing-library/react";
+import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import PropsPanel from "@/components/ui/ui-builder/internal/props-panel";
import {
useLayerStore,
} from "@/lib/ui-builder/store/layer-store";
import { RegistryEntry, ComponentLayer } from '@/components/ui/ui-builder/types';
+import { textInputFieldOverrides } from '@/lib/ui-builder/registry/form-field-overrides';
import { z } from "zod";
import { useEditorStore } from "@/lib/ui-builder/store/editor-store";
@@ -51,6 +52,26 @@ jest.mock("@/components/ui/textarea", () => ({
),
}));
+jest.mock("@/components/ui/tooltip", () => ({
+ Tooltip: ({ children }: any) => children,
+ TooltipTrigger: ({ children }: any) => children,
+ TooltipContent: ({ children }: any) => {children}
,
+}));
+
+jest.mock("@/components/ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: any) => {children}
,
+ DropdownMenuTrigger: ({ children }: any) => children,
+ DropdownMenuContent: ({ children }: any) => {children}
,
+ DropdownMenuItem: ({ children, onClick }: any) => (
+ {children}
+ ),
+}));
+
+jest.mock("@/components/ui/card", () => ({
+ Card: ({ children }: any) => {children}
,
+ CardContent: ({ children }: any) => {children}
,
+}));
+
describe("PropsPanel", () => {
const mockedUseLayerStore = useLayerStore as unknown as jest.Mock;
const mockedUseEditorStore = useEditorStore as unknown as jest.Mock;
@@ -74,6 +95,9 @@ describe("PropsPanel", () => {
{children}
),
+ fieldOverrides: {
+ label: (layer: ComponentLayer) => textInputFieldOverrides(layer, true, 'label'),
+ },
},
Input: {
schema: z.object({
@@ -106,6 +130,10 @@ describe("PropsPanel", () => {
},
};
+ const mockBindPropToVariable = jest.fn();
+ const mockUnbindPropFromVariable = jest.fn();
+ const mockIsBindingImmutable = jest.fn().mockReturnValue(false);
+
const mockLayerState = {
selectedLayerId: "layer-1",
findLayerById: mockFindLayerById,
@@ -113,13 +141,22 @@ describe("PropsPanel", () => {
duplicateLayer: mockDuplicateLayer,
updateLayer: mockUpdateLayer,
addComponentLayer: mockAddComponentLayer,
+ bindPropToVariable: mockBindPropToVariable,
+ unbindPropFromVariable: mockUnbindPropFromVariable,
+ isBindingImmutable: mockIsBindingImmutable,
variables: [],
+ isLayerAPage: jest.fn().mockReturnValue(false),
};
+ const mockIncrementRevision = jest.fn();
+
const mockEditorState = {
registry: mockRegistry,
getComponentDefinition: mockGetComponentDefinition,
+ incrementRevision: mockIncrementRevision,
revisionCounter: 0,
+ allowPagesCreation: true,
+ allowPagesDeletion: true,
};
beforeAll(() => {
@@ -147,6 +184,10 @@ describe("PropsPanel", () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockBindPropToVariable.mockClear();
+ mockUnbindPropFromVariable.mockClear();
+ mockIsBindingImmutable.mockReturnValue(false);
+ mockIncrementRevision.mockClear();
(useLayerStore as any).getState = jest.fn(() => mockLayerState);
mockedUseLayerStore.mockImplementation((selector) => {
@@ -315,6 +356,7 @@ describe("PropsPanel", () => {
duplicateLayer: mockDuplicateLayer,
updateLayer: mockUpdateLayer,
variables: [],
+ isLayerAPage: jest.fn().mockReturnValue(false),
};
(useLayerStore as any).getState = jest.fn(() => noLayerState);
@@ -363,32 +405,176 @@ describe("PropsPanel", () => {
it("should display the duplicate and delete buttons", () => {
expect(
- screen.getByTestId("button-Duplicate Component")
+ screen.getByTestId("button-Duplicate ,Component")
).toBeInTheDocument();
- expect(screen.getByTestId("button-Delete Component")).toBeInTheDocument();
+ expect(screen.getByTestId("button-Delete ,Component")).toBeInTheDocument();
});
- it("should update layer properties on form change", async () => {
- const { container } = renderPropsPanel();
- let labelInput: HTMLElement | null = null;
- await waitFor(() => {
- labelInput = container.querySelector('input[name="label"]');
- expect(labelInput).toBeInTheDocument();
+ it("should update layer properties on form change", () => {
+ const labelInput = screen.getByDisplayValue("Click Me");
+
+ fireEvent.change(labelInput, { target: { value: "New Label" } });
+
+ // Allow for either 2 or 3 parameters (the third being undefined)
+ expect(mockUpdateLayer).toHaveBeenCalledWith("layer-1", {
+ label: "New Label",
+ className: "button-class",
+ }, undefined);
+ });
+
+ it("should hide delete button when allowPagesDeletion is false for page layers", () => {
+ // Mock isLayerAPage to return true (this is a page layer)
+ const pageLayerState = {
+ ...mockLayerState,
+ isLayerAPage: jest.fn().mockReturnValue(true),
+ };
+
+ // Mock editor state with allowPagesDeletion: false
+ const restrictedEditorState = {
+ ...mockEditorState,
+ allowPagesDeletion: false,
+ };
+
+ (useLayerStore as any).getState = jest.fn(() => pageLayerState);
+
+ // Fix the selector pattern implementation
+ mockedUseLayerStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector(pageLayerState);
+ }
+ return pageLayerState;
+ });
+
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector(restrictedEditorState);
+ }
+ return restrictedEditorState;
});
- userEvent.clear(labelInput!);
- userEvent.type(labelInput!, "New Label", { delay: 0 });
+ renderPropsPanel();
- await waitFor(() => {
- expect(mockUpdateLayer).toHaveBeenLastCalledWith(
- "layer-1",
- {
- label: "New Label",
- className: "button-class",
- },
- undefined
- );
+ // Duplicate button should still be visible (allowPagesCreation is still true)
+ expect(screen.getByTestId("button-Duplicate ,Page")).toBeInTheDocument();
+ // Delete button should be hidden
+ expect(screen.queryByTestId("button-Delete ,Page")).not.toBeInTheDocument();
+ });
+
+ it("should hide duplicate button when allowPagesCreation is false for page layers", () => {
+ // Mock isLayerAPage to return true (this is a page layer)
+ const pageLayerState = {
+ ...mockLayerState,
+ isLayerAPage: jest.fn().mockReturnValue(true),
+ };
+
+ // Mock editor state with allowPagesCreation: false
+ const restrictedEditorState = {
+ ...mockEditorState,
+ allowPagesCreation: false,
+ };
+
+ (useLayerStore as any).getState = jest.fn(() => pageLayerState);
+
+ // Fix the selector pattern implementation
+ mockedUseLayerStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector(pageLayerState);
+ }
+ return pageLayerState;
+ });
+
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector(restrictedEditorState);
+ }
+ return restrictedEditorState;
+ });
+
+ renderPropsPanel();
+
+ // Delete button should still be visible (allowPagesDeletion is still true)
+ expect(screen.getByTestId("button-Delete ,Page")).toBeInTheDocument();
+ // Duplicate button should be hidden
+ expect(screen.queryByTestId("button-Duplicate ,Page")).not.toBeInTheDocument();
+ });
+
+ it("should hide both buttons when both permissions are false for page layers", () => {
+ // Mock isLayerAPage to return true (this is a page layer)
+ const pageLayerState = {
+ ...mockLayerState,
+ isLayerAPage: jest.fn().mockReturnValue(true),
+ };
+
+ // Mock editor state with both permissions false
+ const restrictedEditorState = {
+ ...mockEditorState,
+ allowPagesCreation: false,
+ allowPagesDeletion: false,
+ };
+
+ (useLayerStore as any).getState = jest.fn(() => pageLayerState);
+
+ // Fix the selector pattern implementation
+ mockedUseLayerStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector(pageLayerState);
+ }
+ return pageLayerState;
+ });
+
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector(restrictedEditorState);
+ }
+ return restrictedEditorState;
+ });
+
+ renderPropsPanel();
+
+ // Both buttons should be hidden
+ expect(screen.queryByTestId("button-Duplicate ,Page")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("button-Delete ,Page")).not.toBeInTheDocument();
+ });
+
+ it("should show both buttons when permissions are false but layer is not a page", () => {
+ // Clean up any previous renders
+ cleanup();
+
+ // Mock isLayerAPage to return false (this is not a page layer)
+ const nonPageLayerState = {
+ ...mockLayerState,
+ isLayerAPage: jest.fn().mockReturnValue(false),
+ };
+
+ // Mock editor state with both permissions false
+ const restrictedEditorState = {
+ ...mockEditorState,
+ allowPagesCreation: false,
+ allowPagesDeletion: false,
+ };
+
+ (useLayerStore as any).getState = jest.fn(() => nonPageLayerState);
+
+ // Fix the selector pattern implementation
+ mockedUseLayerStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector(nonPageLayerState);
+ }
+ return nonPageLayerState;
});
+
+ mockedUseEditorStore.mockImplementation((selector) => {
+ if (typeof selector === 'function') {
+ return selector(restrictedEditorState);
+ }
+ return restrictedEditorState;
+ });
+
+ renderPropsPanel();
+
+ // Both buttons should be visible for non-page layers regardless of page permissions
+ expect(screen.getByTestId("button-Duplicate ,Component")).toBeInTheDocument();
+ expect(screen.getByTestId("button-Delete ,Component")).toBeInTheDocument();
});
});
@@ -402,6 +588,7 @@ describe("PropsPanel", () => {
duplicateLayer: mockDuplicateLayer,
updateLayer: mockUpdateLayer,
variables: [],
+ isLayerAPage: jest.fn().mockReturnValue(false),
};
(useLayerStore as any).getState = jest.fn(() => undefinedLayerState);
@@ -443,6 +630,7 @@ describe("PropsPanel", () => {
duplicateLayer: mockDuplicateLayer,
updateLayer: mockUpdateLayer,
variables: [], // Add variables for consistency
+ isLayerAPage: jest.fn().mockReturnValue(false), // Add missing function
};
(useLayerStore as any).getState = jest.fn(() => unknownComponentState);
@@ -456,8 +644,8 @@ describe("PropsPanel", () => {
expect(screen.queryByText("Unknown Test Component Properties")).not.toBeInTheDocument();
expect(screen.queryByText("Type: UnknownComponent")).not.toBeInTheDocument();
expect(screen.queryByTestId("auto-form")).not.toBeInTheDocument();
- expect(screen.queryByTestId("button-Duplicate Component")).not.toBeInTheDocument();
- expect(screen.queryByTestId("button-Delete Component")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("button-Duplicate ,Component")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("button-Delete ,Component")).not.toBeInTheDocument();
});
it("should handle rapid consecutive updates correctly", async () => {
@@ -506,6 +694,7 @@ describe("PropsPanel", () => {
const layerStateWithVariables = {
...mockLayerState,
variables: mockVariables,
+ isLayerAPage: jest.fn().mockReturnValue(false),
};
(useLayerStore as any).getState = jest.fn(() => layerStateWithVariables);
@@ -569,14 +758,14 @@ describe("PropsPanel", () => {
};
mockFindLayerById.mockReturnValue(componentLayer);
- const { container } = renderPropsPanel();
+ renderPropsPanel();
// Wait for form to render and check that variable is resolved for display
await waitFor(() => {
- const labelInput = container.querySelector('input[name="label"]') as HTMLInputElement;
- expect(labelInput).toBeInTheDocument();
- // The form should display the resolved value, not the variable reference
- expect(labelInput.value).toBe('John Doe');
+ // When a variable is bound, it shows the variable name and resolved value, not an input field
+ expect(screen.getByText('userName')).toBeInTheDocument();
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ expect(screen.getByText('string')).toBeInTheDocument();
});
});
@@ -593,14 +782,14 @@ describe("PropsPanel", () => {
};
mockFindLayerById.mockReturnValue(componentLayer);
- const { container } = renderPropsPanel();
+ renderPropsPanel();
- // Should handle missing variable gracefully
+ // Should handle missing variable gracefully by falling back to normal input
await waitFor(() => {
- const labelInput = container.querySelector('input[name="label"]') as HTMLInputElement;
- expect(labelInput).toBeInTheDocument();
- // Should show empty or undefined value for missing variable
- expect(labelInput.value).toBe('');
+ expect(screen.queryByText("Missing Variable Button Properties")).toBeInTheDocument();
+ // Should render the form without crashing
+ const classNameInput = screen.getByDisplayValue('button-class');
+ expect(classNameInput).toBeInTheDocument();
});
});
@@ -687,5 +876,100 @@ describe("PropsPanel", () => {
expect(screen.queryByText("Multi Type Variables Properties")).toBeInTheDocument();
});
});
+
+ it('should show immutable indicator and hide unbind button for immutable bindings', () => {
+ const testLayer: ComponentLayer = {
+ id: 'test-layer',
+ type: 'Button',
+ name: 'Test Button',
+ props: {
+ label: { __variableRef: 'var1' },
+ className: 'test-class'
+ },
+ children: [],
+ };
+
+ const layerStateWithImmutableBinding = {
+ ...mockLayerState,
+ variables: mockVariables,
+ selectedLayerId: 'test-layer',
+ findLayerById: jest.fn().mockReturnValue(testLayer),
+ isBindingImmutable: jest.fn().mockImplementation((layerId: string, propName: string) =>
+ layerId === 'test-layer' && propName === 'label'
+ ),
+ isLayerAPage: jest.fn().mockReturnValue(false),
+ };
+
+ (useLayerStore as any).getState = jest.fn(() => layerStateWithImmutableBinding);
+
+ mockedUseLayerStore.mockImplementation((selector) => {
+ if (typeof selector === "function") {
+ return selector(layerStateWithImmutableBinding);
+ }
+ return layerStateWithImmutableBinding;
+ });
+
+ render( );
+
+ // The label field should show as bound to a variable
+ expect(screen.getByText('userName')).toBeInTheDocument();
+
+ // Should show the immutable indicator
+ expect(screen.getByTestId('immutable-badge')).toBeInTheDocument();
+
+ // Should not show the unbind button for immutable bindings
+ const unbindButtons = screen.queryAllByRole('button');
+ const unlinkButton = unbindButtons.find(button =>
+ button.querySelector('svg[class*="lucide-unlink"]')
+ );
+ expect(unlinkButton).toBeUndefined();
+ });
+
+ it('should show unbind button for mutable bindings', () => {
+ const testLayer: ComponentLayer = {
+ id: 'test-layer',
+ type: 'Button',
+ name: 'Test Button',
+ props: {
+ label: { __variableRef: 'var1' },
+ className: 'test-class'
+ },
+ children: [],
+ };
+
+ const layerStateWithMutableBinding = {
+ ...mockLayerState,
+ variables: mockVariables,
+ selectedLayerId: 'test-layer',
+ findLayerById: jest.fn().mockReturnValue(testLayer),
+ isBindingImmutable: jest.fn().mockReturnValue(false), // All bindings are mutable
+ isLayerAPage: jest.fn().mockReturnValue(false),
+ };
+
+ (useLayerStore as any).getState = jest.fn(() => layerStateWithMutableBinding);
+
+ mockedUseLayerStore.mockImplementation((selector) => {
+ if (typeof selector === "function") {
+ return selector(layerStateWithMutableBinding);
+ }
+ return layerStateWithMutableBinding;
+ });
+
+ render( );
+
+ // The label field should show as bound to a variable
+ expect(screen.getByText('userName')).toBeInTheDocument();
+
+ // Should not show the immutable indicator
+ expect(screen.queryByText('Immutable')).not.toBeInTheDocument();
+
+ // Should show the unbind button for mutable bindings
+ const unbindButtons = screen.queryAllByRole('button');
+ const unlinkButton = unbindButtons.find(button =>
+ button.querySelector('svg[class*="lucide-unlink"]')
+ );
+ expect(unlinkButton).toBeDefined();
+ expect(unlinkButton).toBeInTheDocument();
+ });
});
});
diff --git a/__tests__/variables-panel.test.tsx b/__tests__/variables-panel.test.tsx
index 75e3c15..85e80f3 100644
--- a/__tests__/variables-panel.test.tsx
+++ b/__tests__/variables-panel.test.tsx
@@ -97,6 +97,7 @@ describe('VariablesPanel', () => {
mockedUseEditorStore.mockImplementation((selector) => {
const state = {
incrementRevision: mockIncrementRevision,
+ allowVariableEditing: true,
// Add other required properties to satisfy TypeScript
previewMode: false,
setPreviewMode: jest.fn(),
@@ -115,11 +116,36 @@ describe('VariablesPanel', () => {
render( );
expect(screen.getByText('Variables')).toBeInTheDocument();
- expect(screen.getByText('Add Variable')).toBeInTheDocument();
+
+ // Debug: Let's try to find the button by its role instead
+ const addButton = screen.queryByRole('button', { name: /add variable/i });
+ if (!addButton) {
+ // If button not found, let's see what buttons are rendered
+ const allButtons = screen.queryAllByRole('button');
+ console.log('All buttons found:', allButtons.map(btn => btn.textContent));
+ }
+
+ expect(addButton).toBeInTheDocument();
});
it('should hide add button when editVariables is false', () => {
- render( );
+ // Mock allowVariableEditing to false for this test
+ mockedUseEditorStore.mockImplementation((selector) => {
+ const state = {
+ incrementRevision: mockIncrementRevision,
+ allowVariableEditing: false, // Set to false for this test
+ previewMode: false,
+ setPreviewMode: jest.fn(),
+ registry: {},
+ initialize: jest.fn(),
+ getComponentDefinition: jest.fn(),
+ revisionCounter: 0,
+ setRevisionCounter: jest.fn(),
+ } as any;
+ return selector(state);
+ });
+
+ render( );
expect(screen.getByText('Variables')).toBeInTheDocument();
expect(screen.queryByText('Add Variable')).not.toBeInTheDocument();
@@ -157,14 +183,46 @@ describe('VariablesPanel', () => {
removeVariable: mockRemoveVariable,
});
- render( );
+ // Mock allowVariableEditing to false for this test
+ mockedUseEditorStore.mockImplementation((selector) => {
+ const state = {
+ incrementRevision: mockIncrementRevision,
+ allowVariableEditing: false, // Set to false for this test
+ previewMode: false,
+ setPreviewMode: jest.fn(),
+ registry: {},
+ initialize: jest.fn(),
+ getComponentDefinition: jest.fn(),
+ revisionCounter: 0,
+ setRevisionCounter: jest.fn(),
+ } as any;
+ return selector(state);
+ });
+
+ render( );
expect(screen.getByText('No variables defined.')).toBeInTheDocument();
expect(screen.queryByText(/Click "Add Variable"/)).not.toBeInTheDocument();
});
it('should hide edit and delete buttons when editVariables is false', () => {
- render( );
+ // Mock allowVariableEditing to false for this test
+ mockedUseEditorStore.mockImplementation((selector) => {
+ const state = {
+ incrementRevision: mockIncrementRevision,
+ allowVariableEditing: false, // Set to false for this test
+ previewMode: false,
+ setPreviewMode: jest.fn(),
+ registry: {},
+ initialize: jest.fn(),
+ getComponentDefinition: jest.fn(),
+ revisionCounter: 0,
+ setRevisionCounter: jest.fn(),
+ } as any;
+ return selector(state);
+ });
+
+ render( );
// Variables should still be displayed
expect(screen.getByText('userName')).toBeInTheDocument();
diff --git a/app/examples/editor/immutable-bindings/page.tsx b/app/examples/editor/immutable-bindings/page.tsx
new file mode 100644
index 0000000..f1976da
--- /dev/null
+++ b/app/examples/editor/immutable-bindings/page.tsx
@@ -0,0 +1,14 @@
+import { BuilderWithImmutableBindings } from "app/platform/builder-with-immutable-bindings";
+
+export const metadata = {
+ title: "UI Builder - Immutable Bindings Example",
+ description: "Showcase of immutable variable bindings feature in UI Builder"
+};
+
+export default function ImmutableBindingsEditorPage() {
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/examples/editor/immutable-pages/page.tsx b/app/examples/editor/immutable-pages/page.tsx
new file mode 100644
index 0000000..7e26554
--- /dev/null
+++ b/app/examples/editor/immutable-pages/page.tsx
@@ -0,0 +1,14 @@
+
+import { BuilderWithPages } from "app/platform/builder-with-pages";
+
+export const metadata = {
+ title: "UI Builder",
+};
+
+export default function ImmutablePagesExample() {
+ return (
+
+
+
+ );
+}
diff --git a/app/platform/builder-with-immutable-bindings.tsx b/app/platform/builder-with-immutable-bindings.tsx
new file mode 100644
index 0000000..afdb450
--- /dev/null
+++ b/app/platform/builder-with-immutable-bindings.tsx
@@ -0,0 +1,368 @@
+"use client"
+
+import React from "react";
+import UIBuilder from "@/components/ui/ui-builder";
+import { demoComponentRegistry } from "./demo-components";
+import { primitiveComponentDefinitions } from "@/lib/ui-builder/registry/primitive-component-definitions";
+import { complexComponentDefinitions } from "@/lib/ui-builder/registry/complex-component-definitions";
+import { ComponentLayer, Variable } from '@/components/ui/ui-builder/types';
+import { useLayerStore } from "@/lib/ui-builder/store/layer-store";
+
+// Initial page structure showcasing immutable bindings
+const initialLayers: ComponentLayer[] = [{
+ "id": "demo-page",
+ "type": "div",
+ "name": "Immutable Bindings Demo",
+ "props": {
+ "className": "min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-8",
+ },
+ "children": [
+ {
+ "id": "header",
+ "type": "div",
+ "name": "Header Section",
+ "props": {
+ "className": "max-w-4xl mx-auto mb-12"
+ },
+ "children": [
+ {
+ "id": "title",
+ "type": "span",
+ "name": "Main Title",
+ "props": {
+ "className": "text-4xl font-bold text-gray-900 block mb-4"
+ },
+ "children": "🔒 Immutable Variable Bindings Demo"
+ },
+ {
+ "id": "subtitle",
+ "type": "span",
+ "name": "Subtitle",
+ "props": {
+ "className": "text-lg text-gray-600 block mb-8"
+ },
+ "children": "Explore how immutable bindings protect critical data while allowing content customization"
+ }
+ ]
+ },
+ {
+ "id": "demo-grid",
+ "type": "div",
+ "name": "Demo Grid",
+ "props": {
+ "className": "max-w-4xl mx-auto grid gap-8 md:grid-cols-2 lg:grid-cols-1"
+ },
+ "children": [
+ {
+ "id": "user-profile-section",
+ "type": "div",
+ "name": "User Profile Section",
+ "props": {
+ "className": "space-y-4"
+ },
+ "children": [
+ {
+ "id": "user-section-title",
+ "type": "span",
+ "name": "User Section Title",
+ "props": {
+ "className": "text-2xl font-semibold text-gray-800 block"
+ },
+ "children": "👤 User Profile Component"
+ },
+ {
+ "id": "user-description",
+ "type": "span",
+ "name": "User Description",
+ "props": {
+ "className": "text-gray-600 block mb-4"
+ },
+ "children": "This component has immutable user data (ID, email, role) but allows customizing the display name."
+ },
+ {
+ "id": "user-profile-demo",
+ "type": "UserProfile",
+ "name": "User Profile Demo",
+ "props": {
+ "userId": { "__variableRef": "current_user_id" },
+ "displayName": { "__variableRef": "current_user_name" },
+ "email": { "__variableRef": "current_user_email" },
+ "role": { "__variableRef": "current_user_role" }
+ },
+ "children": []
+ }
+ ]
+ },
+ {
+ "id": "branded-button-section",
+ "type": "div",
+ "name": "Branded Button Section",
+ "props": {
+ "className": "space-y-4"
+ },
+ "children": [
+ {
+ "id": "button-section-title",
+ "type": "span",
+ "name": "Button Section Title",
+ "props": {
+ "className": "text-2xl font-semibold text-gray-800 block"
+ },
+ "children": "🎨 Branded Button Component"
+ },
+ {
+ "id": "button-description",
+ "type": "span",
+ "name": "Button Description",
+ "props": {
+ "className": "text-gray-600 block mb-4"
+ },
+ "children": "Brand color and company name are immutable, but button text can be customized by content creators."
+ },
+ {
+ "id": "button-container",
+ "type": "div",
+ "name": "Button Container",
+ "props": {
+ "className": "flex flex-wrap gap-4"
+ },
+ "children": [
+ {
+ "id": "primary-button-demo",
+ "type": "BrandedButton",
+ "name": "Primary Button",
+ "props": {
+ "text": { "__variableRef": "button_text" },
+ "brandColor": { "__variableRef": "company_brand_color" },
+ "companyName": { "__variableRef": "company_name" },
+ "variant": "primary",
+ "size": "md"
+ },
+ "children": []
+ },
+ {
+ "id": "secondary-button-demo",
+ "type": "BrandedButton",
+ "name": "Secondary Button",
+ "props": {
+ "text": "Learn More",
+ "brandColor": { "__variableRef": "company_brand_color" },
+ "companyName": { "__variableRef": "company_name" },
+ "variant": "secondary",
+ "size": "md"
+ },
+ "children": []
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "system-alert-section",
+ "type": "div",
+ "name": "System Alert Section",
+ "props": {
+ "className": "space-y-4"
+ },
+ "children": [
+ {
+ "id": "alert-section-title",
+ "type": "span",
+ "name": "Alert Section Title",
+ "props": {
+ "className": "text-2xl font-semibold text-gray-800 block"
+ },
+ "children": "⚠️ System Alert Component"
+ },
+ {
+ "id": "alert-description",
+ "type": "span",
+ "name": "Alert Description",
+ "props": {
+ "className": "text-gray-600 block mb-4"
+ },
+ "children": "System version and maintenance mode are immutable system settings, but alert messages can be customized."
+ },
+ {
+ "id": "system-alert-demo",
+ "type": "SystemAlert",
+ "name": "System Alert Demo",
+ "props": {
+ "message": { "__variableRef": "alert_message" },
+ "type": "info",
+ "systemVersion": { "__variableRef": "system_version" },
+ "maintenanceMode": { "__variableRef": "maintenance_mode" }
+ },
+ "children": []
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "instructions",
+ "type": "div",
+ "name": "Instructions",
+ "props": {
+ "className": "max-w-4xl mx-auto mt-12 p-6 bg-white rounded-lg shadow-lg border-l-4 border-blue-500"
+ },
+ "children": [
+ {
+ "id": "instructions-title",
+ "type": "span",
+ "name": "Instructions Title",
+ "props": {
+ "className": "text-xl font-bold text-gray-900 block mb-4"
+ },
+ "children": "🔍 How to Explore This Demo:"
+ },
+ {
+ "id": "instructions-list",
+ "type": "div",
+ "name": "Instructions List",
+ "props": {
+ "className": "space-y-2 text-gray-700"
+ },
+ "children": [
+ {
+ "id": "instruction-1",
+ "type": "span",
+ "name": "Instruction 1",
+ "props": {
+ "className": "block"
+ },
+ "children": "1. Select any of the demo components above"
+ },
+ {
+ "id": "instruction-2",
+ "type": "span",
+ "name": "Instruction 2",
+ "props": {
+ "className": "block"
+ },
+ "children": "2. Open the Props Panel on the right"
+ },
+ {
+ "id": "instruction-3",
+ "type": "span",
+ "name": "Instruction 3",
+ "props": {
+ "className": "block"
+ },
+ "children": "3. Notice the 🔗 variable binding indicators and 🔒 'Immutable' badges"
+ },
+ {
+ "id": "instruction-4",
+ "type": "span",
+ "name": "Instruction 4",
+ "props": {
+ "className": "block"
+ },
+ "children": "4. Try to unbind immutable variables (you can't!) vs mutable ones (you can)"
+ },
+ {
+ "id": "instruction-5",
+ "type": "span",
+ "name": "Instruction 5",
+ "props": {
+ "className": "block"
+ },
+ "children": "5. Check the Variables Panel to see the underlying variable system"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}];
+
+// Variables that will be automatically bound to components
+const initialVariables: Variable[] = [
+ // User-related variables
+ {
+ id: "current_user_id",
+ name: "Current User ID",
+ type: "string",
+ defaultValue: "usr_789xyz"
+ },
+ {
+ id: "current_user_name",
+ name: "Current User Name",
+ type: "string",
+ defaultValue: "Alex Rivera"
+ },
+ {
+ id: "current_user_email",
+ name: "Current User Email",
+ type: "string",
+ defaultValue: "alex.rivera@company.com"
+ },
+ {
+ id: "current_user_role",
+ name: "Current User Role",
+ type: "string",
+ defaultValue: "Senior Developer"
+ },
+
+ // Brand-related variables
+ {
+ id: "company_brand_color",
+ name: "Company Brand Color",
+ type: "string",
+ defaultValue: "#6366f1"
+ },
+ {
+ id: "company_name",
+ name: "Company Name",
+ type: "string",
+ defaultValue: "TechCorp Inc."
+ },
+ {
+ id: "button_text",
+ name: "Button Text",
+ type: "string",
+ defaultValue: "Get Started"
+ },
+
+ // System-related variables
+ {
+ id: "system_version",
+ name: "System Version",
+ type: "string",
+ defaultValue: "2.1.4"
+ },
+ {
+ id: "maintenance_mode",
+ name: "Maintenance Mode",
+ type: "boolean",
+ defaultValue: false
+ },
+ {
+ id: "alert_message",
+ name: "Alert Message",
+ type: "string",
+ defaultValue: "Welcome to the new immutable bindings feature! This message can be customized."
+ }
+];
+
+export const BuilderWithImmutableBindings = () => {
+ const handleChange = (updatedPages: ComponentLayer[]) => {
+ console.log("🔄 Builder state changed:", updatedPages);
+ };
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/app/platform/builder-with-pages.tsx b/app/platform/builder-with-pages.tsx
index fa05776..2519337 100644
--- a/app/platform/builder-with-pages.tsx
+++ b/app/platform/builder-with-pages.tsx
@@ -981,12 +981,14 @@ const initialLayers: ComponentLayer[] = [{
]
}]
-export const BuilderWithPages = () => {
+export const BuilderWithPages = ({fixedPages = false}: {fixedPages?: boolean}) => {
return ;
};
\ No newline at end of file
diff --git a/app/platform/demo-components.tsx b/app/platform/demo-components.tsx
new file mode 100644
index 0000000..9445e8a
--- /dev/null
+++ b/app/platform/demo-components.tsx
@@ -0,0 +1,252 @@
+import React from 'react';
+import { z } from 'zod';
+import { ComponentRegistry } from '@/components/ui/ui-builder/types';
+import { textInputFieldOverrides } from '@/lib/ui-builder/registry/form-field-overrides';
+
+// UserProfile Component - demonstrates immutable user data binding
+interface UserProfileProps {
+ userId?: string;
+ displayName?: string;
+ email?: string;
+ role?: string;
+ avatar?: string;
+ className?: string;
+}
+
+export const UserProfile: React.FC = ({
+ userId = "user_123",
+ displayName = "John Doe",
+ email = "john@example.com",
+ role = "Developer",
+ avatar,
+ className = ""
+}) => {
+ return (
+
+
+ {avatar ? (
+
+ ) : (
+ (displayName || "U").charAt(0).toUpperCase()
+ )}
+
+
+
{displayName}
+
{email}
+
+
+ {role}
+
+ ID: {userId}
+
+
+
+ );
+};
+
+// BrandedButton Component - demonstrates immutable branding with customizable content
+interface BrandedButtonProps {
+ text?: string;
+ brandColor?: string;
+ companyName?: string;
+ variant?: 'primary' | 'secondary';
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+ onClick?: () => void;
+}
+
+export const BrandedButton: React.FC = ({
+ text = "Click Me",
+ brandColor = "#3b82f6",
+ companyName = "Acme Corp",
+ variant = 'primary',
+ size = 'md',
+ className = "",
+ onClick
+}) => {
+ const sizeClasses = {
+ sm: 'px-3 py-1.5 text-sm',
+ md: 'px-4 py-2 text-base',
+ lg: 'px-6 py-3 text-lg'
+ };
+
+ const baseClasses = `font-semibold rounded-lg transition-all duration-200 ${sizeClasses[size]}`;
+
+ const variantClasses = variant === 'primary'
+ ? `text-white shadow-lg hover:shadow-xl transform hover:scale-105`
+ : `bg-white border-2 shadow-md hover:shadow-lg`;
+
+ const style = variant === 'primary'
+ ? { backgroundColor: brandColor }
+ : { borderColor: brandColor, color: brandColor };
+
+ return (
+
+ {text}
+ by {companyName}
+
+ );
+};
+
+// SystemAlert Component - demonstrates system-wide configurations
+interface SystemAlertProps {
+ message?: string;
+ type?: 'info' | 'warning' | 'error' | 'success';
+ systemVersion?: string;
+ maintenanceMode?: boolean;
+ className?: string;
+}
+
+export const SystemAlert: React.FC = ({
+ message = "System notification",
+ type = "info",
+ systemVersion = "1.0.0",
+ maintenanceMode = false,
+ className = ""
+}) => {
+ const typeStyles = {
+ info: 'bg-blue-50 border-blue-200 text-blue-800',
+ warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
+ error: 'bg-red-50 border-red-200 text-red-800',
+ success: 'bg-green-50 border-green-200 text-green-800'
+ };
+
+ const icons = {
+ info: 'ℹ️',
+ warning: '⚠️',
+ error: '❌',
+ success: '✅'
+ };
+
+ return (
+
+
+
{icons[type]}
+
+
{message}
+
+ System v{systemVersion}
+ {maintenanceMode && MAINTENANCE MODE }
+
+
+
+
+ );
+};
+
+// Registry definitions with immutable bindings
+export const demoComponentRegistry: ComponentRegistry = {
+ UserProfile: {
+ component: UserProfile,
+ schema: z.object({
+ userId: z.string().default("user_123"),
+ displayName: z.string().default("John Doe"),
+ email: z.string().email().default("john@example.com"),
+ role: z.string().default("Developer"),
+ avatar: z.string().optional(),
+ className: z.string().optional()
+ }),
+ from: "app/platform/demo-components",
+ defaultVariableBindings: [
+ {
+ propName: "userId",
+ variableId: "current_user_id",
+ immutable: true // System data that shouldn't be editable
+ },
+ {
+ propName: "email",
+ variableId: "current_user_email",
+ immutable: true // Personal data that's protected
+ },
+ {
+ propName: "role",
+ variableId: "current_user_role",
+ immutable: true // Security-related data
+ },
+ {
+ propName: "displayName",
+ variableId: "current_user_name",
+ immutable: false // Can be customized for display purposes
+ }
+ ],
+ fieldOverrides: {
+ userId: (layer) => textInputFieldOverrides(layer, true, 'userId'),
+ displayName: (layer) => textInputFieldOverrides(layer, true, 'displayName'),
+ email: (layer) => textInputFieldOverrides(layer, true, 'email'),
+ role: (layer) => textInputFieldOverrides(layer, true, 'role'),
+ }
+ },
+
+ BrandedButton: {
+ component: BrandedButton,
+ schema: z.object({
+ text: z.string().default("Click Me"),
+ brandColor: z.string().default("#3b82f6"),
+ companyName: z.string().default("Acme Corp"),
+ variant: z.enum(['primary', 'secondary']).default('primary'),
+ size: z.enum(['sm', 'md', 'lg']).default('md'),
+ className: z.string().optional()
+ }),
+ from: "app/platform/demo-components",
+ defaultVariableBindings: [
+ {
+ propName: "brandColor",
+ variableId: "company_brand_color",
+ immutable: true // Brand consistency must be maintained
+ },
+ {
+ propName: "companyName",
+ variableId: "company_name",
+ immutable: true // Company identity is protected
+ },
+ {
+ propName: "text",
+ variableId: "button_text",
+ immutable: false // Content creators can customize button text
+ }
+ ],
+ fieldOverrides: {
+ text: (layer) => textInputFieldOverrides(layer, true, 'text'),
+ brandColor: (layer) => textInputFieldOverrides(layer, true, 'brandColor'),
+ companyName: (layer) => textInputFieldOverrides(layer, true, 'companyName'),
+ }
+ },
+
+ SystemAlert: {
+ component: SystemAlert,
+ schema: z.object({
+ message: z.string().default("System notification"),
+ type: z.enum(['info', 'warning', 'error', 'success']).default('info'),
+ systemVersion: z.string().default("1.0.0"),
+ maintenanceMode: z.boolean().default(false),
+ className: z.string().optional()
+ }),
+ from: "app/platform/demo-components",
+ defaultVariableBindings: [
+ {
+ propName: "systemVersion",
+ variableId: "system_version",
+ immutable: true // System info should not be editable
+ },
+ {
+ propName: "maintenanceMode",
+ variableId: "maintenance_mode",
+ immutable: true // System state is controlled by admins only
+ },
+ {
+ propName: "message",
+ variableId: "alert_message",
+ immutable: false // Content can be customized
+ }
+ ],
+ fieldOverrides: {
+ message: (layer) => textInputFieldOverrides(layer, true, 'message'),
+ systemVersion: (layer) => textInputFieldOverrides(layer, true, 'systemVersion'),
+ }
+ }
+};
\ No newline at end of file
diff --git a/components/ui/ui-builder/index.tsx b/components/ui/ui-builder/index.tsx
index 13f3708..5395328 100644
--- a/components/ui/ui-builder/index.tsx
+++ b/components/ui/ui-builder/index.tsx
@@ -62,7 +62,9 @@ interface UIBuilderProps {
componentRegistry: ComponentRegistry;
panelConfig?: PanelConfig;
persistLayerStore?: boolean;
- editVariables?: boolean;
+ allowVariableEditing?: boolean;
+ allowPagesCreation?: boolean;
+ allowPagesDeletion?: boolean;
}
/**
@@ -79,7 +81,9 @@ const UIBuilder = ({
componentRegistry,
panelConfig: userPanelConfig,
persistLayerStore = true,
- editVariables = true,
+ allowVariableEditing = true,
+ allowPagesCreation = true,
+ allowPagesDeletion = true,
}: UIBuilderProps) => {
const layerStore = useStore(useLayerStore, (state) => state);
const editorStore = useStore(useEditorStore, (state) => state);
@@ -87,7 +91,7 @@ const UIBuilder = ({
const [editorStoreInitialized, setEditorStoreInitialized] = useState(false);
const [layerStoreInitialized, setLayerStoreInitialized] = useState(false);
- const memoizedDefaultTabsContent = useMemo(() => defaultConfigTabsContent(editVariables), [editVariables]);
+ const memoizedDefaultTabsContent = useMemo(() => defaultConfigTabsContent(), []);
const currentPanelConfig = useMemo(() => {
const effectiveTabsContent = userPanelConfig?.pageConfigPanelTabsContent || memoizedDefaultTabsContent;
@@ -104,7 +108,7 @@ const UIBuilder = ({
// Effect 1: Initialize Editor Store with registry and page form props
useEffect(() => {
if (editorStore && componentRegistry && !editorStoreInitialized) {
- editorStore.initialize(componentRegistry, persistLayerStore);
+ editorStore.initialize(componentRegistry, persistLayerStore, allowPagesCreation, allowPagesDeletion, allowVariableEditing);
setEditorStoreInitialized(true);
}
}, [
@@ -112,6 +116,9 @@ const UIBuilder = ({
componentRegistry,
editorStoreInitialized,
persistLayerStore,
+ allowPagesCreation,
+ allowPagesDeletion,
+ allowVariableEditing,
]);
// Effect 2: Conditionally initialize Layer Store *after* Editor Store is initialized
@@ -304,7 +311,7 @@ export function PageConfigPanel({
* @param {boolean} editVariables - Whether to allow editing variables.
* @returns {TabsContentConfig} The default tabs content configuration.
*/
-export function defaultConfigTabsContent(editVariables: boolean = true) {
+export function defaultConfigTabsContent() {
return {
layers: { title: "Layers", content: },
appearance: { title: "Appearance", content: (
@@ -314,7 +321,7 @@ export function defaultConfigTabsContent(editVariables: boolean = true) {
),
},
- data: { title: "Data", content: }
+ data: { title: "Data", content: }
}
}
diff --git a/components/ui/ui-builder/internal/clickable-wrapper.tsx b/components/ui/ui-builder/internal/clickable-wrapper.tsx
index bcce43d..c6d3544 100644
--- a/components/ui/ui-builder/internal/clickable-wrapper.tsx
+++ b/components/ui/ui-builder/internal/clickable-wrapper.tsx
@@ -21,8 +21,8 @@ interface ClickableWrapperProps {
totalLayers: number;
onSelectElement: (layerId: string) => void;
children: React.ReactNode;
- onDuplicateLayer: () => void;
- onDeleteLayer: () => void;
+ onDuplicateLayer?: () => void;
+ onDeleteLayer?: () => void;
listenToScrollParent: boolean;
observeMutations: boolean;
}
diff --git a/components/ui/ui-builder/internal/editor-panel.tsx b/components/ui/ui-builder/internal/editor-panel.tsx
index 1c535e0..7c00ee3 100644
--- a/components/ui/ui-builder/internal/editor-panel.tsx
+++ b/components/ui/ui-builder/internal/editor-panel.tsx
@@ -33,6 +33,9 @@ const EditorPanel: React.FC = ({ className, useCanvas }) => {
const componentRegistry = useEditorStore((state) => state.registry);
const selectedLayer = findLayerById(selectedLayerId) as ComponentLayer;
const selectedPage = findLayerById(selectedPageId) as ComponentLayer;
+ const isLayerAPage = useLayerStore((state) => state.isLayerAPage(selectedLayerId || ""));
+ const allowPagesCreation = useEditorStore((state) => state.allowPagesCreation);
+ const allowPagesDeletion = useEditorStore((state) => state.allowPagesDeletion);
const layers = selectedPage.children;
@@ -41,26 +44,26 @@ const EditorPanel: React.FC = ({ className, useCanvas }) => {
}, [selectLayer]);
const handleDeleteLayer = useCallback(() => {
- if (selectedLayer) {
+ if (selectedLayer && !isLayerAPage) {
removeLayer(selectedLayer.id);
}
- }, [selectedLayer, removeLayer]);
+ }, [selectedLayer, removeLayer, isLayerAPage]);
const handleDuplicateLayer = useCallback(() => {
- if (selectedLayer) {
+ if (selectedLayer && !isLayerAPage) {
duplicateLayer(selectedLayer.id);
}
- }, [selectedLayer, duplicateLayer]);
+ }, [selectedLayer, duplicateLayer, isLayerAPage]);
const editorConfig = useMemo(() => ({
zIndex: 1,
totalLayers: countLayers(layers),
selectedLayer: selectedLayer,
onSelectElement: onSelectElement,
- handleDuplicateLayer: handleDuplicateLayer,
- handleDeleteLayer: handleDeleteLayer,
+ handleDuplicateLayer: allowPagesCreation ? handleDuplicateLayer : undefined,
+ handleDeleteLayer: allowPagesDeletion ? handleDeleteLayer : undefined,
usingCanvas: useCanvas,
- }), [layers, selectedLayer, onSelectElement, handleDuplicateLayer, handleDeleteLayer, useCanvas]);
+ }), [layers, selectedLayer, onSelectElement, handleDuplicateLayer, handleDeleteLayer, useCanvas, allowPagesCreation, allowPagesDeletion]);
const isMobileScreen = window.innerWidth < 768;
diff --git a/components/ui/ui-builder/internal/layer-menu.tsx b/components/ui/ui-builder/internal/layer-menu.tsx
index c4e6a2d..5c0aae3 100644
--- a/components/ui/ui-builder/internal/layer-menu.tsx
+++ b/components/ui/ui-builder/internal/layer-menu.tsx
@@ -14,8 +14,8 @@ interface MenuProps {
width: number;
height: number;
zIndex: number;
- handleDuplicateComponent: () => void;
- handleDeleteComponent: () => void;
+ handleDuplicateComponent?: () => void;
+ handleDeleteComponent?: () => void;
}
export const LayerMenu: React.FC = ({
@@ -28,15 +28,15 @@ export const LayerMenu: React.FC = ({
}) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const selectedLayer = useLayerStore((state) => state.findLayerById(layerId));
+ const isLayerAPage = useLayerStore((state) => state.isLayerAPage(layerId));
const componentRegistry = useEditorStore((state) => state.registry);
+ const allowPagesCreation = useEditorStore((state) => state.allowPagesCreation);
+ const allowPagesDeletion = useEditorStore((state) => state.allowPagesDeletion);
- //const hasChildrenInSchema = schema.shape.children !== undefined;
- const hasChildrenInSchema =
- selectedLayer &&
- hasLayerChildren(selectedLayer) &&
- componentRegistry[selectedLayer.type as keyof typeof componentRegistry]
- .schema.shape.children !== undefined;
+ // Check permissions for page operations
+ const canDuplicate = !isLayerAPage || allowPagesCreation;
+ const canDelete = !isLayerAPage || allowPagesDeletion;
const style = useMemo(() => ({
top: y,
@@ -48,7 +48,17 @@ export const LayerMenu: React.FC = ({
return buttonVariants({ variant: "ghost", size: "sm" });
}, []);
-
+ const canRenderAddChild = useMemo(() => {
+ if (!selectedLayer) return false;
+
+ const componentDef = componentRegistry[selectedLayer.type as keyof typeof componentRegistry];
+ if (!componentDef) return false;
+
+ return (
+ hasLayerChildren(selectedLayer) &&
+ componentDef.schema.shape.children !== undefined
+ );
+ }, [selectedLayer, componentRegistry]);
return (
<>
@@ -75,7 +85,7 @@ export const LayerMenu: React.FC = ({
popoverOpen ? "max-w-xs" : ""
)}
>
- {hasChildrenInSchema && (
+ {canRenderAddChild && (
= ({
)}
-
- Duplicate
-
-
-
- Delete
-
-
+ {canDuplicate && (
+
+ Duplicate {isLayerAPage ? "Page" : "Component"}
+
+
+ )}
+ {canDelete && (
+
+ Delete {isLayerAPage ? "Page" : "Component"}
+
+
+ )}
diff --git a/components/ui/ui-builder/internal/nav.tsx b/components/ui/ui-builder/internal/nav.tsx
index d944365..89d1029 100644
--- a/components/ui/ui-builder/internal/nav.tsx
+++ b/components/ui/ui-builder/internal/nav.tsx
@@ -543,6 +543,9 @@ function PagesPopover() {
selectedPageId
);
const [textInputValue, setTextInputValue] = useState("");
+ const allowPagesCreation = useEditorStore(
+ (state) => state.allowPagesCreation
+ );
const selectedPageData = useMemo(() => {
return pages.find((page) => page.id === selectedPageId);
@@ -635,7 +638,7 @@ function PagesPopover() {
No pages found
- {textInputForm}
+ {allowPagesCreation && textInputForm}
{pages.map((page) => (
))}
-
- {textInputForm}
-
+ {allowPagesCreation && (
+
+ {textInputForm}
+
+ )}
diff --git a/components/ui/ui-builder/internal/props-panel.tsx b/components/ui/ui-builder/internal/props-panel.tsx
index cd25f36..ff5e015 100644
--- a/components/ui/ui-builder/internal/props-panel.tsx
+++ b/components/ui/ui-builder/internal/props-panel.tsx
@@ -132,12 +132,26 @@ const ComponentPropsAutoForm: React.FC = ({
}) => {
const findLayerById = useLayerStore((state) => state.findLayerById);
const revisionCounter = useEditorStore((state) => state.revisionCounter);
- const selectedLayer = findLayerById(selectedLayerId) as ComponentLayer | undefined;
-
+ const selectedLayer = findLayerById(selectedLayerId) as
+ | ComponentLayer
+ | undefined;
+ const isPage = useLayerStore((state) => state.isLayerAPage(selectedLayerId));
+ const allowPagesCreation = useEditorStore(
+ (state) => state.allowPagesCreation
+ );
+ const allowPagesDeletion = useEditorStore(
+ (state) => state.allowPagesDeletion
+ );
+
// Retrieve the appropriate schema from componentRegistry
const { schema } = useMemo(() => {
- if (selectedLayer && componentRegistry[selectedLayer.type as keyof typeof componentRegistry]) {
- return componentRegistry[selectedLayer.type as keyof typeof componentRegistry];
+ if (
+ selectedLayer &&
+ componentRegistry[selectedLayer.type as keyof typeof componentRegistry]
+ ) {
+ return componentRegistry[
+ selectedLayer.type as keyof typeof componentRegistry
+ ];
}
return { schema: EMPTY_ZOD_SCHEMA }; // Fallback schema
}, [selectedLayer, componentRegistry]);
@@ -151,28 +165,37 @@ const ComponentPropsAutoForm: React.FC = ({
}, [duplicateLayer, selectedLayerId]);
const onParsedValuesChange = useCallback(
- (parsedValues: z.infer & { children?: string | { layerType: string, addPosition: number } }) => {
+ (
+ parsedValues: z.infer & {
+ children?: string | { layerType: string; addPosition: number };
+ }
+ ) => {
const { children, ...dataProps } = parsedValues;
-
+
// Preserve variable references by merging with original props
const preservedProps: Record = {};
if (selectedLayer) {
// Start with all original props to preserve any that aren't in the form update
Object.assign(preservedProps, selectedLayer.props);
-
+
// Then update only the props that came from the form, preserving variable references
- Object.keys(dataProps as Record).forEach(key => {
+ Object.keys(dataProps as Record).forEach((key) => {
const originalValue = selectedLayer.props[key];
const newValue = (dataProps as Record)[key];
const fieldDef = schema?.shape?.[key];
- const baseType = fieldDef ? getBaseType(fieldDef as z.ZodAny) : undefined;
+ const baseType = fieldDef
+ ? getBaseType(fieldDef as z.ZodAny)
+ : undefined;
// If the original value was a variable reference, preserve it
if (isVariableReference(originalValue)) {
// Keep the variable reference - the form should not override variable bindings
preservedProps[key] = originalValue;
} else {
// Handle date serialization
- if (baseType === z.ZodFirstPartyTypeKind.ZodDate && newValue instanceof Date) {
+ if (
+ baseType === z.ZodFirstPartyTypeKind.ZodDate &&
+ newValue instanceof Date
+ ) {
preservedProps[key] = newValue.toISOString();
} else {
preservedProps[key] = newValue;
@@ -180,13 +203,19 @@ const ComponentPropsAutoForm: React.FC = ({
}
});
}
-
- if(typeof children === "string") {
- updateLayer(selectedLayerId, preservedProps, { children: children });
- }else if(children && children.layerType) {
- updateLayer(selectedLayerId, preservedProps, { children: selectedLayer?.children });
- addComponentLayer(children.layerType, selectedLayerId, children.addPosition)
- }else{
+
+ if (typeof children === "string") {
+ updateLayer(selectedLayerId, preservedProps, { children: children });
+ } else if (children && children.layerType) {
+ updateLayer(selectedLayerId, preservedProps, {
+ children: selectedLayer?.children,
+ });
+ addComponentLayer(
+ children.layerType,
+ selectedLayerId,
+ children.addPosition
+ );
+ } else {
updateLayer(selectedLayerId, preservedProps);
}
},
@@ -198,13 +227,16 @@ const ComponentPropsAutoForm: React.FC = ({
if (!selectedLayer) return EMPTY_FORM_VALUES;
const variables = useLayerStore.getState().variables;
-
+
// First resolve variable references to get display values
- const resolvedProps = resolveVariableReferences(selectedLayer.props, variables);
-
+ const resolvedProps = resolveVariableReferences(
+ selectedLayer.props,
+ variables
+ );
+
const transformedProps: Record = {};
const schemaShape = schema?.shape as z.ZodRawShape | undefined; // Get shape from the memoized schema
-
+
if (schemaShape) {
for (const [key, value] of Object.entries(resolvedProps)) {
const fieldDef = schemaShape[key];
@@ -212,12 +244,13 @@ const ComponentPropsAutoForm: React.FC = ({
const baseType = getBaseType(fieldDef as z.ZodAny);
if (baseType === z.ZodFirstPartyTypeKind.ZodEnum) {
// Convert enum value to string if it's not already a string
- transformedProps[key] = typeof value === 'string' ? value : String(value);
+ transformedProps[key] =
+ typeof value === "string" ? value : String(value);
} else if (baseType === z.ZodFirstPartyTypeKind.ZodDate) {
// Convert string to Date if necessary
if (value instanceof Date) {
transformedProps[key] = value;
- } else if (typeof value === 'string' || typeof value === 'number') {
+ } else if (typeof value === "string" || typeof value === "number") {
const date = new Date(value);
transformedProps[key] = isNaN(date.getTime()) ? undefined : date;
} else {
@@ -230,14 +263,13 @@ const ComponentPropsAutoForm: React.FC = ({
transformedProps[key] = value;
}
}
-
} else {
- // Fallback if schema shape isn't available: copy resolved props as is
- Object.assign(transformedProps, resolvedProps);
+ // Fallback if schema shape isn't available: copy resolved props as is
+ Object.assign(transformedProps, resolvedProps);
}
return { ...transformedProps, children: selectedLayer.children };
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedLayer, schema, revisionCounter]); // Include revisionCounter to detect undo/redo changes
const autoFormSchema = useMemo(() => {
@@ -271,22 +303,28 @@ const ComponentPropsAutoForm: React.FC = ({
fieldConfig={autoFormFieldConfig}
className="space-y-4 mt-4"
>
-
- Duplicate Component
-
-
- Delete Component
-
+ {(!isPage || allowPagesCreation) && (
+
+ Duplicate {isPage ? "Page" : "Component"}
+
+ )}
+ {(!isPage || allowPagesDeletion) && (
+
+ Delete {isPage ? "Page" : "Component"}
+
+ )}
);
};
diff --git a/components/ui/ui-builder/internal/render-utils.tsx b/components/ui/ui-builder/internal/render-utils.tsx
index 5ad5d84..6fe8400 100644
--- a/components/ui/ui-builder/internal/render-utils.tsx
+++ b/components/ui/ui-builder/internal/render-utils.tsx
@@ -20,8 +20,8 @@ export interface EditorConfig {
selectedLayer: ComponentLayer;
parentUpdated?: boolean;
onSelectElement: (layerId: string) => void;
- handleDuplicateLayer: () => void;
- handleDeleteLayer: () => void;
+ handleDuplicateLayer?: () => void;
+ handleDeleteLayer?: () => void;
usingCanvas?: boolean;
}
diff --git a/components/ui/ui-builder/internal/tree-row-node.tsx b/components/ui/ui-builder/internal/tree-row-node.tsx
index f7b2e13..66aa5de 100644
--- a/components/ui/ui-builder/internal/tree-row-node.tsx
+++ b/components/ui/ui-builder/internal/tree-row-node.tsx
@@ -19,6 +19,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { AddComponentsPopover } from "@/components/ui/ui-builder/internal/add-component-popover";
import { NameEdit } from "@/components/ui/ui-builder/internal/name-edit";
+import { useEditorStore } from "@/lib/ui-builder/store/editor-store";
+import { useLayerStore } from "@/lib/ui-builder/store/layer-store";
interface TreeRowNodeProps {
node: ComponentLayer;
@@ -60,6 +62,15 @@ export const TreeRowNode: React.FC = memo(({
const [popoverOrMenuOpen, setPopoverOrMenuOpen] = useState(false);
+ const isPage = useLayerStore((state) => state.isLayerAPage(node.id));
+
+ const allowPagesCreation = useEditorStore(
+ (state) => state.allowPagesCreation
+ );
+ const allowPagesDeletion = useEditorStore(
+ (state) => state.allowPagesDeletion
+ );
+
const handleOpen = useCallback(() => {
onToggle(id, !open);
}, [id, open, onToggle]);
@@ -184,10 +195,14 @@ export const TreeRowNode: React.FC = memo(({
Rename
-
- Duplicate
-
- Remove
+ {allowPagesCreation && (
+
+ Duplicate
+
+ )}
+ {allowPagesDeletion && (
+ Remove
+ )}
diff --git a/components/ui/ui-builder/internal/variables-panel.tsx b/components/ui/ui-builder/internal/variables-panel.tsx
index e3b7d83..0d41416 100644
--- a/components/ui/ui-builder/internal/variables-panel.tsx
+++ b/components/ui/ui-builder/internal/variables-panel.tsx
@@ -22,26 +22,17 @@ const EMPTY_OBJECT = {};
interface VariablesPanelProps {
className?: string;
- editVariables?: boolean;
}
export const VariablesPanel: React.FC = ({
className,
- editVariables = true,
}) => {
const { variables, addVariable, updateVariable, removeVariable } =
useLayerStore();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState(null);
const incrementRevision = useEditorStore((state) => state.incrementRevision);
-
- const handleRemoveVariable = useCallback(
- (id: string) => {
- removeVariable(id);
- incrementRevision();
- },
- [removeVariable, incrementRevision]
- );
+ const allowVariableEditing = useEditorStore((state) => state.allowVariableEditing);
const handleAddVariable = useCallback(
(name: string, type: Variable["type"], defaultValue: any) => {
@@ -83,7 +74,7 @@ export const VariablesPanel: React.FC = ({
Variables
- {editVariables && (
+ {allowVariableEditing && (
Add Variable
@@ -91,7 +82,7 @@ export const VariablesPanel: React.FC = ({
)}
- {isAdding && editVariables && (
+ {isAdding && allowVariableEditing && (
= ({
onSave={handleOnSave}
onCancel={handleOnCancel}
onDelete={handleOnDelete}
- editVariables={editVariables}
+ editVariables={allowVariableEditing}
/>
))}
{variables.length === 0 && !isAdding && (
- {editVariables
+ {allowVariableEditing
? 'No variables defined. Click "Add Variable" to create one.'
: "No variables defined."}
diff --git a/components/ui/ui-builder/types.ts b/components/ui/ui-builder/types.ts
index 2be3fac..5f866cb 100644
--- a/components/ui/ui-builder/types.ts
+++ b/components/ui/ui-builder/types.ts
@@ -26,12 +26,19 @@ export interface Variable {
defaultValue: any;
}
+export interface DefaultVariableBinding {
+ propName: string;
+ variableId: string;
+ immutable?: boolean;
+}
+
export interface RegistryEntry> {
component?: T;
schema: ZodObject;
from?: string;
isFromDefaultExport?: boolean;
defaultChildren?: (ComponentLayer)[] | string;
+ defaultVariableBindings?: DefaultVariableBinding[];
fieldOverrides?: Record;
}
diff --git a/lib/ui-builder/registry/complex-component-definitions.ts b/lib/ui-builder/registry/complex-component-definitions.ts
index 963b426..1609529 100644
--- a/lib/ui-builder/registry/complex-component-definitions.ts
+++ b/lib/ui-builder/registry/complex-component-definitions.ts
@@ -11,27 +11,8 @@ import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/
import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { classNameFieldOverrides, childrenFieldOverrides, iconNameFieldOverrides, commonFieldOverrides, childrenAsTipTapFieldOverrides, textInputFieldOverrides, commonVariableRenderParentOverrides } from "@/lib/ui-builder/registry/form-field-overrides";
import { ComponentLayer } from '@/components/ui/ui-builder/types';
-import { ExampleComp } from '../../../app/platform/example-comp';
export const complexComponentDefinitions: ComponentRegistry = {
- ExampleComp: {
- component: ExampleComp,
- schema: z.object({
- name: z.string().default("World"),
- age: z.coerce.number().default(20),
- birthDate: z.coerce.date().default(new Date()),
- married: z.boolean().default(false),
- work: z.enum(["developer", "designer", "manager"]).default("developer"),
- children: z.any().optional(),
- }),
- from: "@/components/ui/ui-builder/example-comp",
- fieldOverrides: {
- name: (layer: ComponentLayer)=> textInputFieldOverrides(layer, true, "name"),
- age: (layer: ComponentLayer)=> commonVariableRenderParentOverrides("age"),
- married: (layer: ComponentLayer)=> commonVariableRenderParentOverrides("married"),
- children: (layer: ComponentLayer)=> childrenFieldOverrides(layer),
- },
- },
Button: {
component: Button,
schema: z.object({
diff --git a/lib/ui-builder/registry/form-field-overrides.tsx b/lib/ui-builder/registry/form-field-overrides.tsx
index 65b0c98..9335380 100644
--- a/lib/ui-builder/registry/form-field-overrides.tsx
+++ b/lib/ui-builder/registry/form-field-overrides.tsx
@@ -20,7 +20,7 @@ import {
} from "@/components/ui/tooltip";
import { useLayerStore } from "../store/layer-store";
import { isVariableReference } from "../utils/variable-resolver";
-import { Link, Unlink } from "lucide-react";
+import { Link, LockKeyhole, Unlink } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -33,6 +33,7 @@ import { useEditorStore } from "../store/editor-store";
import { Card, CardContent } from "@/components/ui/card";
import BreakpointClassNameControl from "@/components/ui/ui-builder/internal/classname-control";
import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
export const classNameFieldOverrides: FieldConfigFunction = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -247,6 +248,7 @@ export function VariableBindingWrapper({
const variables = useLayerStore((state) => state.variables);
const selectedLayerId = useLayerStore((state) => state.selectedLayerId);
const findLayerById = useLayerStore((state) => state.findLayerById);
+ const isBindingImmutable = useLayerStore((state) => state.isBindingImmutable);
const incrementRevision = useEditorStore((state) => state.incrementRevision);
const unbindPropFromVariable = useLayerStore(
(state) => state.unbindPropFromVariable
@@ -265,6 +267,7 @@ export function VariableBindingWrapper({
const boundVariable = isCurrentlyBound
? variables.find((v) => v.id === currentValue.__variableRef)
: null;
+ const isImmutable = isBindingImmutable(selectedLayer.id, propName);
const handleBindToVariable = (variableId: string) => {
bindPropToVariable(selectedLayer.id, propName, variableId);
@@ -295,6 +298,11 @@ export function VariableBindingWrapper({
{boundVariable.type}
+ {isImmutable && (
+
+
+
+ )}
{String(boundVariable.defaultValue)}
@@ -303,18 +311,20 @@ export function VariableBindingWrapper({
-
-
-
-
-
-
- Unbind Variable
-
+ {!isImmutable && (
+
+
+
+
+
+
+ Unbind Variable
+
+ )}
) : (
diff --git a/lib/ui-builder/store/editor-store.ts b/lib/ui-builder/store/editor-store.ts
index dbbdae8..9fa00e8 100644
--- a/lib/ui-builder/store/editor-store.ts
+++ b/lib/ui-builder/store/editor-store.ts
@@ -11,7 +11,7 @@ export interface EditorStore {
registry: ComponentRegistry;
- initialize: (registry: ComponentRegistry, persistLayerStoreConfig: boolean) => void;
+ initialize: (registry: ComponentRegistry, persistLayerStoreConfig: boolean, allowPagesCreation: boolean, allowPagesDeletion: boolean, allowVariableEditing: boolean) => void;
getComponentDefinition: (type: string) => RegistryEntry> | undefined;
persistLayerStoreConfig: boolean;
@@ -20,6 +20,13 @@ export interface EditorStore {
// Revision counter to track state changes for form revalidation
revisionCounter: number;
incrementRevision: () => void;
+
+ allowPagesCreation: boolean;
+ setAllowPagesCreation: (allow: boolean) => void;
+ allowPagesDeletion: boolean;
+ setAllowPagesDeletion: (allow: boolean) => void;
+ allowVariableEditing: boolean;
+ setAllowVariableEditing: (allow: boolean) => void;
}
const store: StateCreator = (set, get) => ({
@@ -28,8 +35,8 @@ const store: StateCreator = (set, get) => ({
registry: {},
- initialize: (registry, persistLayerStoreConfig) => {
- set(state => ({ ...state, registry, persistLayerStoreConfig }));
+ initialize: (registry, persistLayerStoreConfig, allowPagesCreation, allowPagesDeletion, allowVariableEditing) => {
+ set(state => ({ ...state, registry, persistLayerStoreConfig, allowPagesCreation, allowPagesDeletion, allowVariableEditing }));
},
getComponentDefinition: (type: string) => {
const { registry } = get();
@@ -45,6 +52,13 @@ const store: StateCreator = (set, get) => ({
revisionCounter: 0,
incrementRevision: () => set(state => ({ revisionCounter: state.revisionCounter + 1 })),
+
+ allowPagesCreation: true,
+ setAllowPagesCreation: (allow) => set({ allowPagesCreation: allow }),
+ allowPagesDeletion: true,
+ setAllowPagesDeletion: (allow) => set({ allowPagesDeletion: allow }),
+ allowVariableEditing: true,
+ setAllowVariableEditing: (allow) => set({ allowVariableEditing: allow }),
});
export const useEditorStore = create()(store);
\ No newline at end of file
diff --git a/lib/ui-builder/store/layer-store.ts b/lib/ui-builder/store/layer-store.ts
index e83f778..458bce9 100644
--- a/lib/ui-builder/store/layer-store.ts
+++ b/lib/ui-builder/store/layer-store.ts
@@ -19,6 +19,7 @@ export interface LayerStore {
selectedLayerId: string | null;
selectedPageId: string;
variables: Variable[];
+ immutableBindings: Record>; // layerId -> propName -> isImmutable
initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => void;
addComponentLayer: (layerType: string, parentId: string, parentPosition?: number) => void;
addPageLayer: (pageId: string) => void;
@@ -29,12 +30,15 @@ export interface LayerStore {
selectPage: (pageId: string) => void;
findLayerById: (layerId: string | null) => ComponentLayer | undefined;
findLayersForPageId: (pageId: string) => ComponentLayer[];
+ isLayerAPage: (layerId: string) => boolean;
addVariable: (name: string, type: Variable['type'], defaultValue: any) => void;
updateVariable: (variableId: string, updates: Partial>) => void;
removeVariable: (variableId: string) => void;
bindPropToVariable: (layerId: string, propName: string, variableId: string) => void;
unbindPropFromVariable: (layerId: string, propName: string) => void;
+ isBindingImmutable: (layerId: string, propName: string) => boolean;
+ setImmutableBinding: (layerId: string, propName: string, isImmutable: boolean) => void; // Test helper
}
const store: StateCreator = (set, get) => (
@@ -52,6 +56,8 @@ const store: StateCreator = (set, get) => (
// Variables available for binding
variables: [],
+ // Track immutable bindings: layerId -> propName -> isImmutable
+ immutableBindings: {},
selectedLayerId: null,
selectedPageId: '1',
initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => {
@@ -76,11 +82,17 @@ const store: StateCreator = (set, get) => (
return [];
},
+ isLayerAPage: (layerId: string) => {
+ const { pages } = get();
+ return pages.some(page => page.id === layerId);
+ },
+
addComponentLayer: (layerType: string, parentId: string, parentPosition?: number) => set(produce((state: LayerStore) => {
const { registry } = useEditorStore.getState();
const defaultProps = getDefaultProps(registry[layerType].schema);
const defaultChildrenRaw = registry[layerType].defaultChildren;
const defaultChildren = typeof defaultChildrenRaw === "string" ? defaultChildrenRaw : (defaultChildrenRaw?.map(child => duplicateWithNewIdsAndName(child, false)) || []);
+ const defaultVariableBindings = registry[layerType].defaultVariableBindings || [];
const initialProps = Object.entries(defaultProps).reduce((acc, [key, propDef]) => {
if (key !== "children") {
@@ -97,12 +109,27 @@ const store: StateCreator = (set, get) => (
children: defaultChildren,
};
+ // Apply default variable bindings
+ for (const binding of defaultVariableBindings) {
+ const variable = state.variables.find(v => v.id === binding.variableId);
+ if (variable) {
+ // Set the variable reference in the props
+ newLayer.props[binding.propName] = { __variableRef: binding.variableId };
+
+ // Track immutable bindings
+ if (binding.immutable) {
+ if (!state.immutableBindings[newLayer.id]) {
+ state.immutableBindings[newLayer.id] = {};
+ }
+ state.immutableBindings[newLayer.id][binding.propName] = true;
+ }
+ }
+ }
+
// Traverse and update the pages to add the new layer
const updatedPages = addLayer(state.pages, newLayer, parentId, parentPosition);
- return {
- ...state,
- pages: updatedPages
- };
+ // Directly mutate the state instead of returning a new object
+ state.pages = updatedPages;
})),
addPageLayer: (pageName: string) => set(produce((state: LayerStore) => {
@@ -345,6 +372,12 @@ const store: StateCreator = (set, get) => (
// Unbind a component prop from a variable reference and set default value from schema
unbindPropFromVariable: (layerId, propName) => {
+ // Check if the binding is immutable
+ if (get().isBindingImmutable(layerId, propName)) {
+ console.warn(`Cannot unbind immutable variable binding for ${propName} on layer ${layerId}`);
+ return;
+ }
+
const { registry } = useEditorStore.getState();
const layer = get().findLayerById(layerId);
@@ -369,6 +402,22 @@ const store: StateCreator = (set, get) => (
get().updateLayer(layerId, { [propName]: defaultValue });
},
+
+ // Check if a binding is immutable
+ isBindingImmutable: (layerId: string, propName: string) => {
+ const { immutableBindings } = get();
+ return immutableBindings[layerId]?.[propName] === true;
+ },
+
+ // Test helper
+ setImmutableBinding: (layerId: string, propName: string, isImmutable: boolean) => {
+ set(produce((state: LayerStore) => {
+ if (!state.immutableBindings[layerId]) {
+ state.immutableBindings[layerId] = {};
+ }
+ state.immutableBindings[layerId][propName] = isImmutable;
+ }));
+ },
}
)
@@ -407,7 +456,7 @@ const useLayerStore = create(persist(temporal(store,
}
), {
name: "layer-store",
- version: 4,
+ version: 5,
storage: createJSONStorage(() => conditionalLocalStorage),
migrate: (persistedState: unknown, version: number) => {
/* istanbul ignore if*/
@@ -417,7 +466,10 @@ const useLayerStore = create(persist(temporal(store,
return migrateV2ToV3(persistedState as LayerStore);
} else if (version === 3) {
// New variable support: ensure variables array exists
- return { ...(persistedState as LayerStore), variables: [] as Variable[] } as LayerStore;
+ return { ...(persistedState as LayerStore), variables: [] as Variable[], immutableBindings: {} } as LayerStore;
+ } else if (version === 4) {
+ // New immutable bindings support: ensure immutableBindings object exists
+ return { ...(persistedState as LayerStore), immutableBindings: {} } as LayerStore;
}
return persistedState;
}
diff --git a/registry/block-registry.json b/registry/block-registry.json
index 94f5733..df65eb8 100644
--- a/registry/block-registry.json
+++ b/registry/block-registry.json
@@ -70,7 +70,7 @@
"files": [
{
"path": "components/ui/ui-builder/types.ts",
- "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { ZodObject } from \"zod\";\nimport { ComponentType as ReactComponentType } from 'react';\nimport {\n FieldConfigItem,\n } from \"@/components/ui/auto-form/types\";\n\n\nexport type {\n AutoFormInputComponentProps,\n FieldConfigItem,\n } from \"@/components/ui/auto-form/types\";\n\nexport type ComponentLayer = {\n id: string;\n name?: string;\n type: string;\n props: Record;\n children: ComponentLayer[] | string;\n};\n\nexport interface Variable {\n id: string;\n name: string;\n type: 'string' | 'number' | 'boolean';\n defaultValue: any;\n}\n\nexport interface RegistryEntry> {\n component?: T;\n schema: ZodObject;\n from?: string;\n isFromDefaultExport?: boolean;\n defaultChildren?: (ComponentLayer)[] | string;\n fieldOverrides?: Record;\n}\n\nexport type FieldConfigFunction = (layer: ComponentLayer, allowVariableBinding?: boolean ) => FieldConfigItem;\n\nexport type ComponentRegistry = Record<\n string, RegistryEntry>\n>;\n\n\n\n\n\n\n",
+ "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { ZodObject } from \"zod\";\nimport { ComponentType as ReactComponentType } from 'react';\nimport {\n FieldConfigItem,\n } from \"@/components/ui/auto-form/types\";\n\n\nexport type {\n AutoFormInputComponentProps,\n FieldConfigItem,\n } from \"@/components/ui/auto-form/types\";\n\nexport type ComponentLayer = {\n id: string;\n name?: string;\n type: string;\n props: Record;\n children: ComponentLayer[] | string;\n};\n\nexport interface Variable {\n id: string;\n name: string;\n type: 'string' | 'number' | 'boolean';\n defaultValue: any;\n}\n\nexport interface DefaultVariableBinding {\n propName: string;\n variableId: string;\n immutable?: boolean;\n}\n\nexport interface RegistryEntry> {\n component?: T;\n schema: ZodObject;\n from?: string;\n isFromDefaultExport?: boolean;\n defaultChildren?: (ComponentLayer)[] | string;\n defaultVariableBindings?: DefaultVariableBinding[];\n fieldOverrides?: Record;\n}\n\nexport type FieldConfigFunction = (layer: ComponentLayer, allowVariableBinding?: boolean ) => FieldConfigItem;\n\nexport type ComponentRegistry = Record<\n string, RegistryEntry>\n>;\n\n\n\n\n\n\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/types.ts"
},
@@ -94,7 +94,7 @@
},
{
"path": "components/ui/ui-builder/index.tsx",
- "content": "/**\n * Learn more about the UI Builder:\n * https://github.com/olliethedev/ui-builder\n */\n\n\"use client\";\n\nimport React, { useEffect, useState, useMemo, useCallback } from \"react\";\nimport LayersPanel from \"@/components/ui/ui-builder/internal/layers-panel\";\nimport EditorPanel from \"@/components/ui/ui-builder/internal/editor-panel\";\nimport PropsPanel from \"@/components/ui/ui-builder/internal/props-panel\";\nimport { NavBar } from \"@/components/ui/ui-builder/internal/nav\";\nimport { ThemeProvider } from \"next-themes\";\nimport { Tabs, TabsList, TabsTrigger, TabsContent } from \"@/components/ui/tabs\";\nimport {\n ResizablePanel,\n ResizableHandle,\n ResizablePanelGroup,\n} from \"@/components/ui/resizable\";\nimport { Button } from \"@/components/ui/button\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useStore } from \"@/hooks/use-store\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport {\n ComponentRegistry,\n ComponentLayer,\n Variable\n} from \"@/components/ui/ui-builder/types\";\nimport { TailwindThemePanel } from \"@/components/ui/ui-builder/internal/tailwind-theme-panel\";\nimport { ConfigPanel } from \"@/components/ui/ui-builder/internal/config-panel\";\nimport { VariablesPanel } from \"@/components/ui/ui-builder/internal/variables-panel\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\n\n/**\n * TabsContentConfig defines the structure for the content of the page config panel tabs.\n */\nexport interface TabsContentConfig {\n layers: { title: string; content: React.ReactNode };\n appearance?: { title: string; content: React.ReactNode };\n data?: { title: string; content: React.ReactNode };\n}\n\n/**\n * PanelConfig defines the configuration for the main panels in the UI Builder.\n */\ninterface PanelConfig {\n navBar?: React.ReactNode;\n pageConfigPanel?: React.ReactNode;\n pageConfigPanelTabsContent?: TabsContentConfig;\n editorPanel?: React.ReactNode;\n propsPanel?: React.ReactNode;\n}\n\n/**\n * UIBuilderProps defines the props for the UIBuilder component.\n */\ninterface UIBuilderProps {\n initialLayers?: ComponentLayer[];\n onChange?: (pages: ComponentLayer[]) => void;\n initialVariables?: Variable[];\n onVariablesChange?: (variables: Variable[]) => void;\n componentRegistry: ComponentRegistry;\n panelConfig?: PanelConfig;\n persistLayerStore?: boolean;\n editVariables?: boolean;\n}\n\n/**\n * UIBuilder component manages the initialization of editor and layer stores, and renders the serializable layout.\n *\n * @param {UIBuilderProps} props - The props for the UIBuilder component.\n * @returns {JSX.Element} The UIBuilder component wrapped in a ThemeProvider.\n */\nconst UIBuilder = ({\n initialLayers,\n onChange,\n initialVariables,\n onVariablesChange,\n componentRegistry,\n panelConfig: userPanelConfig,\n persistLayerStore = true,\n editVariables = true,\n}: UIBuilderProps) => {\n const layerStore = useStore(useLayerStore, (state) => state);\n const editorStore = useStore(useEditorStore, (state) => state);\n\n const [editorStoreInitialized, setEditorStoreInitialized] = useState(false);\n const [layerStoreInitialized, setLayerStoreInitialized] = useState(false);\n\n const memoizedDefaultTabsContent = useMemo(() => defaultConfigTabsContent(editVariables), [editVariables]);\n\n const currentPanelConfig = useMemo(() => {\n const effectiveTabsContent = userPanelConfig?.pageConfigPanelTabsContent || memoizedDefaultTabsContent;\n const defaultPanels = getDefaultPanelConfigValues(true, effectiveTabsContent);\n\n return {\n navBar: userPanelConfig?.navBar ?? defaultPanels.navBar,\n pageConfigPanel: userPanelConfig?.pageConfigPanel ?? defaultPanels.pageConfigPanel,\n editorPanel: userPanelConfig?.editorPanel ?? defaultPanels.editorPanel,\n propsPanel: userPanelConfig?.propsPanel ?? defaultPanels.propsPanel,\n };\n }, [userPanelConfig, memoizedDefaultTabsContent]);\n\n // Effect 1: Initialize Editor Store with registry and page form props\n useEffect(() => {\n if (editorStore && componentRegistry && !editorStoreInitialized) {\n editorStore.initialize(componentRegistry, persistLayerStore);\n setEditorStoreInitialized(true);\n }\n }, [\n editorStore,\n componentRegistry,\n editorStoreInitialized,\n persistLayerStore,\n ]);\n\n // Effect 2: Conditionally initialize Layer Store *after* Editor Store is initialized\n useEffect(() => {\n if (layerStore && editorStore) {\n if (initialLayers && !layerStoreInitialized) {\n layerStore.initialize(initialLayers, undefined, undefined, initialVariables);\n setLayerStoreInitialized(true);\n const { clear } = useLayerStore.temporal.getState();\n clear();\n } else {\n setLayerStoreInitialized(true);\n }\n }\n }, [\n layerStore,\n editorStore,\n componentRegistry,\n initialLayers,\n initialVariables,\n layerStoreInitialized,\n ]);\n\n // Effect 3: Handle onChange callback when pages change\n useEffect(() => {\n if (onChange && layerStore?.pages && layerStoreInitialized) {\n onChange(layerStore.pages);\n }\n }, [layerStore?.pages, onChange, layerStoreInitialized]);\n\n // Effect 4: Handle onVariablesChange callback when variables change\n useEffect(() => {\n if (onVariablesChange && layerStore?.variables && layerStoreInitialized) {\n onVariablesChange(layerStore.variables);\n }\n }, [layerStore?.variables, onVariablesChange, layerStoreInitialized]);\n\n const isLoading = !layerStoreInitialized || !editorStoreInitialized;\n const layout = isLoading ? (\n \n ) : (\n \n );\n\n return (\n \n \n {layout}\n \n \n );\n};\n\nfunction MainLayout({ panelConfig }: { panelConfig: PanelConfig }) {\n const mainPanels = useMemo(\n () => [\n {\n title: \"Page Config\",\n content: panelConfig.pageConfigPanel,\n defaultSize: 25,\n },\n {\n title: \"UI Editor\",\n content: panelConfig.editorPanel,\n defaultSize: 50,\n },\n {\n title: \"Props\",\n content: panelConfig.propsPanel,\n defaultSize: 25,\n },\n ],\n [panelConfig]\n );\n\n const [selectedPanel, setSelectedPanel] = useState(mainPanels[1]);\n\n const handlePanelClickById = useCallback((e: React.MouseEvent) => {\n const panelIndex = parseInt(e.currentTarget.dataset.panelIndex || \"0\");\n setSelectedPanel(mainPanels[panelIndex]);\n }, [mainPanels]);\n\n return (\n \n {panelConfig.navBar}\n {/* Desktop Layout */}\n
\n \n {mainPanels.map((panel, index) => (\n \n {index > 0 && }\n \n {panel.content}\n \n \n ))}\n \n
\n {/* Mobile Layout */}\n
\n {selectedPanel.content}\n
\n
\n {mainPanels.map((panel, index) => (\n \n {panel.title}\n \n ))}\n
\n
\n
\n
\n );\n}\n\n/**\n * PageConfigPanel renders a tabbed panel for page configuration, including layers, appearance, and variables tabs.\n *\n * @param {object} props\n * @param {string} props.className - The class name for the panel container.\n * @param {TabsContentConfig} props.tabsContent - The content for the tabs.\n * @returns {JSX.Element} The page config panel with tabs.\n */\nexport function PageConfigPanel({\n className,\n tabsContent,\n}: {\n className: string;\n tabsContent: TabsContentConfig;\n}) {\n const { layers, appearance, data } = tabsContent;\n const tabCount = 1 + (appearance ? 1 : 0) + (data ? 1 : 0);\n \n return (\n \n \n {layers.title} \n {appearance && {appearance.title} }\n {data && {data.title} }\n \n \n {layers.content}\n \n {appearance && (\n \n {appearance.content}\n \n )}\n {data && (\n \n {data.content}\n \n )}\n \n );\n}\n\n/**\n * Returns the default tab content configuration for the page config panel.\n *\n * @param {boolean} editVariables - Whether to allow editing variables.\n * @returns {TabsContentConfig} The default tabs content configuration.\n */\nexport function defaultConfigTabsContent(editVariables: boolean = true) {\n return {\n layers: { title: \"Layers\", content: },\n appearance: { title: \"Appearance\", content: (\n \n \n \n
\n ),\n },\n data: { title: \"Data\", content: }\n }\n}\n\n/**\n * LoadingSkeleton renders a skeleton UI while the builder is initializing.\n *\n * @returns {JSX.Element} The loading skeleton.\n */\nexport function LoadingSkeleton() {\n return (\n \n );\n}\n\n/**\n * Returns the default panel configuration values for the UI Builder.\n *\n * @param {boolean} useCanvas - Whether to use the canvas editor.\n * @param {TabsContentConfig} tabsContent - The content for the page config panel tabs.\n * @returns {PanelConfig} The default panel configuration.\n */\nexport const getDefaultPanelConfigValues = (useCanvas: boolean, tabsContent: TabsContentConfig) => {\n return {\n navBar: ,\n pageConfigPanel: (\n \n ),\n editorPanel: (\n \n ),\n propsPanel: (\n \n ),\n };\n};\n\nexport default UIBuilder;\n",
+ "content": "/**\n * Learn more about the UI Builder:\n * https://github.com/olliethedev/ui-builder\n */\n\n\"use client\";\n\nimport React, { useEffect, useState, useMemo, useCallback } from \"react\";\nimport LayersPanel from \"@/components/ui/ui-builder/internal/layers-panel\";\nimport EditorPanel from \"@/components/ui/ui-builder/internal/editor-panel\";\nimport PropsPanel from \"@/components/ui/ui-builder/internal/props-panel\";\nimport { NavBar } from \"@/components/ui/ui-builder/internal/nav\";\nimport { ThemeProvider } from \"next-themes\";\nimport { Tabs, TabsList, TabsTrigger, TabsContent } from \"@/components/ui/tabs\";\nimport {\n ResizablePanel,\n ResizableHandle,\n ResizablePanelGroup,\n} from \"@/components/ui/resizable\";\nimport { Button } from \"@/components/ui/button\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useStore } from \"@/hooks/use-store\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport {\n ComponentRegistry,\n ComponentLayer,\n Variable\n} from \"@/components/ui/ui-builder/types\";\nimport { TailwindThemePanel } from \"@/components/ui/ui-builder/internal/tailwind-theme-panel\";\nimport { ConfigPanel } from \"@/components/ui/ui-builder/internal/config-panel\";\nimport { VariablesPanel } from \"@/components/ui/ui-builder/internal/variables-panel\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\n\n/**\n * TabsContentConfig defines the structure for the content of the page config panel tabs.\n */\nexport interface TabsContentConfig {\n layers: { title: string; content: React.ReactNode };\n appearance?: { title: string; content: React.ReactNode };\n data?: { title: string; content: React.ReactNode };\n}\n\n/**\n * PanelConfig defines the configuration for the main panels in the UI Builder.\n */\ninterface PanelConfig {\n navBar?: React.ReactNode;\n pageConfigPanel?: React.ReactNode;\n pageConfigPanelTabsContent?: TabsContentConfig;\n editorPanel?: React.ReactNode;\n propsPanel?: React.ReactNode;\n}\n\n/**\n * UIBuilderProps defines the props for the UIBuilder component.\n */\ninterface UIBuilderProps {\n initialLayers?: ComponentLayer[];\n onChange?: (pages: ComponentLayer[]) => void;\n initialVariables?: Variable[];\n onVariablesChange?: (variables: Variable[]) => void;\n componentRegistry: ComponentRegistry;\n panelConfig?: PanelConfig;\n persistLayerStore?: boolean;\n allowVariableEditing?: boolean;\n allowPagesCreation?: boolean;\n allowPagesDeletion?: boolean;\n}\n\n/**\n * UIBuilder component manages the initialization of editor and layer stores, and renders the serializable layout.\n *\n * @param {UIBuilderProps} props - The props for the UIBuilder component.\n * @returns {JSX.Element} The UIBuilder component wrapped in a ThemeProvider.\n */\nconst UIBuilder = ({\n initialLayers,\n onChange,\n initialVariables,\n onVariablesChange,\n componentRegistry,\n panelConfig: userPanelConfig,\n persistLayerStore = true,\n allowVariableEditing = true,\n allowPagesCreation = true,\n allowPagesDeletion = true,\n}: UIBuilderProps) => {\n const layerStore = useStore(useLayerStore, (state) => state);\n const editorStore = useStore(useEditorStore, (state) => state);\n\n const [editorStoreInitialized, setEditorStoreInitialized] = useState(false);\n const [layerStoreInitialized, setLayerStoreInitialized] = useState(false);\n\n const memoizedDefaultTabsContent = useMemo(() => defaultConfigTabsContent(), []);\n\n const currentPanelConfig = useMemo(() => {\n const effectiveTabsContent = userPanelConfig?.pageConfigPanelTabsContent || memoizedDefaultTabsContent;\n const defaultPanels = getDefaultPanelConfigValues(true, effectiveTabsContent);\n\n return {\n navBar: userPanelConfig?.navBar ?? defaultPanels.navBar,\n pageConfigPanel: userPanelConfig?.pageConfigPanel ?? defaultPanels.pageConfigPanel,\n editorPanel: userPanelConfig?.editorPanel ?? defaultPanels.editorPanel,\n propsPanel: userPanelConfig?.propsPanel ?? defaultPanels.propsPanel,\n };\n }, [userPanelConfig, memoizedDefaultTabsContent]);\n\n // Effect 1: Initialize Editor Store with registry and page form props\n useEffect(() => {\n if (editorStore && componentRegistry && !editorStoreInitialized) {\n editorStore.initialize(componentRegistry, persistLayerStore, allowPagesCreation, allowPagesDeletion, allowVariableEditing);\n setEditorStoreInitialized(true);\n }\n }, [\n editorStore,\n componentRegistry,\n editorStoreInitialized,\n persistLayerStore,\n allowPagesCreation,\n allowPagesDeletion,\n allowVariableEditing,\n ]);\n\n // Effect 2: Conditionally initialize Layer Store *after* Editor Store is initialized\n useEffect(() => {\n if (layerStore && editorStore) {\n if (initialLayers && !layerStoreInitialized) {\n layerStore.initialize(initialLayers, undefined, undefined, initialVariables);\n setLayerStoreInitialized(true);\n const { clear } = useLayerStore.temporal.getState();\n clear();\n } else {\n setLayerStoreInitialized(true);\n }\n }\n }, [\n layerStore,\n editorStore,\n componentRegistry,\n initialLayers,\n initialVariables,\n layerStoreInitialized,\n ]);\n\n // Effect 3: Handle onChange callback when pages change\n useEffect(() => {\n if (onChange && layerStore?.pages && layerStoreInitialized) {\n onChange(layerStore.pages);\n }\n }, [layerStore?.pages, onChange, layerStoreInitialized]);\n\n // Effect 4: Handle onVariablesChange callback when variables change\n useEffect(() => {\n if (onVariablesChange && layerStore?.variables && layerStoreInitialized) {\n onVariablesChange(layerStore.variables);\n }\n }, [layerStore?.variables, onVariablesChange, layerStoreInitialized]);\n\n const isLoading = !layerStoreInitialized || !editorStoreInitialized;\n const layout = isLoading ? (\n \n ) : (\n \n );\n\n return (\n \n \n {layout}\n \n \n );\n};\n\nfunction MainLayout({ panelConfig }: { panelConfig: PanelConfig }) {\n const mainPanels = useMemo(\n () => [\n {\n title: \"Page Config\",\n content: panelConfig.pageConfigPanel,\n defaultSize: 25,\n },\n {\n title: \"UI Editor\",\n content: panelConfig.editorPanel,\n defaultSize: 50,\n },\n {\n title: \"Props\",\n content: panelConfig.propsPanel,\n defaultSize: 25,\n },\n ],\n [panelConfig]\n );\n\n const [selectedPanel, setSelectedPanel] = useState(mainPanels[1]);\n\n const handlePanelClickById = useCallback((e: React.MouseEvent) => {\n const panelIndex = parseInt(e.currentTarget.dataset.panelIndex || \"0\");\n setSelectedPanel(mainPanels[panelIndex]);\n }, [mainPanels]);\n\n return (\n \n {panelConfig.navBar}\n {/* Desktop Layout */}\n
\n \n {mainPanels.map((panel, index) => (\n \n {index > 0 && }\n \n {panel.content}\n \n \n ))}\n \n
\n {/* Mobile Layout */}\n
\n {selectedPanel.content}\n
\n
\n {mainPanels.map((panel, index) => (\n \n {panel.title}\n \n ))}\n
\n
\n
\n
\n );\n}\n\n/**\n * PageConfigPanel renders a tabbed panel for page configuration, including layers, appearance, and variables tabs.\n *\n * @param {object} props\n * @param {string} props.className - The class name for the panel container.\n * @param {TabsContentConfig} props.tabsContent - The content for the tabs.\n * @returns {JSX.Element} The page config panel with tabs.\n */\nexport function PageConfigPanel({\n className,\n tabsContent,\n}: {\n className: string;\n tabsContent: TabsContentConfig;\n}) {\n const { layers, appearance, data } = tabsContent;\n const tabCount = 1 + (appearance ? 1 : 0) + (data ? 1 : 0);\n \n return (\n \n \n {layers.title} \n {appearance && {appearance.title} }\n {data && {data.title} }\n \n \n {layers.content}\n \n {appearance && (\n \n {appearance.content}\n \n )}\n {data && (\n \n {data.content}\n \n )}\n \n );\n}\n\n/**\n * Returns the default tab content configuration for the page config panel.\n *\n * @param {boolean} editVariables - Whether to allow editing variables.\n * @returns {TabsContentConfig} The default tabs content configuration.\n */\nexport function defaultConfigTabsContent() {\n return {\n layers: { title: \"Layers\", content: },\n appearance: { title: \"Appearance\", content: (\n \n \n \n
\n ),\n },\n data: { title: \"Data\", content: }\n }\n}\n\n/**\n * LoadingSkeleton renders a skeleton UI while the builder is initializing.\n *\n * @returns {JSX.Element} The loading skeleton.\n */\nexport function LoadingSkeleton() {\n return (\n \n );\n}\n\n/**\n * Returns the default panel configuration values for the UI Builder.\n *\n * @param {boolean} useCanvas - Whether to use the canvas editor.\n * @param {TabsContentConfig} tabsContent - The content for the page config panel tabs.\n * @returns {PanelConfig} The default panel configuration.\n */\nexport const getDefaultPanelConfigValues = (useCanvas: boolean, tabsContent: TabsContentConfig) => {\n return {\n navBar: ,\n pageConfigPanel: (\n \n ),\n editorPanel: (\n \n ),\n propsPanel: (\n \n ),\n };\n};\n\nexport default UIBuilder;\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/index.tsx"
},
@@ -130,13 +130,13 @@
},
{
"path": "components/ui/ui-builder/internal/variables-panel.tsx",
- "content": "\"use client\";\n\nimport React, { useCallback, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Trash2, Plus, Edit2, Check, X } from \"lucide-react\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { Variable } from \"@/components/ui/ui-builder/types\";\nimport { cn } from \"@/lib/utils\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\n\nconst EMPTY_OBJECT = {};\n\ninterface VariablesPanelProps {\n className?: string;\n editVariables?: boolean;\n}\n\nexport const VariablesPanel: React.FC = ({\n className,\n editVariables = true,\n}) => {\n const { variables, addVariable, updateVariable, removeVariable } =\n useLayerStore();\n const [isAdding, setIsAdding] = useState(false);\n const [editingId, setEditingId] = useState(null);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n\n const handleRemoveVariable = useCallback(\n (id: string) => {\n removeVariable(id);\n incrementRevision();\n },\n [removeVariable, incrementRevision]\n );\n\n const handleAddVariable = useCallback(\n (name: string, type: Variable[\"type\"], defaultValue: any) => {\n addVariable(name, type, defaultValue);\n incrementRevision();\n },\n [addVariable, incrementRevision]\n );\n\n const handleSetIsAdding = useCallback(() => {\n setIsAdding(true);\n }, []);\n\n const handleSetIsNotAdding = useCallback(() => {\n setIsAdding(false);\n }, []);\n\n const handleOnSave = useCallback(\n (id: string, updates: Partial>) => {\n updateVariable(id, updates);\n setEditingId(null);\n },\n [updateVariable]\n );\n\n const handleOnCancel = useCallback(() => {\n setEditingId(null);\n }, []);\n\n const handleOnDelete = useCallback(\n (id: string) => {\n removeVariable(id);\n incrementRevision();\n },\n [removeVariable, incrementRevision]\n );\n\n return (\n \n
\n
Variables \n {editVariables && (\n
\n \n Add Variable\n \n )}\n
\n\n {isAdding && editVariables && (\n
\n )}\n\n
\n {variables.map((variable) => (\n \n ))}\n
\n\n {variables.length === 0 && !isAdding && (\n
\n {editVariables\n ? 'No variables defined. Click \"Add Variable\" to create one.'\n : \"No variables defined.\"}\n
\n )}\n
\n );\n};\n\ninterface AddVariableFormProps {\n onSave: (name: string, type: Variable[\"type\"], defaultValue: any) => void;\n onCancel: () => void;\n}\n\nconst AddVariableForm: React.FC = ({\n onSave,\n onCancel,\n}) => {\n const [name, setName] = useState(\"\");\n const [type, setType] = useState(\"string\");\n const [defaultValue, setDefaultValue] = useState(\"\");\n const [errors, setErrors] = useState<{\n name?: string;\n defaultValue?: string;\n }>(EMPTY_OBJECT);\n\n const validateForm = useCallback(() => {\n const newErrors: { name?: string; defaultValue?: string } = {};\n\n if (!name.trim()) {\n newErrors.name = \"Name is required\";\n }\n\n if (!defaultValue.trim()) {\n newErrors.defaultValue = \"Preview value is required\";\n }\n\n setErrors(newErrors);\n return Object.keys(newErrors).length === 0;\n }, [name, defaultValue]);\n\n const handleSave = useCallback(() => {\n if (!validateForm()) return;\n\n let parsedValue: any = defaultValue;\n try {\n if (type === \"number\") {\n parsedValue = parseFloat(defaultValue) || 0;\n } else if (type === \"boolean\") {\n parsedValue = defaultValue.toLowerCase() === \"true\";\n }\n } catch (e) {\n // Keep as string if parsing fails\n }\n\n onSave(name, type, parsedValue);\n }, [name, type, defaultValue, onSave, validateForm]);\n\n const handleNameChange = useCallback(\n (e: React.ChangeEvent) => {\n setName(e.target.value);\n if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));\n },\n [errors.name]\n );\n\n const handleTypeChange = useCallback(\n (value: string) => setType(value as Variable[\"type\"]),\n []\n );\n\n const handleDefaultValueChange = useCallback(\n (e: React.ChangeEvent) => {\n setDefaultValue(e.target.value);\n if (errors.defaultValue)\n setErrors((prev) => ({ ...prev, defaultValue: undefined }));\n },\n [errors.defaultValue]\n );\n\n return (\n \n \n Add New Variable \n \n \n \n
\n Name * \n \n
\n {errors.name && (\n
{errors.name}
\n )}\n
\n\n \n Type \n \n \n \n \n \n String \n Number \n Boolean \n \n \n
\n\n \n
\n Preview Value * \n \n
\n {errors.defaultValue && (\n
{errors.defaultValue}
\n )}\n
\n\n \n \n \n Save\n \n \n \n Cancel\n \n
\n \n \n );\n};\n\ninterface VariableCardProps {\n variable: Variable;\n isEditing: boolean;\n onEdit: (id: string) => void;\n onSave: (id: string, updates: Partial>) => void;\n onCancel: () => void;\n onDelete: (id: string) => void;\n editVariables: boolean;\n}\n\nconst VariableCard: React.FC = ({\n variable,\n isEditing,\n onEdit,\n onSave,\n onCancel,\n onDelete,\n editVariables,\n}) => {\n const [name, setName] = useState(variable.name);\n const [type, setType] = useState(variable.type);\n const [defaultValue, setDefaultValue] = useState(\n String(variable.defaultValue)\n );\n const [errors, setErrors] = useState<{\n name?: string;\n defaultValue?: string;\n }>(EMPTY_OBJECT);\n\n const validateForm = useCallback(() => {\n const newErrors: { name?: string; defaultValue?: string } = {};\n\n if (!name.trim()) {\n newErrors.name = \"Name is required\";\n }\n\n if (!defaultValue.trim()) {\n newErrors.defaultValue = \"Preview value is required\";\n }\n\n setErrors(newErrors);\n return Object.keys(newErrors).length === 0;\n }, [name, defaultValue]);\n\n const handleSave = useCallback(() => {\n if (!validateForm()) return;\n\n let parsedValue: any = defaultValue;\n try {\n if (type === \"number\") {\n parsedValue = parseFloat(defaultValue) || 0;\n } else if (type === \"boolean\") {\n parsedValue = defaultValue.toLowerCase() === \"true\";\n }\n } catch (e) {\n // Keep as string if parsing fails\n }\n\n onSave(variable.id, { name, type, defaultValue: parsedValue });\n }, [name, type, defaultValue, onSave, validateForm, variable.id]);\n\n const handleNameChange = useCallback(\n (e: React.ChangeEvent) => {\n setName(e.target.value);\n if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));\n },\n [errors.name]\n );\n\n const handleTypeChange = useCallback(\n (value: string) => setType(value as Variable[\"type\"]),\n []\n );\n\n const handleDefaultValueChange = useCallback(\n (e: React.ChangeEvent) => {\n setDefaultValue(e.target.value);\n if (errors.defaultValue)\n setErrors((prev) => ({ ...prev, defaultValue: undefined }));\n },\n [errors.defaultValue]\n );\n\n const handleOnEdit = useCallback(() => {\n onEdit(variable.id);\n }, [onEdit, variable.id]);\n\n const handleOnDelete = useCallback(() => {\n onDelete(variable.id);\n }, [onDelete, variable.id]);\n\n if (isEditing) {\n return (\n \n \n \n
\n Name * \n \n
\n {errors.name && (\n
{errors.name}
\n )}\n
\n\n \n Type \n \n \n \n \n \n String \n Number \n Boolean \n \n \n
\n\n \n
\n Preview Value * \n \n
\n {errors.defaultValue && (\n
{errors.defaultValue}
\n )}\n
\n\n \n \n \n Save\n \n \n \n Cancel\n \n
\n \n \n );\n }\n\n return (\n \n \n \n
\n
\n {variable.name} \n \n {variable.type}\n \n
\n
\n {String(variable.defaultValue)}\n
\n
\n {editVariables && (\n
\n \n \n \n \n \n \n
\n )}\n
\n \n \n );\n};\n\nfunction getPlaceholderForType(type: Variable[\"type\"]): string {\n switch (type) {\n case \"string\":\n return \"Enter text...\";\n case \"number\":\n return \"0\";\n case \"boolean\":\n return \"true\";\n default:\n return \"\";\n }\n}\n",
+ "content": "\"use client\";\n\nimport React, { useCallback, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Trash2, Plus, Edit2, Check, X } from \"lucide-react\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { Variable } from \"@/components/ui/ui-builder/types\";\nimport { cn } from \"@/lib/utils\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\n\nconst EMPTY_OBJECT = {};\n\ninterface VariablesPanelProps {\n className?: string;\n}\n\nexport const VariablesPanel: React.FC = ({\n className,\n}) => {\n const { variables, addVariable, updateVariable, removeVariable } =\n useLayerStore();\n const [isAdding, setIsAdding] = useState(false);\n const [editingId, setEditingId] = useState(null);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n const allowVariableEditing = useEditorStore((state) => state.allowVariableEditing);\n\n const handleAddVariable = useCallback(\n (name: string, type: Variable[\"type\"], defaultValue: any) => {\n addVariable(name, type, defaultValue);\n incrementRevision();\n },\n [addVariable, incrementRevision]\n );\n\n const handleSetIsAdding = useCallback(() => {\n setIsAdding(true);\n }, []);\n\n const handleSetIsNotAdding = useCallback(() => {\n setIsAdding(false);\n }, []);\n\n const handleOnSave = useCallback(\n (id: string, updates: Partial>) => {\n updateVariable(id, updates);\n setEditingId(null);\n },\n [updateVariable]\n );\n\n const handleOnCancel = useCallback(() => {\n setEditingId(null);\n }, []);\n\n const handleOnDelete = useCallback(\n (id: string) => {\n removeVariable(id);\n incrementRevision();\n },\n [removeVariable, incrementRevision]\n );\n\n return (\n \n
\n
Variables \n {allowVariableEditing && (\n
\n \n Add Variable\n \n )}\n
\n\n {isAdding && allowVariableEditing && (\n
\n )}\n\n
\n {variables.map((variable) => (\n \n ))}\n
\n\n {variables.length === 0 && !isAdding && (\n
\n {allowVariableEditing\n ? 'No variables defined. Click \"Add Variable\" to create one.'\n : \"No variables defined.\"}\n
\n )}\n
\n );\n};\n\ninterface AddVariableFormProps {\n onSave: (name: string, type: Variable[\"type\"], defaultValue: any) => void;\n onCancel: () => void;\n}\n\nconst AddVariableForm: React.FC = ({\n onSave,\n onCancel,\n}) => {\n const [name, setName] = useState(\"\");\n const [type, setType] = useState(\"string\");\n const [defaultValue, setDefaultValue] = useState(\"\");\n const [errors, setErrors] = useState<{\n name?: string;\n defaultValue?: string;\n }>(EMPTY_OBJECT);\n\n const validateForm = useCallback(() => {\n const newErrors: { name?: string; defaultValue?: string } = {};\n\n if (!name.trim()) {\n newErrors.name = \"Name is required\";\n }\n\n if (!defaultValue.trim()) {\n newErrors.defaultValue = \"Preview value is required\";\n }\n\n setErrors(newErrors);\n return Object.keys(newErrors).length === 0;\n }, [name, defaultValue]);\n\n const handleSave = useCallback(() => {\n if (!validateForm()) return;\n\n let parsedValue: any = defaultValue;\n try {\n if (type === \"number\") {\n parsedValue = parseFloat(defaultValue) || 0;\n } else if (type === \"boolean\") {\n parsedValue = defaultValue.toLowerCase() === \"true\";\n }\n } catch (e) {\n // Keep as string if parsing fails\n }\n\n onSave(name, type, parsedValue);\n }, [name, type, defaultValue, onSave, validateForm]);\n\n const handleNameChange = useCallback(\n (e: React.ChangeEvent) => {\n setName(e.target.value);\n if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));\n },\n [errors.name]\n );\n\n const handleTypeChange = useCallback(\n (value: string) => setType(value as Variable[\"type\"]),\n []\n );\n\n const handleDefaultValueChange = useCallback(\n (e: React.ChangeEvent) => {\n setDefaultValue(e.target.value);\n if (errors.defaultValue)\n setErrors((prev) => ({ ...prev, defaultValue: undefined }));\n },\n [errors.defaultValue]\n );\n\n return (\n \n \n Add New Variable \n \n \n \n
\n Name * \n \n
\n {errors.name && (\n
{errors.name}
\n )}\n
\n\n \n Type \n \n \n \n \n \n String \n Number \n Boolean \n \n \n
\n\n \n
\n Preview Value * \n \n
\n {errors.defaultValue && (\n
{errors.defaultValue}
\n )}\n
\n\n \n \n \n Save\n \n \n \n Cancel\n \n
\n \n \n );\n};\n\ninterface VariableCardProps {\n variable: Variable;\n isEditing: boolean;\n onEdit: (id: string) => void;\n onSave: (id: string, updates: Partial>) => void;\n onCancel: () => void;\n onDelete: (id: string) => void;\n editVariables: boolean;\n}\n\nconst VariableCard: React.FC = ({\n variable,\n isEditing,\n onEdit,\n onSave,\n onCancel,\n onDelete,\n editVariables,\n}) => {\n const [name, setName] = useState(variable.name);\n const [type, setType] = useState(variable.type);\n const [defaultValue, setDefaultValue] = useState(\n String(variable.defaultValue)\n );\n const [errors, setErrors] = useState<{\n name?: string;\n defaultValue?: string;\n }>(EMPTY_OBJECT);\n\n const validateForm = useCallback(() => {\n const newErrors: { name?: string; defaultValue?: string } = {};\n\n if (!name.trim()) {\n newErrors.name = \"Name is required\";\n }\n\n if (!defaultValue.trim()) {\n newErrors.defaultValue = \"Preview value is required\";\n }\n\n setErrors(newErrors);\n return Object.keys(newErrors).length === 0;\n }, [name, defaultValue]);\n\n const handleSave = useCallback(() => {\n if (!validateForm()) return;\n\n let parsedValue: any = defaultValue;\n try {\n if (type === \"number\") {\n parsedValue = parseFloat(defaultValue) || 0;\n } else if (type === \"boolean\") {\n parsedValue = defaultValue.toLowerCase() === \"true\";\n }\n } catch (e) {\n // Keep as string if parsing fails\n }\n\n onSave(variable.id, { name, type, defaultValue: parsedValue });\n }, [name, type, defaultValue, onSave, validateForm, variable.id]);\n\n const handleNameChange = useCallback(\n (e: React.ChangeEvent) => {\n setName(e.target.value);\n if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));\n },\n [errors.name]\n );\n\n const handleTypeChange = useCallback(\n (value: string) => setType(value as Variable[\"type\"]),\n []\n );\n\n const handleDefaultValueChange = useCallback(\n (e: React.ChangeEvent) => {\n setDefaultValue(e.target.value);\n if (errors.defaultValue)\n setErrors((prev) => ({ ...prev, defaultValue: undefined }));\n },\n [errors.defaultValue]\n );\n\n const handleOnEdit = useCallback(() => {\n onEdit(variable.id);\n }, [onEdit, variable.id]);\n\n const handleOnDelete = useCallback(() => {\n onDelete(variable.id);\n }, [onDelete, variable.id]);\n\n if (isEditing) {\n return (\n \n \n \n
\n Name * \n \n
\n {errors.name && (\n
{errors.name}
\n )}\n
\n\n \n Type \n \n \n \n \n \n String \n Number \n Boolean \n \n \n
\n\n \n
\n Preview Value * \n \n
\n {errors.defaultValue && (\n
{errors.defaultValue}
\n )}\n
\n\n \n \n \n Save\n \n \n \n Cancel\n \n
\n \n \n );\n }\n\n return (\n \n \n \n
\n
\n {variable.name} \n \n {variable.type}\n \n
\n
\n {String(variable.defaultValue)}\n
\n
\n {editVariables && (\n
\n \n \n \n \n \n \n
\n )}\n
\n \n \n );\n};\n\nfunction getPlaceholderForType(type: Variable[\"type\"]): string {\n switch (type) {\n case \"string\":\n return \"Enter text...\";\n case \"number\":\n return \"0\";\n case \"boolean\":\n return \"true\";\n default:\n return \"\";\n }\n}\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/internal/variables-panel.tsx"
},
{
"path": "components/ui/ui-builder/internal/tree-row-node.tsx",
- "content": "import React, { useCallback, useState, memo, useMemo } from \"react\";\nimport { NodeAttrs } from \"he-tree-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n ChevronDown,\n ChevronRight,\n GripVertical,\n MoreVertical,\n Plus,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { hasLayerChildren } from \"@/lib/ui-builder/store/layer-utils\";\nimport { ComponentLayer } from \"@/components/ui/ui-builder/types\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { AddComponentsPopover } from \"@/components/ui/ui-builder/internal/add-component-popover\";\nimport { NameEdit } from \"@/components/ui/ui-builder/internal/name-edit\";\n\ninterface TreeRowNodeProps {\n node: ComponentLayer;\n id: number | string;\n level: number;\n open: boolean;\n draggable: boolean;\n onToggle: (id: number | string, open: boolean) => void;\n nodeAttributes: NodeAttrs;\n selectedLayerId: string | null;\n selectLayer: (id: string) => void;\n removeLayer: (id: string) => void;\n duplicateLayer: (id: string) => void;\n updateLayer: (\n id: string,\n update: Partial,\n options?: {\n name?: string;\n children?: ComponentLayer[];\n }\n ) => void;\n}\n\nexport const TreeRowNode: React.FC = memo(({\n node,\n id,\n level,\n open,\n draggable,\n onToggle,\n nodeAttributes,\n selectedLayerId,\n selectLayer,\n removeLayer,\n duplicateLayer,\n updateLayer,\n}) => {\n const [isRenaming, setIsRenaming] = useState(false);\n\n const [popoverOrMenuOpen, setPopoverOrMenuOpen] = useState(false);\n\n const handleOpen = useCallback(() => {\n onToggle(id, !open);\n }, [id, open, onToggle]);\n\n const handleSelect = useCallback(() => {\n selectLayer(node.id);\n }, [node.id, selectLayer]);\n\n const handleRemove = useCallback(() => {\n removeLayer(node.id);\n }, [node.id, removeLayer]);\n\n const handleDuplicate = useCallback(() => {\n duplicateLayer(node.id);\n }, [node.id, duplicateLayer]);\n\n const handleRenameClick = useCallback(() => {\n setIsRenaming(true);\n }, []);\n\n const handleSaveRename = useCallback(\n (newName: string) => {\n updateLayer(node.id, {}, { name: newName });\n setIsRenaming(false);\n },\n [node.id, updateLayer]\n );\n\n const handleCancelRename = useCallback(() => {\n setIsRenaming(false);\n }, []);\n\n const { key, ...rest } = nodeAttributes;\n\n if (!node) {\n return null;\n }\n\n return (\n \n
\n\n {hasLayerChildren(node) && node.children.length > 0 ? (\n
\n {open ? (\n \n ) : (\n \n )}\n \n ) : (\n
\n )}\n\n {isRenaming ? (\n
\n ) : (\n
\n \n \n
\n {node.name}\n \n )}\n {hasLayerChildren(node) && (\n
\n \n \n Add component \n \n \n )}\n
\n \n \n \n \n \n \n \n Rename\n \n \n Duplicate\n \n Remove \n \n \n
\n );\n});\n\nTreeRowNode.displayName = \"TreeRowNode\";\n\nconst RowOffset = ({ level }: { level: number }) => {\n\n const style = useMemo(() => ({\n width: level * 20,\n }), [level]);\n \n\n const arr = useMemo(() => Array.from({ length: level }), [level]);\n\n return (\n \n {arr.map((_, index) => (\n
\n ))}\n
\n );\n};\n\nexport const TreeRowPlaceholder: React.FC<\n Pick\n> = ({ nodeAttributes }) => {\n const { key, ...rest } = nodeAttributes;\n return (\n \n );\n};\n",
+ "content": "import React, { useCallback, useState, memo, useMemo } from \"react\";\nimport { NodeAttrs } from \"he-tree-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n ChevronDown,\n ChevronRight,\n GripVertical,\n MoreVertical,\n Plus,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { hasLayerChildren } from \"@/lib/ui-builder/store/layer-utils\";\nimport { ComponentLayer } from \"@/components/ui/ui-builder/types\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { AddComponentsPopover } from \"@/components/ui/ui-builder/internal/add-component-popover\";\nimport { NameEdit } from \"@/components/ui/ui-builder/internal/name-edit\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\n\ninterface TreeRowNodeProps {\n node: ComponentLayer;\n id: number | string;\n level: number;\n open: boolean;\n draggable: boolean;\n onToggle: (id: number | string, open: boolean) => void;\n nodeAttributes: NodeAttrs;\n selectedLayerId: string | null;\n selectLayer: (id: string) => void;\n removeLayer: (id: string) => void;\n duplicateLayer: (id: string) => void;\n updateLayer: (\n id: string,\n update: Partial,\n options?: {\n name?: string;\n children?: ComponentLayer[];\n }\n ) => void;\n}\n\nexport const TreeRowNode: React.FC = memo(({\n node,\n id,\n level,\n open,\n draggable,\n onToggle,\n nodeAttributes,\n selectedLayerId,\n selectLayer,\n removeLayer,\n duplicateLayer,\n updateLayer,\n}) => {\n const [isRenaming, setIsRenaming] = useState(false);\n\n const [popoverOrMenuOpen, setPopoverOrMenuOpen] = useState(false);\n\n const isPage = useLayerStore((state) => state.isLayerAPage(node.id));\n\n const allowPagesCreation = useEditorStore(\n (state) => state.allowPagesCreation\n );\n const allowPagesDeletion = useEditorStore(\n (state) => state.allowPagesDeletion\n );\n\n const handleOpen = useCallback(() => {\n onToggle(id, !open);\n }, [id, open, onToggle]);\n\n const handleSelect = useCallback(() => {\n selectLayer(node.id);\n }, [node.id, selectLayer]);\n\n const handleRemove = useCallback(() => {\n removeLayer(node.id);\n }, [node.id, removeLayer]);\n\n const handleDuplicate = useCallback(() => {\n duplicateLayer(node.id);\n }, [node.id, duplicateLayer]);\n\n const handleRenameClick = useCallback(() => {\n setIsRenaming(true);\n }, []);\n\n const handleSaveRename = useCallback(\n (newName: string) => {\n updateLayer(node.id, {}, { name: newName });\n setIsRenaming(false);\n },\n [node.id, updateLayer]\n );\n\n const handleCancelRename = useCallback(() => {\n setIsRenaming(false);\n }, []);\n\n const { key, ...rest } = nodeAttributes;\n\n if (!node) {\n return null;\n }\n\n return (\n \n
\n\n {hasLayerChildren(node) && node.children.length > 0 ? (\n
\n {open ? (\n \n ) : (\n \n )}\n \n ) : (\n
\n )}\n\n {isRenaming ? (\n
\n ) : (\n
\n \n \n
\n {node.name}\n \n )}\n {hasLayerChildren(node) && (\n
\n \n \n Add component \n \n \n )}\n
\n \n \n \n \n \n \n \n Rename\n \n {allowPagesCreation && (\n \n Duplicate\n \n )}\n {allowPagesDeletion && (\n Remove \n )}\n \n \n
\n );\n});\n\nTreeRowNode.displayName = \"TreeRowNode\";\n\nconst RowOffset = ({ level }: { level: number }) => {\n\n const style = useMemo(() => ({\n width: level * 20,\n }), [level]);\n \n\n const arr = useMemo(() => Array.from({ length: level }), [level]);\n\n return (\n \n {arr.map((_, index) => (\n
\n ))}\n
\n );\n};\n\nexport const TreeRowPlaceholder: React.FC<\n Pick\n> = ({ nodeAttributes }) => {\n const { key, ...rest } = nodeAttributes;\n return (\n \n );\n};\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/internal/tree-row-node.tsx"
},
@@ -160,19 +160,19 @@
},
{
"path": "components/ui/ui-builder/internal/render-utils.tsx",
- "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport React, { memo, Suspense, useMemo, useRef } from \"react\";\nimport isDeepEqual from \"fast-deep-equal\";\n\nimport { ClickableWrapper } from \"@/components/ui/ui-builder/internal/clickable-wrapper\";\nimport { ErrorBoundary } from \"react-error-boundary\";\n\nimport { ErrorFallback } from \"@/components/ui/ui-builder/internal/error-fallback\";\nimport { isPrimitiveComponent } from \"@/lib/ui-builder/store/editor-utils\";\nimport { hasLayerChildren } from \"@/lib/ui-builder/store/layer-utils\";\nimport { DevProfiler } from \"@/components/ui/ui-builder/internal/dev-profiler\";\nimport { ComponentRegistry, ComponentLayer, Variable } from '@/components/ui/ui-builder/types';\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { resolveVariableReferences } from \"@/lib/ui-builder/utils/variable-resolver\";\n\nexport interface EditorConfig {\n \n zIndex: number;\n totalLayers: number;\n selectedLayer: ComponentLayer;\n parentUpdated?: boolean;\n onSelectElement: (layerId: string) => void;\n handleDuplicateLayer: () => void;\n handleDeleteLayer: () => void;\n usingCanvas?: boolean;\n}\n\nexport const RenderLayer: React.FC<{\n layer: ComponentLayer;\n componentRegistry: ComponentRegistry;\n editorConfig?: EditorConfig;\n variables?: Variable[];\n variableValues?: Record;\n}> = memo(\n ({ layer, componentRegistry, editorConfig, variables, variableValues }) => {\n const storeVariables = useLayerStore((state) => state.variables);\n // Use provided variables or fall back to store variables\n const effectiveVariables = variables || storeVariables;\n const componentDefinition =\n componentRegistry[layer.type as keyof typeof componentRegistry];\n\n const prevLayer = useRef(layer);\n\n const infoData = useMemo(() => ({\n layerType: layer.type,\n layerId: layer.id,\n layerName: layer.name,\n availableComponents: Object.keys(componentRegistry),\n layer: layer\n }), [layer, componentRegistry]);\n\n\n // Resolve variable references in props\n const resolvedProps = resolveVariableReferences(layer.props, effectiveVariables, variableValues);\n const childProps: Record = useMemo(() => ({ ...resolvedProps }), [resolvedProps]);\n \n // Memoize child editor config to avoid creating objects in JSX\n const childEditorConfig = useMemo(() => {\n return editorConfig\n ? { ...editorConfig, zIndex: editorConfig.zIndex + 1, parentUpdated: editorConfig.parentUpdated || !isDeepEqual(prevLayer.current, layer) }\n : undefined;\n }, [editorConfig, layer]);\n\n if (!componentDefinition) {\n console.error(\n `[UIBuilder] Component definition not found in registry:`, \n infoData\n );\n return null;\n }\n\n let Component: React.ElementType | undefined =\n componentDefinition.component;\n let isPrimitive = false;\n if (isPrimitiveComponent(componentDefinition)) {\n Component = layer.type as keyof JSX.IntrinsicElements;\n isPrimitive = true;\n }\n\n ;\n\n if (!Component) return null;\n\n \n if (hasLayerChildren(layer) && layer.children.length > 0) {\n childProps.children = layer.children.map((child) => (\n \n ));\n } else if (typeof layer.children === \"string\") {\n childProps.children = layer.children;\n }\n\n const WrappedComponent = isPrimitive ? (\n \n ) : (\n \n \n \n );\n\n if (!editorConfig) {\n return WrappedComponent;\n } else {\n const {\n zIndex,\n totalLayers,\n selectedLayer,\n onSelectElement,\n handleDuplicateLayer,\n handleDeleteLayer,\n usingCanvas,\n } = editorConfig;\n\n return (\n \n \n {WrappedComponent}\n \n \n );\n }\n },\n (prevProps, nextProps) => {\n if(nextProps.editorConfig?.parentUpdated) {\n return false;\n }\n const editorConfigEqual = isDeepEqual(\n prevProps.editorConfig?.selectedLayer?.id,\n nextProps.editorConfig?.selectedLayer?.id\n );\n const layerEqual = isDeepEqual(prevProps.layer, nextProps.layer);\n return editorConfigEqual && layerEqual;\n }\n);\n\nRenderLayer.displayName = \"RenderLayer\";\n\nconst ErrorSuspenseWrapper: React.FC<{\n id: string;\n children: React.ReactNode;\n}> = ({ children }) => {\n const loadingFallback = useMemo(() => , []);\n \n return (\n \n {children} \n \n );\n};\n\n\nconst LoadingComponent: React.FC = () => (\n Loading...
\n);\n\n",
+ "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport React, { memo, Suspense, useMemo, useRef } from \"react\";\nimport isDeepEqual from \"fast-deep-equal\";\n\nimport { ClickableWrapper } from \"@/components/ui/ui-builder/internal/clickable-wrapper\";\nimport { ErrorBoundary } from \"react-error-boundary\";\n\nimport { ErrorFallback } from \"@/components/ui/ui-builder/internal/error-fallback\";\nimport { isPrimitiveComponent } from \"@/lib/ui-builder/store/editor-utils\";\nimport { hasLayerChildren } from \"@/lib/ui-builder/store/layer-utils\";\nimport { DevProfiler } from \"@/components/ui/ui-builder/internal/dev-profiler\";\nimport { ComponentRegistry, ComponentLayer, Variable } from '@/components/ui/ui-builder/types';\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { resolveVariableReferences } from \"@/lib/ui-builder/utils/variable-resolver\";\n\nexport interface EditorConfig {\n \n zIndex: number;\n totalLayers: number;\n selectedLayer: ComponentLayer;\n parentUpdated?: boolean;\n onSelectElement: (layerId: string) => void;\n handleDuplicateLayer?: () => void;\n handleDeleteLayer?: () => void;\n usingCanvas?: boolean;\n}\n\nexport const RenderLayer: React.FC<{\n layer: ComponentLayer;\n componentRegistry: ComponentRegistry;\n editorConfig?: EditorConfig;\n variables?: Variable[];\n variableValues?: Record;\n}> = memo(\n ({ layer, componentRegistry, editorConfig, variables, variableValues }) => {\n const storeVariables = useLayerStore((state) => state.variables);\n // Use provided variables or fall back to store variables\n const effectiveVariables = variables || storeVariables;\n const componentDefinition =\n componentRegistry[layer.type as keyof typeof componentRegistry];\n\n const prevLayer = useRef(layer);\n\n const infoData = useMemo(() => ({\n layerType: layer.type,\n layerId: layer.id,\n layerName: layer.name,\n availableComponents: Object.keys(componentRegistry),\n layer: layer\n }), [layer, componentRegistry]);\n\n\n // Resolve variable references in props\n const resolvedProps = resolveVariableReferences(layer.props, effectiveVariables, variableValues);\n const childProps: Record = useMemo(() => ({ ...resolvedProps }), [resolvedProps]);\n \n // Memoize child editor config to avoid creating objects in JSX\n const childEditorConfig = useMemo(() => {\n return editorConfig\n ? { ...editorConfig, zIndex: editorConfig.zIndex + 1, parentUpdated: editorConfig.parentUpdated || !isDeepEqual(prevLayer.current, layer) }\n : undefined;\n }, [editorConfig, layer]);\n\n if (!componentDefinition) {\n console.error(\n `[UIBuilder] Component definition not found in registry:`, \n infoData\n );\n return null;\n }\n\n let Component: React.ElementType | undefined =\n componentDefinition.component;\n let isPrimitive = false;\n if (isPrimitiveComponent(componentDefinition)) {\n Component = layer.type as keyof JSX.IntrinsicElements;\n isPrimitive = true;\n }\n\n ;\n\n if (!Component) return null;\n\n \n if (hasLayerChildren(layer) && layer.children.length > 0) {\n childProps.children = layer.children.map((child) => (\n \n ));\n } else if (typeof layer.children === \"string\") {\n childProps.children = layer.children;\n }\n\n const WrappedComponent = isPrimitive ? (\n \n ) : (\n \n \n \n );\n\n if (!editorConfig) {\n return WrappedComponent;\n } else {\n const {\n zIndex,\n totalLayers,\n selectedLayer,\n onSelectElement,\n handleDuplicateLayer,\n handleDeleteLayer,\n usingCanvas,\n } = editorConfig;\n\n return (\n \n \n {WrappedComponent}\n \n \n );\n }\n },\n (prevProps, nextProps) => {\n if(nextProps.editorConfig?.parentUpdated) {\n return false;\n }\n const editorConfigEqual = isDeepEqual(\n prevProps.editorConfig?.selectedLayer?.id,\n nextProps.editorConfig?.selectedLayer?.id\n );\n const layerEqual = isDeepEqual(prevProps.layer, nextProps.layer);\n return editorConfigEqual && layerEqual;\n }\n);\n\nRenderLayer.displayName = \"RenderLayer\";\n\nconst ErrorSuspenseWrapper: React.FC<{\n id: string;\n children: React.ReactNode;\n}> = ({ children }) => {\n const loadingFallback = useMemo(() => , []);\n \n return (\n \n {children} \n \n );\n};\n\n\nconst LoadingComponent: React.FC = () => (\n Loading...
\n);\n\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/internal/render-utils.tsx"
},
{
"path": "components/ui/ui-builder/internal/props-panel.tsx",
- "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport React, { useCallback, useMemo } from \"react\";\nimport { z } from \"zod\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport {\n ComponentRegistry,\n ComponentLayer,\n} from \"@/components/ui/ui-builder/types\";\nimport { Button } from \"@/components/ui/button\";\nimport AutoForm from \"@/components/ui/auto-form\";\nimport { generateFieldOverrides } from \"@/lib/ui-builder/store/editor-utils\";\nimport { addDefaultValues } from \"@/lib/ui-builder/store/schema-utils\";\nimport { getBaseType } from \"@/components/ui/auto-form/utils\";\nimport { isVariableReference } from \"@/lib/ui-builder/utils/variable-resolver\";\nimport { resolveVariableReferences } from \"@/lib/ui-builder/utils/variable-resolver\";\n\ninterface PropsPanelProps {\n className?: string;\n}\n\nconst PropsPanel: React.FC = ({ className }) => {\n const selectedLayerId = useLayerStore((state) => state.selectedLayerId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const removeLayer = useLayerStore((state) => state.removeLayer);\n const duplicateLayer = useLayerStore((state) => state.duplicateLayer);\n const updateLayer = useLayerStore((state) => state.updateLayer);\n const addComponentLayer = useLayerStore((state) => state.addComponentLayer);\n const componentRegistry = useEditorStore((state) => state.registry);\n const selectedLayer = findLayerById(selectedLayerId);\n\n const handleAddComponentLayer = useCallback(\n (layerType: string, parentLayerId: string, addPosition?: number) => {\n addComponentLayer(layerType, parentLayerId, addPosition);\n },\n [addComponentLayer]\n );\n\n const handleDeleteLayer = useCallback(\n (layerId: string) => {\n removeLayer(layerId);\n },\n [removeLayer]\n );\n\n const handleDuplicateLayer = useCallback(() => {\n if (selectedLayer) {\n duplicateLayer(selectedLayer.id);\n }\n }, [selectedLayer, duplicateLayer]);\n\n const handleUpdateLayer = useCallback(\n (\n id: string,\n props: Record,\n rest?: Partial>\n ) => {\n updateLayer(id, props, rest);\n },\n [updateLayer]\n );\n\n //first check if selectedLayer.type is a valid key in componentRegistry\n if (\n selectedLayer &&\n !componentRegistry[selectedLayer.type as keyof typeof componentRegistry]\n ) {\n return null;\n }\n\n return (\n \n {selectedLayer && (\n <>\n
\n
\n Type: {selectedLayer.type.replaceAll(\"_\", \"\")}\n \n >\n )}\n\n {!selectedLayer && (\n <>\n
Component Properties \n
No component selected
\n >\n )}\n {selectedLayer && (\n
\n )}\n
\n );\n};\nPropsPanel.displayName = \"PropsPanel\";\nexport default PropsPanel;\n\ninterface ComponentPropsAutoFormProps {\n selectedLayerId: string;\n componentRegistry: ComponentRegistry;\n removeLayer: (id: string) => void;\n duplicateLayer: (id: string) => void;\n updateLayer: (\n id: string,\n props: Record,\n rest?: Partial>\n ) => void;\n addComponentLayer: (\n layerType: string,\n parentLayerId: string,\n addPosition?: number\n ) => void;\n}\n\nconst EMPTY_ZOD_SCHEMA = z.object({});\nconst EMPTY_FORM_VALUES = {};\n\nconst ComponentPropsAutoForm: React.FC = ({\n selectedLayerId,\n componentRegistry,\n removeLayer,\n duplicateLayer,\n updateLayer,\n addComponentLayer,\n}) => {\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const revisionCounter = useEditorStore((state) => state.revisionCounter);\n const selectedLayer = findLayerById(selectedLayerId) as ComponentLayer | undefined;\n \n // Retrieve the appropriate schema from componentRegistry\n const { schema } = useMemo(() => {\n if (selectedLayer && componentRegistry[selectedLayer.type as keyof typeof componentRegistry]) {\n return componentRegistry[selectedLayer.type as keyof typeof componentRegistry];\n }\n return { schema: EMPTY_ZOD_SCHEMA }; // Fallback schema\n }, [selectedLayer, componentRegistry]);\n\n const handleDeleteLayer = useCallback(() => {\n removeLayer(selectedLayerId);\n }, [removeLayer, selectedLayerId]);\n\n const handleDuplicateLayer = useCallback(() => {\n duplicateLayer(selectedLayerId);\n }, [duplicateLayer, selectedLayerId]);\n\n const onParsedValuesChange = useCallback(\n (parsedValues: z.infer & { children?: string | { layerType: string, addPosition: number } }) => {\n const { children, ...dataProps } = parsedValues;\n \n // Preserve variable references by merging with original props\n const preservedProps: Record = {};\n if (selectedLayer) {\n // Start with all original props to preserve any that aren't in the form update\n Object.assign(preservedProps, selectedLayer.props);\n \n // Then update only the props that came from the form, preserving variable references\n Object.keys(dataProps as Record).forEach(key => {\n const originalValue = selectedLayer.props[key];\n const newValue = (dataProps as Record)[key];\n const fieldDef = schema?.shape?.[key];\n const baseType = fieldDef ? getBaseType(fieldDef as z.ZodAny) : undefined;\n // If the original value was a variable reference, preserve it\n if (isVariableReference(originalValue)) {\n // Keep the variable reference - the form should not override variable bindings\n preservedProps[key] = originalValue;\n } else {\n // Handle date serialization\n if (baseType === z.ZodFirstPartyTypeKind.ZodDate && newValue instanceof Date) {\n preservedProps[key] = newValue.toISOString();\n } else {\n preservedProps[key] = newValue;\n }\n }\n });\n }\n \n if(typeof children === \"string\") {\n updateLayer(selectedLayerId, preservedProps, { children: children });\n }else if(children && children.layerType) {\n updateLayer(selectedLayerId, preservedProps, { children: selectedLayer?.children });\n addComponentLayer(children.layerType, selectedLayerId, children.addPosition)\n }else{\n updateLayer(selectedLayerId, preservedProps);\n }\n },\n [updateLayer, selectedLayerId, selectedLayer, addComponentLayer, schema]\n );\n\n // Prepare values for AutoForm, converting enum values to strings as select elements only accept string values\n const formValues = useMemo(() => {\n if (!selectedLayer) return EMPTY_FORM_VALUES;\n\n const variables = useLayerStore.getState().variables;\n \n // First resolve variable references to get display values\n const resolvedProps = resolveVariableReferences(selectedLayer.props, variables);\n \n const transformedProps: Record = {};\n const schemaShape = schema?.shape as z.ZodRawShape | undefined; // Get shape from the memoized schema\n \n if (schemaShape) {\n for (const [key, value] of Object.entries(resolvedProps)) {\n const fieldDef = schemaShape[key];\n if (fieldDef) {\n const baseType = getBaseType(fieldDef as z.ZodAny);\n if (baseType === z.ZodFirstPartyTypeKind.ZodEnum) {\n // Convert enum value to string if it's not already a string\n transformedProps[key] = typeof value === 'string' ? value : String(value);\n } else if (baseType === z.ZodFirstPartyTypeKind.ZodDate) {\n // Convert string to Date if necessary\n if (value instanceof Date) {\n transformedProps[key] = value;\n } else if (typeof value === 'string' || typeof value === 'number') {\n const date = new Date(value);\n transformedProps[key] = isNaN(date.getTime()) ? undefined : date;\n } else {\n transformedProps[key] = undefined;\n }\n } else {\n transformedProps[key] = value;\n }\n } else {\n transformedProps[key] = value;\n }\n }\n \n } else {\n // Fallback if schema shape isn't available: copy resolved props as is\n Object.assign(transformedProps, resolvedProps);\n }\n\n return { ...transformedProps, children: selectedLayer.children };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [selectedLayer, schema, revisionCounter]); // Include revisionCounter to detect undo/redo changes\n\n const autoFormSchema = useMemo(() => {\n return addDefaultValues(schema, formValues);\n }, [schema, formValues]);\n\n const autoFormFieldConfig = useMemo(() => {\n if (!selectedLayer) return undefined; // Or a default config if appropriate\n return generateFieldOverrides(componentRegistry, selectedLayer);\n }, [componentRegistry, selectedLayer]);\n\n // Create a unique key that changes when we need to force re-render the form\n // This includes selectedLayerId and revisionCounter to handle both layer changes and undo/redo\n const formKey = useMemo(() => {\n return `${selectedLayerId}-${revisionCounter}`;\n }, [selectedLayerId, revisionCounter]);\n\n if (\n !selectedLayer ||\n !componentRegistry[selectedLayer.type as keyof typeof componentRegistry]\n ) {\n return null;\n }\n\n return (\n \n \n Duplicate Component\n \n \n Delete Component\n \n \n );\n};\n\nComponentPropsAutoForm.displayName = \"ComponentPropsAutoForm\";\n\nconst nameForLayer = (layer: ComponentLayer) => {\n return layer.name || layer.type.replaceAll(\"_\", \"\");\n};\n\nconst Title = () => {\n const { selectedLayerId } = useLayerStore();\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const selectedLayer = findLayerById(selectedLayerId);\n return (\n \n {selectedLayer ? nameForLayer(selectedLayer) : \"\"} Properties\n \n );\n};\n",
+ "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport React, { useCallback, useMemo } from \"react\";\nimport { z } from \"zod\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport {\n ComponentRegistry,\n ComponentLayer,\n} from \"@/components/ui/ui-builder/types\";\nimport { Button } from \"@/components/ui/button\";\nimport AutoForm from \"@/components/ui/auto-form\";\nimport { generateFieldOverrides } from \"@/lib/ui-builder/store/editor-utils\";\nimport { addDefaultValues } from \"@/lib/ui-builder/store/schema-utils\";\nimport { getBaseType } from \"@/components/ui/auto-form/utils\";\nimport { isVariableReference } from \"@/lib/ui-builder/utils/variable-resolver\";\nimport { resolveVariableReferences } from \"@/lib/ui-builder/utils/variable-resolver\";\n\ninterface PropsPanelProps {\n className?: string;\n}\n\nconst PropsPanel: React.FC = ({ className }) => {\n const selectedLayerId = useLayerStore((state) => state.selectedLayerId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const removeLayer = useLayerStore((state) => state.removeLayer);\n const duplicateLayer = useLayerStore((state) => state.duplicateLayer);\n const updateLayer = useLayerStore((state) => state.updateLayer);\n const addComponentLayer = useLayerStore((state) => state.addComponentLayer);\n const componentRegistry = useEditorStore((state) => state.registry);\n const selectedLayer = findLayerById(selectedLayerId);\n\n const handleAddComponentLayer = useCallback(\n (layerType: string, parentLayerId: string, addPosition?: number) => {\n addComponentLayer(layerType, parentLayerId, addPosition);\n },\n [addComponentLayer]\n );\n\n const handleDeleteLayer = useCallback(\n (layerId: string) => {\n removeLayer(layerId);\n },\n [removeLayer]\n );\n\n const handleDuplicateLayer = useCallback(() => {\n if (selectedLayer) {\n duplicateLayer(selectedLayer.id);\n }\n }, [selectedLayer, duplicateLayer]);\n\n const handleUpdateLayer = useCallback(\n (\n id: string,\n props: Record,\n rest?: Partial>\n ) => {\n updateLayer(id, props, rest);\n },\n [updateLayer]\n );\n\n //first check if selectedLayer.type is a valid key in componentRegistry\n if (\n selectedLayer &&\n !componentRegistry[selectedLayer.type as keyof typeof componentRegistry]\n ) {\n return null;\n }\n\n return (\n \n {selectedLayer && (\n <>\n
\n
\n Type: {selectedLayer.type.replaceAll(\"_\", \"\")}\n \n >\n )}\n\n {!selectedLayer && (\n <>\n
Component Properties \n
No component selected
\n >\n )}\n {selectedLayer && (\n
\n )}\n
\n );\n};\nPropsPanel.displayName = \"PropsPanel\";\nexport default PropsPanel;\n\ninterface ComponentPropsAutoFormProps {\n selectedLayerId: string;\n componentRegistry: ComponentRegistry;\n removeLayer: (id: string) => void;\n duplicateLayer: (id: string) => void;\n updateLayer: (\n id: string,\n props: Record,\n rest?: Partial>\n ) => void;\n addComponentLayer: (\n layerType: string,\n parentLayerId: string,\n addPosition?: number\n ) => void;\n}\n\nconst EMPTY_ZOD_SCHEMA = z.object({});\nconst EMPTY_FORM_VALUES = {};\n\nconst ComponentPropsAutoForm: React.FC = ({\n selectedLayerId,\n componentRegistry,\n removeLayer,\n duplicateLayer,\n updateLayer,\n addComponentLayer,\n}) => {\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const revisionCounter = useEditorStore((state) => state.revisionCounter);\n const selectedLayer = findLayerById(selectedLayerId) as\n | ComponentLayer\n | undefined;\n const isPage = useLayerStore((state) => state.isLayerAPage(selectedLayerId));\n const allowPagesCreation = useEditorStore(\n (state) => state.allowPagesCreation\n );\n const allowPagesDeletion = useEditorStore(\n (state) => state.allowPagesDeletion\n );\n\n // Retrieve the appropriate schema from componentRegistry\n const { schema } = useMemo(() => {\n if (\n selectedLayer &&\n componentRegistry[selectedLayer.type as keyof typeof componentRegistry]\n ) {\n return componentRegistry[\n selectedLayer.type as keyof typeof componentRegistry\n ];\n }\n return { schema: EMPTY_ZOD_SCHEMA }; // Fallback schema\n }, [selectedLayer, componentRegistry]);\n\n const handleDeleteLayer = useCallback(() => {\n removeLayer(selectedLayerId);\n }, [removeLayer, selectedLayerId]);\n\n const handleDuplicateLayer = useCallback(() => {\n duplicateLayer(selectedLayerId);\n }, [duplicateLayer, selectedLayerId]);\n\n const onParsedValuesChange = useCallback(\n (\n parsedValues: z.infer & {\n children?: string | { layerType: string; addPosition: number };\n }\n ) => {\n const { children, ...dataProps } = parsedValues;\n\n // Preserve variable references by merging with original props\n const preservedProps: Record = {};\n if (selectedLayer) {\n // Start with all original props to preserve any that aren't in the form update\n Object.assign(preservedProps, selectedLayer.props);\n\n // Then update only the props that came from the form, preserving variable references\n Object.keys(dataProps as Record).forEach((key) => {\n const originalValue = selectedLayer.props[key];\n const newValue = (dataProps as Record)[key];\n const fieldDef = schema?.shape?.[key];\n const baseType = fieldDef\n ? getBaseType(fieldDef as z.ZodAny)\n : undefined;\n // If the original value was a variable reference, preserve it\n if (isVariableReference(originalValue)) {\n // Keep the variable reference - the form should not override variable bindings\n preservedProps[key] = originalValue;\n } else {\n // Handle date serialization\n if (\n baseType === z.ZodFirstPartyTypeKind.ZodDate &&\n newValue instanceof Date\n ) {\n preservedProps[key] = newValue.toISOString();\n } else {\n preservedProps[key] = newValue;\n }\n }\n });\n }\n\n if (typeof children === \"string\") {\n updateLayer(selectedLayerId, preservedProps, { children: children });\n } else if (children && children.layerType) {\n updateLayer(selectedLayerId, preservedProps, {\n children: selectedLayer?.children,\n });\n addComponentLayer(\n children.layerType,\n selectedLayerId,\n children.addPosition\n );\n } else {\n updateLayer(selectedLayerId, preservedProps);\n }\n },\n [updateLayer, selectedLayerId, selectedLayer, addComponentLayer, schema]\n );\n\n // Prepare values for AutoForm, converting enum values to strings as select elements only accept string values\n const formValues = useMemo(() => {\n if (!selectedLayer) return EMPTY_FORM_VALUES;\n\n const variables = useLayerStore.getState().variables;\n\n // First resolve variable references to get display values\n const resolvedProps = resolveVariableReferences(\n selectedLayer.props,\n variables\n );\n\n const transformedProps: Record = {};\n const schemaShape = schema?.shape as z.ZodRawShape | undefined; // Get shape from the memoized schema\n\n if (schemaShape) {\n for (const [key, value] of Object.entries(resolvedProps)) {\n const fieldDef = schemaShape[key];\n if (fieldDef) {\n const baseType = getBaseType(fieldDef as z.ZodAny);\n if (baseType === z.ZodFirstPartyTypeKind.ZodEnum) {\n // Convert enum value to string if it's not already a string\n transformedProps[key] =\n typeof value === \"string\" ? value : String(value);\n } else if (baseType === z.ZodFirstPartyTypeKind.ZodDate) {\n // Convert string to Date if necessary\n if (value instanceof Date) {\n transformedProps[key] = value;\n } else if (typeof value === \"string\" || typeof value === \"number\") {\n const date = new Date(value);\n transformedProps[key] = isNaN(date.getTime()) ? undefined : date;\n } else {\n transformedProps[key] = undefined;\n }\n } else {\n transformedProps[key] = value;\n }\n } else {\n transformedProps[key] = value;\n }\n }\n } else {\n // Fallback if schema shape isn't available: copy resolved props as is\n Object.assign(transformedProps, resolvedProps);\n }\n\n return { ...transformedProps, children: selectedLayer.children };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [selectedLayer, schema, revisionCounter]); // Include revisionCounter to detect undo/redo changes\n\n const autoFormSchema = useMemo(() => {\n return addDefaultValues(schema, formValues);\n }, [schema, formValues]);\n\n const autoFormFieldConfig = useMemo(() => {\n if (!selectedLayer) return undefined; // Or a default config if appropriate\n return generateFieldOverrides(componentRegistry, selectedLayer);\n }, [componentRegistry, selectedLayer]);\n\n // Create a unique key that changes when we need to force re-render the form\n // This includes selectedLayerId and revisionCounter to handle both layer changes and undo/redo\n const formKey = useMemo(() => {\n return `${selectedLayerId}-${revisionCounter}`;\n }, [selectedLayerId, revisionCounter]);\n\n if (\n !selectedLayer ||\n !componentRegistry[selectedLayer.type as keyof typeof componentRegistry]\n ) {\n return null;\n }\n\n return (\n \n {(!isPage || allowPagesCreation) && (\n \n Duplicate {isPage ? \"Page\" : \"Component\"}\n \n )}\n {(!isPage || allowPagesDeletion) && (\n \n Delete {isPage ? \"Page\" : \"Component\"}\n \n )}\n \n );\n};\n\nComponentPropsAutoForm.displayName = \"ComponentPropsAutoForm\";\n\nconst nameForLayer = (layer: ComponentLayer) => {\n return layer.name || layer.type.replaceAll(\"_\", \"\");\n};\n\nconst Title = () => {\n const { selectedLayerId } = useLayerStore();\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const selectedLayer = findLayerById(selectedLayerId);\n return (\n \n {selectedLayer ? nameForLayer(selectedLayer) : \"\"} Properties\n \n );\n};\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/internal/props-panel.tsx"
},
{
"path": "components/ui/ui-builder/internal/nav.tsx",
- "content": "\"use client\";\n\nimport { forwardRef, useCallback, useMemo, useState } from \"react\";\nimport {\n Eye,\n FileUp,\n Redo,\n Undo,\n SunIcon,\n MoonIcon,\n CheckIcon,\n X,\n PlusIcon,\n Monitor,\n Tablet,\n Smartphone,\n Maximize,\n MoreVertical, // Ensure this import is present\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogHeader,\n DialogOverlay,\n DialogPortal,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport LayerRenderer from \"@/components/ui/ui-builder/layer-renderer\";\nimport { useTheme } from \"next-themes\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n DropdownMenuSeparator,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n CommandShortcut,\n} from \"@/components/ui/command\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport { CodePanel } from \"@/components/ui/ui-builder/code-panel\";\nimport {\n EditorStore,\n useEditorStore,\n} from \"@/lib/ui-builder/store/editor-store\";\nimport {\n ComponentRegistry,\n ComponentLayer,\n} from \"@/components/ui/ui-builder/types\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport {\n KeyCombination,\n useKeyboardShortcuts,\n} from \"@/hooks/use-keyboard-shortcuts\";\nimport { useStore } from \"zustand\";\n\nconst Z_INDEX = 1000;\n\ninterface NavBarProps {\n useCanvas?: boolean;\n}\n\nexport function NavBar({ useCanvas }: NavBarProps) {\n const selectedPageId = useLayerStore((state) => state.selectedPageId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const componentRegistry = useEditorStore((state) => state.registry);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n\n // Fix: Subscribe to temporal state changes using useStoreWithEqualityFn\n const pastStates = useStore(\n useLayerStore.temporal,\n (state) => state.pastStates\n );\n const futureStates = useStore(\n useLayerStore.temporal,\n (state) => state.futureStates\n );\n const { undo, redo } = useLayerStore.temporal.getState();\n\n const page = findLayerById(selectedPageId) as ComponentLayer;\n\n const canUndo = !!pastStates.length;\n const canRedo = !!futureStates.length;\n\n // **Lifted State for Modals**\n const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);\n const [isExportModalOpen, setIsExportModalOpen] = useState(false);\n\n const handleUndo = useCallback(() => {\n undo();\n // Increment revision counter to trigger form revalidation\n incrementRevision();\n }, [undo, incrementRevision]);\n\n const handleRedo = useCallback(() => {\n redo();\n // Increment revision counter to trigger form revalidation\n incrementRevision();\n }, [redo, incrementRevision]);\n\n const keyCombinations = useMemo(\n () => [\n {\n keys: { metaKey: true, shiftKey: false },\n key: \"z\",\n handler: handleUndo,\n },\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"z\",\n handler: handleRedo,\n },\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"9\",\n handler: () => {\n const elements = document.querySelectorAll(\"*\");\n elements.forEach((element) => {\n element.classList.add(\"animate-spin\", \"origin-center\");\n });\n },\n },\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"0\",\n handler: () => {\n const elements = document.querySelectorAll(\"*\");\n elements.forEach((element) => {\n element.classList.remove(\"animate-spin\", \"origin-center\");\n });\n },\n },\n ],\n [handleUndo, handleRedo]\n );\n\n useKeyboardShortcuts(keyCombinations);\n\n const handleOpenPreview = useCallback(() => {\n setIsPreviewModalOpen(true);\n }, []);\n const handleOpenExport = useCallback(() => {\n setIsExportModalOpen(true);\n }, []);\n\n const style = useMemo(() => ({ zIndex: Z_INDEX }), []);\n\n return (\n \n
\n
\n UI Builder\n \n
\n
\n {useCanvas &&
}\n
\n\n
\n {/* Action Buttons for Larger Screens */}\n
\n\n
\n\n {/* Dropdown for Smaller Screens */}\n
\n
\n\n {/* **Dialogs Controlled by NavBar State** */}\n
\n
\n
\n );\n}\n\n/**\n * Reusable Action Buttons Component\n */\ninterface ActionButtonsProps {\n canUndo: boolean;\n canRedo: boolean;\n onUndo: () => void;\n onRedo: () => void;\n onOpenPreview: () => void;\n onOpenExport: () => void;\n}\n\nconst ActionButtons: React.FC = ({\n canUndo,\n canRedo,\n onUndo,\n onRedo,\n onOpenPreview,\n onOpenExport,\n}) => {\n return (\n <>\n \n \n \n Undo \n \n \n \n \n Undo\n \n ⌘Z\n \n \n \n\n \n \n \n Redo \n \n \n \n \n Redo\n \n ⌘+⇧+Z\n \n \n \n\n \n \n \n Preview \n \n \n \n \n Preview\n \n ⌘+⇧+P\n \n \n \n\n \n \n \n Export \n \n \n \n \n Export Code\n \n ⌘+⇧+E\n \n \n \n >\n );\n};\n\n/**\n * Dropdown containing Action Buttons for Smaller Screens\n */\ninterface ResponsiveDropdownProps {\n canUndo: boolean;\n canRedo: boolean;\n onUndo: () => void;\n onRedo: () => void;\n onOpenPreview: () => void;\n onOpenExport: () => void;\n}\n\nconst ResponsiveDropdown: React.FC = ({\n canUndo,\n canRedo,\n onUndo,\n onRedo,\n onOpenPreview,\n onOpenExport,\n}) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n return (\n \n \n \n Actions \n \n \n \n \n \n \n Undo\n ⌘Z \n \n \n \n Redo\n ⌘+⇧+Z \n \n \n \n \n Preview\n ⌘+⇧+P \n \n \n \n Export\n ⌘+⇧+E \n \n \n \n );\n};\n\n/**\n * Preview Dialog Component\n */\ninterface PreviewDialogProps {\n isOpen: boolean;\n onOpenChange: (open: boolean) => void;\n page: ComponentLayer;\n componentRegistry: ComponentRegistry;\n}\n\nconst PreviewDialog: React.FC = ({\n isOpen,\n onOpenChange,\n page,\n componentRegistry,\n}) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const shortcuts = useMemo(\n () => [\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"p\",\n handler: () => {\n onOpenChange(true);\n },\n },\n ],\n [onOpenChange]\n );\n\n useKeyboardShortcuts(shortcuts);\n\n return (\n \n \n \n \n \n Page Preview \n \n \n \n \n \n );\n};\n\n/**\n * Code Dialog Component\n */\ninterface CodeDialogProps {\n isOpen: boolean;\n onOpenChange: (open: boolean) => void;\n}\n\nconst CodeDialog: React.FC = ({ isOpen, onOpenChange }) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const shortcuts = useMemo(\n () => [\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"e\",\n handler: () => {\n onOpenChange(true);\n },\n },\n ],\n [onOpenChange]\n );\n\n useKeyboardShortcuts(shortcuts);\n\n return (\n \n \n \n \n Generated Code \n \n \n \n \n );\n};\n\nfunction ModeToggle() {\n const { setTheme } = useTheme();\n\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const handleSetLightTheme = useCallback(() => {\n setTheme(\"light\");\n }, [setTheme]);\n const handleSetDarkTheme = useCallback(() => {\n setTheme(\"dark\");\n }, [setTheme]);\n const handleSetSystemTheme = useCallback(() => {\n setTheme(\"system\");\n }, [setTheme]);\n\n return (\n \n \n \n \n \n \n \n Toggle theme \n \n \n \n Toggle theme \n \n \n Light \n Dark \n \n System\n \n \n \n );\n}\n\nfunction PagesPopover() {\n const { pages, selectedPageId, addPageLayer, selectPage } = useLayerStore();\n const [open, setOpen] = useState(false);\n const [inputValue, setInputValue] = useState(\"\");\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const [selectedPage, setSelectedPage] = useState(\n selectedPageId\n );\n const [textInputValue, setTextInputValue] = useState(\"\");\n\n const selectedPageData = useMemo(() => {\n return pages.find((page) => page.id === selectedPageId);\n }, [pages, selectedPageId]);\n\n const handleSelect = useCallback(\n (pageId: string) => {\n setSelectedPage(pageId);\n selectPage(pageId);\n setOpen(false);\n },\n [selectPage, setOpen]\n );\n\n const handleAddPageLayer = useCallback(\n (pageName: string) => {\n addPageLayer(pageName);\n setTextInputValue(\"\");\n },\n [addPageLayer, setTextInputValue]\n );\n\n const handleSubmit = useCallback(\n (e: React.FormEvent) => {\n e.preventDefault();\n handleAddPageLayer(textInputValue);\n },\n [handleAddPageLayer, textInputValue]\n );\n\n const handleTextInputChange = useCallback(\n (e: React.ChangeEvent) => {\n setTextInputValue(e.target.value);\n },\n [setTextInputValue]\n );\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n handleAddPageLayer(textInputValue);\n }\n },\n [handleAddPageLayer, textInputValue]\n );\n\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const textInputForm = (\n \n );\n return (\n \n
\n \n \n \n \n {selectedPageData?.name}\n \n \n \n Select page \n \n \n \n \n \n \n No pages found\n {textInputForm}\n \n {pages.map((page) => (\n \n ))}\n \n \n {textInputForm} \n \n \n \n \n \n
\n );\n}\n\nconst PageItem = ({\n selectedPageId,\n page,\n onSelect,\n}: {\n selectedPageId: string;\n page: ComponentLayer;\n onSelect: (pageId: string) => void;\n}) => {\n const handleSelect = useCallback(() => {\n onSelect(page.id);\n }, [onSelect, page.id]);\n\n return (\n \n {selectedPageId === page.id ? (\n \n ) : null}\n {page.name}\n \n );\n};\n\nconst DialogContentWithZIndex = forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, ...props }, ref) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n return (\n \n \n \n {children}\n \n \n Close \n \n \n \n );\n});\n\nDialogContentWithZIndex.displayName = \"DialogContentWithZIndex\";\n\nconst PreviewModeToggle = () => {\n const { previewMode, setPreviewMode } = useEditorStore();\n\n const handleSelect = useCallback((mode: EditorStore[\"previewMode\"]) => {\n setPreviewMode(mode);\n }, [setPreviewMode]);\n\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const previewModeIcon = useMemo(() => {\n return {\n mobile: ,\n tablet: ,\n desktop: ,\n responsive: ,\n }[previewMode];\n }, [previewMode]);\n\n const handleSelectMobile = useCallback(() => {\n handleSelect(\"mobile\");\n }, [handleSelect]);\n const handleSelectTablet = useCallback(() => {\n handleSelect(\"tablet\");\n }, [handleSelect]);\n const handleSelectDesktop = useCallback(() => {\n handleSelect(\"desktop\");\n }, [handleSelect]);\n const handleSelectResponsive = useCallback(() => {\n handleSelect(\"responsive\");\n }, [handleSelect]);\n\n return (\n \n \n \n \n \n {previewModeIcon}\n Select screen size \n \n \n \n Select screen size \n \n \n \n \n Mobile \n \n \n \n Tablet \n \n \n \n Desktop \n \n \n \n Responsive \n \n \n \n );\n};\n",
+ "content": "\"use client\";\n\nimport { forwardRef, useCallback, useMemo, useState } from \"react\";\nimport {\n Eye,\n FileUp,\n Redo,\n Undo,\n SunIcon,\n MoonIcon,\n CheckIcon,\n X,\n PlusIcon,\n Monitor,\n Tablet,\n Smartphone,\n Maximize,\n MoreVertical, // Ensure this import is present\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogHeader,\n DialogOverlay,\n DialogPortal,\n DialogTitle,\n DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport LayerRenderer from \"@/components/ui/ui-builder/layer-renderer\";\nimport { useTheme } from \"next-themes\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n DropdownMenuSeparator,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n CommandShortcut,\n} from \"@/components/ui/command\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport { CodePanel } from \"@/components/ui/ui-builder/code-panel\";\nimport {\n EditorStore,\n useEditorStore,\n} from \"@/lib/ui-builder/store/editor-store\";\nimport {\n ComponentRegistry,\n ComponentLayer,\n} from \"@/components/ui/ui-builder/types\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport {\n KeyCombination,\n useKeyboardShortcuts,\n} from \"@/hooks/use-keyboard-shortcuts\";\nimport { useStore } from \"zustand\";\n\nconst Z_INDEX = 1000;\n\ninterface NavBarProps {\n useCanvas?: boolean;\n}\n\nexport function NavBar({ useCanvas }: NavBarProps) {\n const selectedPageId = useLayerStore((state) => state.selectedPageId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const componentRegistry = useEditorStore((state) => state.registry);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n\n // Fix: Subscribe to temporal state changes using useStoreWithEqualityFn\n const pastStates = useStore(\n useLayerStore.temporal,\n (state) => state.pastStates\n );\n const futureStates = useStore(\n useLayerStore.temporal,\n (state) => state.futureStates\n );\n const { undo, redo } = useLayerStore.temporal.getState();\n\n const page = findLayerById(selectedPageId) as ComponentLayer;\n\n const canUndo = !!pastStates.length;\n const canRedo = !!futureStates.length;\n\n // **Lifted State for Modals**\n const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);\n const [isExportModalOpen, setIsExportModalOpen] = useState(false);\n\n const handleUndo = useCallback(() => {\n undo();\n // Increment revision counter to trigger form revalidation\n incrementRevision();\n }, [undo, incrementRevision]);\n\n const handleRedo = useCallback(() => {\n redo();\n // Increment revision counter to trigger form revalidation\n incrementRevision();\n }, [redo, incrementRevision]);\n\n const keyCombinations = useMemo(\n () => [\n {\n keys: { metaKey: true, shiftKey: false },\n key: \"z\",\n handler: handleUndo,\n },\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"z\",\n handler: handleRedo,\n },\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"9\",\n handler: () => {\n const elements = document.querySelectorAll(\"*\");\n elements.forEach((element) => {\n element.classList.add(\"animate-spin\", \"origin-center\");\n });\n },\n },\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"0\",\n handler: () => {\n const elements = document.querySelectorAll(\"*\");\n elements.forEach((element) => {\n element.classList.remove(\"animate-spin\", \"origin-center\");\n });\n },\n },\n ],\n [handleUndo, handleRedo]\n );\n\n useKeyboardShortcuts(keyCombinations);\n\n const handleOpenPreview = useCallback(() => {\n setIsPreviewModalOpen(true);\n }, []);\n const handleOpenExport = useCallback(() => {\n setIsExportModalOpen(true);\n }, []);\n\n const style = useMemo(() => ({ zIndex: Z_INDEX }), []);\n\n return (\n \n
\n
\n UI Builder\n \n
\n
\n {useCanvas &&
}\n
\n\n
\n {/* Action Buttons for Larger Screens */}\n
\n\n
\n\n {/* Dropdown for Smaller Screens */}\n
\n
\n\n {/* **Dialogs Controlled by NavBar State** */}\n
\n
\n
\n );\n}\n\n/**\n * Reusable Action Buttons Component\n */\ninterface ActionButtonsProps {\n canUndo: boolean;\n canRedo: boolean;\n onUndo: () => void;\n onRedo: () => void;\n onOpenPreview: () => void;\n onOpenExport: () => void;\n}\n\nconst ActionButtons: React.FC = ({\n canUndo,\n canRedo,\n onUndo,\n onRedo,\n onOpenPreview,\n onOpenExport,\n}) => {\n return (\n <>\n \n \n \n Undo \n \n \n \n \n Undo\n \n ⌘Z\n \n \n \n\n \n \n \n Redo \n \n \n \n \n Redo\n \n ⌘+⇧+Z\n \n \n \n\n \n \n \n Preview \n \n \n \n \n Preview\n \n ⌘+⇧+P\n \n \n \n\n \n \n \n Export \n \n \n \n \n Export Code\n \n ⌘+⇧+E\n \n \n \n >\n );\n};\n\n/**\n * Dropdown containing Action Buttons for Smaller Screens\n */\ninterface ResponsiveDropdownProps {\n canUndo: boolean;\n canRedo: boolean;\n onUndo: () => void;\n onRedo: () => void;\n onOpenPreview: () => void;\n onOpenExport: () => void;\n}\n\nconst ResponsiveDropdown: React.FC = ({\n canUndo,\n canRedo,\n onUndo,\n onRedo,\n onOpenPreview,\n onOpenExport,\n}) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n return (\n \n \n \n Actions \n \n \n \n \n \n \n Undo\n ⌘Z \n \n \n \n Redo\n ⌘+⇧+Z \n \n \n \n \n Preview\n ⌘+⇧+P \n \n \n \n Export\n ⌘+⇧+E \n \n \n \n );\n};\n\n/**\n * Preview Dialog Component\n */\ninterface PreviewDialogProps {\n isOpen: boolean;\n onOpenChange: (open: boolean) => void;\n page: ComponentLayer;\n componentRegistry: ComponentRegistry;\n}\n\nconst PreviewDialog: React.FC = ({\n isOpen,\n onOpenChange,\n page,\n componentRegistry,\n}) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const shortcuts = useMemo(\n () => [\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"p\",\n handler: () => {\n onOpenChange(true);\n },\n },\n ],\n [onOpenChange]\n );\n\n useKeyboardShortcuts(shortcuts);\n\n return (\n \n \n \n \n \n Page Preview \n \n \n \n \n \n );\n};\n\n/**\n * Code Dialog Component\n */\ninterface CodeDialogProps {\n isOpen: boolean;\n onOpenChange: (open: boolean) => void;\n}\n\nconst CodeDialog: React.FC = ({ isOpen, onOpenChange }) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const shortcuts = useMemo(\n () => [\n {\n keys: { metaKey: true, shiftKey: true },\n key: \"e\",\n handler: () => {\n onOpenChange(true);\n },\n },\n ],\n [onOpenChange]\n );\n\n useKeyboardShortcuts(shortcuts);\n\n return (\n \n \n \n \n Generated Code \n \n \n \n \n );\n};\n\nfunction ModeToggle() {\n const { setTheme } = useTheme();\n\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const handleSetLightTheme = useCallback(() => {\n setTheme(\"light\");\n }, [setTheme]);\n const handleSetDarkTheme = useCallback(() => {\n setTheme(\"dark\");\n }, [setTheme]);\n const handleSetSystemTheme = useCallback(() => {\n setTheme(\"system\");\n }, [setTheme]);\n\n return (\n \n \n \n \n \n \n \n Toggle theme \n \n \n \n Toggle theme \n \n \n Light \n Dark \n \n System\n \n \n \n );\n}\n\nfunction PagesPopover() {\n const { pages, selectedPageId, addPageLayer, selectPage } = useLayerStore();\n const [open, setOpen] = useState(false);\n const [inputValue, setInputValue] = useState(\"\");\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const [selectedPage, setSelectedPage] = useState(\n selectedPageId\n );\n const [textInputValue, setTextInputValue] = useState(\"\");\n const allowPagesCreation = useEditorStore(\n (state) => state.allowPagesCreation\n );\n\n const selectedPageData = useMemo(() => {\n return pages.find((page) => page.id === selectedPageId);\n }, [pages, selectedPageId]);\n\n const handleSelect = useCallback(\n (pageId: string) => {\n setSelectedPage(pageId);\n selectPage(pageId);\n setOpen(false);\n },\n [selectPage, setOpen]\n );\n\n const handleAddPageLayer = useCallback(\n (pageName: string) => {\n addPageLayer(pageName);\n setTextInputValue(\"\");\n },\n [addPageLayer, setTextInputValue]\n );\n\n const handleSubmit = useCallback(\n (e: React.FormEvent) => {\n e.preventDefault();\n handleAddPageLayer(textInputValue);\n },\n [handleAddPageLayer, textInputValue]\n );\n\n const handleTextInputChange = useCallback(\n (e: React.ChangeEvent) => {\n setTextInputValue(e.target.value);\n },\n [setTextInputValue]\n );\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n handleAddPageLayer(textInputValue);\n }\n },\n [handleAddPageLayer, textInputValue]\n );\n\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const textInputForm = (\n \n );\n return (\n \n
\n \n \n \n \n {selectedPageData?.name}\n \n \n \n Select page \n \n \n \n \n \n \n No pages found\n {allowPagesCreation && textInputForm}\n \n {pages.map((page) => (\n \n ))}\n \n {allowPagesCreation && (\n \n {textInputForm} \n \n )}\n \n \n \n \n
\n );\n}\n\nconst PageItem = ({\n selectedPageId,\n page,\n onSelect,\n}: {\n selectedPageId: string;\n page: ComponentLayer;\n onSelect: (pageId: string) => void;\n}) => {\n const handleSelect = useCallback(() => {\n onSelect(page.id);\n }, [onSelect, page.id]);\n\n return (\n \n {selectedPageId === page.id ? (\n \n ) : null}\n {page.name}\n \n );\n};\n\nconst DialogContentWithZIndex = forwardRef<\n React.ElementRef,\n React.ComponentPropsWithoutRef\n>(({ className, children, ...props }, ref) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n return (\n \n \n \n {children}\n \n \n Close \n \n \n \n );\n});\n\nDialogContentWithZIndex.displayName = \"DialogContentWithZIndex\";\n\nconst PreviewModeToggle = () => {\n const { previewMode, setPreviewMode } = useEditorStore();\n\n const handleSelect = useCallback((mode: EditorStore[\"previewMode\"]) => {\n setPreviewMode(mode);\n }, [setPreviewMode]);\n\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n\n const previewModeIcon = useMemo(() => {\n return {\n mobile: ,\n tablet: ,\n desktop: ,\n responsive: ,\n }[previewMode];\n }, [previewMode]);\n\n const handleSelectMobile = useCallback(() => {\n handleSelect(\"mobile\");\n }, [handleSelect]);\n const handleSelectTablet = useCallback(() => {\n handleSelect(\"tablet\");\n }, [handleSelect]);\n const handleSelectDesktop = useCallback(() => {\n handleSelect(\"desktop\");\n }, [handleSelect]);\n const handleSelectResponsive = useCallback(() => {\n handleSelect(\"responsive\");\n }, [handleSelect]);\n\n return (\n \n \n \n \n \n {previewModeIcon}\n Select screen size \n \n \n \n Select screen size \n \n \n \n \n Mobile \n \n \n \n Tablet \n \n \n \n Desktop \n \n \n \n Responsive \n \n \n \n );\n};\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/internal/nav.tsx"
},
@@ -190,7 +190,7 @@
},
{
"path": "components/ui/ui-builder/internal/layer-menu.tsx",
- "content": "import React, { useMemo, useState } from \"react\";\nimport { ChevronRight, Plus, Trash, Copy } from \"lucide-react\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport { AddComponentsPopover } from \"@/components/ui/ui-builder/internal/add-component-popover\";\nimport { cn } from \"@/lib/utils\";\nimport { hasLayerChildren } from \"@/lib/ui-builder/store/layer-utils\";\n\ninterface MenuProps {\n layerId: string;\n x: number;\n y: number;\n width: number;\n height: number;\n zIndex: number;\n handleDuplicateComponent: () => void;\n handleDeleteComponent: () => void;\n}\n\nexport const LayerMenu: React.FC = ({\n layerId,\n x,\n y,\n zIndex,\n handleDuplicateComponent,\n handleDeleteComponent,\n}) => {\n const [popoverOpen, setPopoverOpen] = useState(false);\n const selectedLayer = useLayerStore((state) => state.findLayerById(layerId));\n\n const componentRegistry = useEditorStore((state) => state.registry);\n\n //const hasChildrenInSchema = schema.shape.children !== undefined;\n const hasChildrenInSchema =\n selectedLayer &&\n hasLayerChildren(selectedLayer) &&\n componentRegistry[selectedLayer.type as keyof typeof componentRegistry]\n .schema.shape.children !== undefined;\n\n const style = useMemo(() => ({\n top: y,\n left: x,\n zIndex: zIndex,\n }), [y, x, zIndex]);\n\n const buttonVariantsValues = useMemo(() => {\n return buttonVariants({ variant: \"ghost\", size: \"sm\" });\n }, []);\n\n \n\n return (\n <>\n \n
\n \n\n \n {hasChildrenInSchema && (\n
\n \n \n )}\n
\n Duplicate \n \n
\n
\n Delete \n \n
\n
\n \n
\n >\n );\n};\n",
+ "content": "import React, { useMemo, useState } from \"react\";\nimport { ChevronRight, Plus, Trash, Copy } from \"lucide-react\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { useLayerStore } from \"@/lib/ui-builder/store/layer-store\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport { AddComponentsPopover } from \"@/components/ui/ui-builder/internal/add-component-popover\";\nimport { cn } from \"@/lib/utils\";\nimport { hasLayerChildren } from \"@/lib/ui-builder/store/layer-utils\";\n\ninterface MenuProps {\n layerId: string;\n x: number;\n y: number;\n width: number;\n height: number;\n zIndex: number;\n handleDuplicateComponent?: () => void;\n handleDeleteComponent?: () => void;\n}\n\nexport const LayerMenu: React.FC = ({\n layerId,\n x,\n y,\n zIndex,\n handleDuplicateComponent,\n handleDeleteComponent,\n}) => {\n const [popoverOpen, setPopoverOpen] = useState(false);\n const selectedLayer = useLayerStore((state) => state.findLayerById(layerId));\n const isLayerAPage = useLayerStore((state) => state.isLayerAPage(layerId));\n\n const componentRegistry = useEditorStore((state) => state.registry);\n const allowPagesCreation = useEditorStore((state) => state.allowPagesCreation);\n const allowPagesDeletion = useEditorStore((state) => state.allowPagesDeletion);\n\n // Check permissions for page operations\n const canDuplicate = !isLayerAPage || allowPagesCreation;\n const canDelete = !isLayerAPage || allowPagesDeletion;\n\n const style = useMemo(() => ({\n top: y,\n left: x,\n zIndex: zIndex,\n }), [y, x, zIndex]);\n\n const buttonVariantsValues = useMemo(() => {\n return buttonVariants({ variant: \"ghost\", size: \"sm\" });\n }, []);\n\n const canRenderAddChild = useMemo(() => {\n if (!selectedLayer) return false;\n \n const componentDef = componentRegistry[selectedLayer.type as keyof typeof componentRegistry];\n if (!componentDef) return false;\n \n return (\n hasLayerChildren(selectedLayer) &&\n componentDef.schema.shape.children !== undefined\n );\n }, [selectedLayer, componentRegistry]);\n\n return (\n <>\n \n
\n \n\n \n {canRenderAddChild && (\n
\n \n \n )}\n {canDuplicate && (\n
\n Duplicate {isLayerAPage ? \"Page\" : \"Component\"} \n \n
\n )}\n {canDelete && (\n
\n Delete {isLayerAPage ? \"Page\" : \"Component\"} \n \n
\n )}\n
\n \n
\n >\n );\n};\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/internal/layer-menu.tsx"
},
@@ -220,7 +220,7 @@
},
{
"path": "components/ui/ui-builder/internal/editor-panel.tsx",
- "content": "\"use client\";\nimport React, { useCallback, useMemo } from \"react\";\nimport { Plus } from \"lucide-react\";\nimport {\n countLayers,\n useLayerStore,\n} from \"@/lib/ui-builder/store/layer-store\";\nimport { ComponentLayer } from '@/components/ui/ui-builder/types';\n\nimport LayerRenderer from \"@/components/ui/ui-builder/layer-renderer\";\nimport { cn } from \"@/lib/utils\";\nimport { IframeWrapper } from \"@/components/ui/ui-builder/internal/iframe-wrapper\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport { InteractiveCanvas } from \"@/components/ui/ui-builder/internal/interactive-canvas\";\nimport { AddComponentsPopover } from \"@/components/ui/ui-builder/internal/add-component-popover\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface EditorPanelProps {\n className?: string;\n useCanvas?: boolean;\n}\n\nconst EditorPanel: React.FC = ({ className, useCanvas }) => {\n const {\n selectLayer,\n selectedLayerId,\n findLayerById,\n duplicateLayer,\n removeLayer,\n selectedPageId,\n } = useLayerStore();\n const previewMode = useEditorStore((state) => state.previewMode);\n const componentRegistry = useEditorStore((state) => state.registry);\n const selectedLayer = findLayerById(selectedLayerId) as ComponentLayer;\n const selectedPage = findLayerById(selectedPageId) as ComponentLayer;\n\n const layers = selectedPage.children;\n\n const onSelectElement = useCallback((layerId: string) => {\n selectLayer(layerId);\n }, [selectLayer]);\n\n const handleDeleteLayer = useCallback(() => {\n if (selectedLayer) {\n removeLayer(selectedLayer.id);\n }\n }, [selectedLayer, removeLayer]);\n\n const handleDuplicateLayer = useCallback(() => {\n if (selectedLayer) {\n duplicateLayer(selectedLayer.id);\n }\n }, [selectedLayer, duplicateLayer]);\n\n const editorConfig = useMemo(() => ({\n zIndex: 1,\n totalLayers: countLayers(layers),\n selectedLayer: selectedLayer,\n onSelectElement: onSelectElement,\n handleDuplicateLayer: handleDuplicateLayer,\n handleDeleteLayer: handleDeleteLayer,\n usingCanvas: useCanvas,\n }), [layers, selectedLayer, onSelectElement, handleDuplicateLayer, handleDeleteLayer, useCanvas]);\n\n const isMobileScreen = window.innerWidth < 768;\n\n const renderer = useMemo(() => (\n \n \n
\n ), [selectedPage, editorConfig, componentRegistry]);\n\n const widthClass = useMemo(() => {\n return {\n responsive: \"w-full\",\n mobile: \"w-[390px]\",\n tablet: \"w-[768px]\",\n desktop: \"w-[1440px]\",\n }[previewMode]\n }, [previewMode]);\n\n return (\n \n {useCanvas ? (\n
\n 0}\n className={cn(`block`, widthClass)}\n >\n {renderer}\n \n \n ) : (\n renderer\n )}\n
\n \n \n \n \n
\n );\n};\n\nexport default EditorPanel;\n",
+ "content": "\"use client\";\nimport React, { useCallback, useMemo } from \"react\";\nimport { Plus } from \"lucide-react\";\nimport {\n countLayers,\n useLayerStore,\n} from \"@/lib/ui-builder/store/layer-store\";\nimport { ComponentLayer } from '@/components/ui/ui-builder/types';\n\nimport LayerRenderer from \"@/components/ui/ui-builder/layer-renderer\";\nimport { cn } from \"@/lib/utils\";\nimport { IframeWrapper } from \"@/components/ui/ui-builder/internal/iframe-wrapper\";\nimport { useEditorStore } from \"@/lib/ui-builder/store/editor-store\";\nimport { InteractiveCanvas } from \"@/components/ui/ui-builder/internal/interactive-canvas\";\nimport { AddComponentsPopover } from \"@/components/ui/ui-builder/internal/add-component-popover\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface EditorPanelProps {\n className?: string;\n useCanvas?: boolean;\n}\n\nconst EditorPanel: React.FC = ({ className, useCanvas }) => {\n const {\n selectLayer,\n selectedLayerId,\n findLayerById,\n duplicateLayer,\n removeLayer,\n selectedPageId,\n } = useLayerStore();\n const previewMode = useEditorStore((state) => state.previewMode);\n const componentRegistry = useEditorStore((state) => state.registry);\n const selectedLayer = findLayerById(selectedLayerId) as ComponentLayer;\n const selectedPage = findLayerById(selectedPageId) as ComponentLayer;\n const isLayerAPage = useLayerStore((state) => state.isLayerAPage(selectedLayerId || \"\"));\n const allowPagesCreation = useEditorStore((state) => state.allowPagesCreation);\n const allowPagesDeletion = useEditorStore((state) => state.allowPagesDeletion);\n\n const layers = selectedPage.children;\n\n const onSelectElement = useCallback((layerId: string) => {\n selectLayer(layerId);\n }, [selectLayer]);\n\n const handleDeleteLayer = useCallback(() => {\n if (selectedLayer && !isLayerAPage) {\n removeLayer(selectedLayer.id);\n }\n }, [selectedLayer, removeLayer, isLayerAPage]);\n\n const handleDuplicateLayer = useCallback(() => {\n if (selectedLayer && !isLayerAPage) {\n duplicateLayer(selectedLayer.id);\n }\n }, [selectedLayer, duplicateLayer, isLayerAPage]);\n\n const editorConfig = useMemo(() => ({\n zIndex: 1,\n totalLayers: countLayers(layers),\n selectedLayer: selectedLayer,\n onSelectElement: onSelectElement,\n handleDuplicateLayer: allowPagesCreation ? handleDuplicateLayer : undefined,\n handleDeleteLayer: allowPagesDeletion ? handleDeleteLayer : undefined,\n usingCanvas: useCanvas,\n }), [layers, selectedLayer, onSelectElement, handleDuplicateLayer, handleDeleteLayer, useCanvas, allowPagesCreation, allowPagesDeletion]);\n\n const isMobileScreen = window.innerWidth < 768;\n\n const renderer = useMemo(() => (\n \n \n
\n ), [selectedPage, editorConfig, componentRegistry]);\n\n const widthClass = useMemo(() => {\n return {\n responsive: \"w-full\",\n mobile: \"w-[390px]\",\n tablet: \"w-[768px]\",\n desktop: \"w-[1440px]\",\n }[previewMode]\n }, [previewMode]);\n\n return (\n \n {useCanvas ? (\n
\n 0}\n className={cn(`block`, widthClass)}\n >\n {renderer}\n \n \n ) : (\n renderer\n )}\n
\n \n \n \n \n
\n );\n};\n\nexport default EditorPanel;\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/internal/editor-panel.tsx"
},
@@ -244,7 +244,7 @@
},
{
"path": "components/ui/ui-builder/internal/clickable-wrapper.tsx",
- "content": "import React, {\n useState,\n useRef,\n useEffect,\n memo,\n useCallback,\n useMemo,\n} from \"react\";\nimport { ComponentLayer } from \"@/components/ui/ui-builder/types\";\nimport { LayerMenu } from \"@/components/ui/ui-builder/internal/layer-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { getScrollParent } from \"@/lib/ui-builder/utils/get-scroll-parent\";\nimport isDeepEqual from \"fast-deep-equal\";\n\nconst MIN_SIZE = 2;\n\ninterface ClickableWrapperProps {\n layer: ComponentLayer;\n isSelected: boolean;\n zIndex: number;\n totalLayers: number;\n onSelectElement: (layerId: string) => void;\n children: React.ReactNode;\n onDuplicateLayer: () => void;\n onDeleteLayer: () => void;\n listenToScrollParent: boolean;\n observeMutations: boolean;\n}\n\n/**\n * ClickableWrapper gives a layer a bounding box that can be clicked. And provides a context menu for the layer.\n */\nconst InternalClickableWrapper: React.FC = ({\n layer,\n isSelected,\n zIndex,\n totalLayers,\n onSelectElement,\n children,\n onDuplicateLayer,\n onDeleteLayer,\n listenToScrollParent,\n observeMutations,\n}) => {\n const [boundingRect, setBoundingRect] = useState(null);\n const [touchPosition, setTouchPosition] = useState<{\n x: number;\n y: number;\n } | null>(null);\n const wrapperRef = useRef(null);\n\n // listen to resize and position changes\n useEffect(() => {\n // update bounding rect on page resize and scroll\n const element = wrapperRef.current?.firstElementChild as HTMLElement | null;\n if (!element) {\n setBoundingRect(null);\n return;\n }\n\n const updateBoundingRect = () => {\n const rect = element.getBoundingClientRect();\n // Prevent unnecessary re-renders if the rect is the same\n setBoundingRect((prevRect) => {\n if (\n prevRect &&\n rect.x === prevRect.x &&\n rect.y === prevRect.y &&\n rect.width === prevRect.width &&\n rect.height === prevRect.height &&\n rect.top === prevRect.top &&\n rect.left === prevRect.left &&\n rect.right === prevRect.right &&\n rect.bottom === prevRect.bottom\n ) {\n return prevRect;\n }\n return rect;\n });\n };\n\n updateBoundingRect();\n\n let resizeObserver: ResizeObserver | null = null;\n if (\"ResizeObserver\" in window) {\n resizeObserver = new ResizeObserver(updateBoundingRect);\n resizeObserver.observe(element);\n }\n\n let mutationObserver: MutationObserver | null = null;\n if (\"MutationObserver\" in window && observeMutations) {\n mutationObserver = new MutationObserver(updateBoundingRect);\n mutationObserver.observe(document.body, {\n attributeFilter: [\"style\", \"class\"],\n attributes: true,\n subtree: true,\n });\n }\n\n let scrollParent: HTMLElement | null = null;\n if (listenToScrollParent) {\n scrollParent = getScrollParent(element);\n if (scrollParent) {\n scrollParent.addEventListener(\"scroll\", updateBoundingRect);\n }\n }\n\n return () => {\n if (resizeObserver) {\n resizeObserver.unobserve(element);\n resizeObserver.disconnect();\n }\n if (scrollParent) {\n scrollParent.removeEventListener(\"scroll\", updateBoundingRect);\n }\n if (mutationObserver) {\n mutationObserver.disconnect();\n }\n };\n }, [isSelected, layer.id, children, listenToScrollParent, observeMutations]);\n\n // listen to window resize\n useEffect(() => {\n const element = wrapperRef.current?.firstElementChild as HTMLElement | null;\n if (!element) {\n setBoundingRect(null);\n return;\n }\n\n const updateBoundingRect = () => {\n const currentRect = element.getBoundingClientRect();\n setBoundingRect((prevRect) => {\n if (\n prevRect &&\n currentRect.x === prevRect.x &&\n currentRect.y === prevRect.y &&\n currentRect.width === prevRect.width &&\n currentRect.height === prevRect.height &&\n currentRect.top === prevRect.top &&\n currentRect.left === prevRect.left &&\n currentRect.right === prevRect.right &&\n currentRect.bottom === prevRect.bottom\n ) {\n return prevRect;\n }\n return currentRect;\n });\n };\n\n window.addEventListener(\"resize\", updateBoundingRect);\n return () => {\n window.removeEventListener(\"resize\", updateBoundingRect);\n };\n }, []);\n\n // listen to panel size changes\n useEffect(() => {\n //since we are using resizable panel, we need to track parent size changes\n if (!wrapperRef.current) return;\n\n const panelContainer = document.getElementById(\"editor-panel-container\");\n if (!panelContainer) return;\n\n const updateBoundingRect = () => {\n const element = wrapperRef.current\n ?.firstElementChild as HTMLElement | null;\n if (element) {\n const rect = element.getBoundingClientRect();\n // Prevent unnecessary re-renders if the rect is the same\n setBoundingRect((prevRect) => {\n if (\n prevRect &&\n rect.x === prevRect.x &&\n rect.y === prevRect.y &&\n rect.width === prevRect.width &&\n rect.height === prevRect.height &&\n rect.top === prevRect.top &&\n rect.left === prevRect.left &&\n rect.right === prevRect.right &&\n rect.bottom === prevRect.bottom\n ) {\n return prevRect;\n }\n return rect;\n });\n }\n };\n\n const resizeObserver = new ResizeObserver(updateBoundingRect);\n resizeObserver.observe(panelContainer);\n\n return () => {\n resizeObserver.disconnect();\n };\n }, [isSelected, layer.id, children]);\n\n const handleClick = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation();\n e.preventDefault();\n onSelectElement(layer.id);\n },\n [onSelectElement, layer.id]\n );\n\n // const handleDoubleClick = (e: React.MouseEvent) => {\n // e.stopPropagation();\n // e.preventDefault();\n // const targetElement = wrapperRef.current?.firstElementChild;\n // if (targetElement) {\n // // Create a new MouseEvent with the same properties as the original\n // const newEvent = new MouseEvent(\"click\", {\n // bubbles: true, // Ensure the event bubbles up if needed\n // cancelable: true, // Allow it to be cancelled\n // view: window, // Typically the window object\n // detail: e.detail, // Copy detail (usually click count)\n // screenX: e.screenX,\n // screenY: e.screenY,\n // clientX: e.clientX,\n // clientY: e.clientY,\n // ctrlKey: e.ctrlKey,\n // altKey: e.altKey,\n // shiftKey: e.shiftKey,\n // metaKey: e.metaKey,\n // button: e.button,\n // relatedTarget: e.relatedTarget,\n // });\n\n // // Dispatch the new event on the target element\n // targetElement.dispatchEvent(newEvent);\n // }\n // };\n\n const style = useMemo(() => {\n if (!boundingRect) return {};\n return {\n top:\n boundingRect.width < MIN_SIZE && boundingRect.height < MIN_SIZE\n ? boundingRect.top - MIN_SIZE\n : boundingRect.top,\n left:\n boundingRect.width < MIN_SIZE && boundingRect.height < MIN_SIZE\n ? boundingRect.left - MIN_SIZE\n : boundingRect.left,\n width: Math.max(boundingRect.width, MIN_SIZE),\n height: Math.max(boundingRect.height, MIN_SIZE),\n zIndex: zIndex,\n };\n }, [boundingRect, zIndex]);\n\n\n const handleWheel = useCallback((e: React.WheelEvent) => {\n const scrollParent = getScrollParent(e.target as HTMLElement);\n if (scrollParent) {\n scrollParent.scrollLeft += e.deltaX;\n scrollParent.scrollTop += e.deltaY;\n }\n }, []);\n\n const handleTouchStart = useCallback((e: React.TouchEvent) => {\n setTouchPosition({\n x: e.touches[0].clientX,\n y: e.touches[0].clientY,\n });\n }, []);\n\n const handleTouchMove = useCallback((e: React.TouchEvent) => {\n if (touchPosition) {\n const deltaX = touchPosition.x - e.touches[0].clientX;\n const deltaY = touchPosition.y - e.touches[0].clientY;\n const scrollParent = getScrollParent(e.target as HTMLElement);\n if (scrollParent) {\n scrollParent.scrollLeft += deltaX;\n scrollParent.scrollTop += deltaY;\n }\n setTouchPosition({\n x: e.touches[0].clientX,\n y: e.touches[0].clientY,\n });\n }\n }, [touchPosition]);\n\n const handleTouchEnd = useCallback(() => {\n setTouchPosition(null);\n }, []);\n\n return (\n <>\n \n {children}\n \n\n {isSelected && boundingRect && (\n \n )}\n\n {boundingRect && (\n \n {/* {small label with layer type floating above the bounding box} */}\n {isSelected && (\n \n {layer.name?.toLowerCase().startsWith(layer.type.toLowerCase())\n ? layer.type.replaceAll(\"_\", \"\")\n : `${layer.name} (${layer.type.replaceAll(\"_\", \"\")})`}\n \n )}\n
\n )}\n >\n );\n};\n\nconst ClickableWrapper = memo(\n InternalClickableWrapper,\n (prevProps, nextProps) => {\n if (prevProps.isSelected !== nextProps.isSelected) return false;\n if (prevProps.zIndex !== nextProps.zIndex) return false;\n if (prevProps.totalLayers !== nextProps.totalLayers) return false;\n if (!isDeepEqual(prevProps.layer, nextProps.layer)) return false;\n if (prevProps.listenToScrollParent !== nextProps.listenToScrollParent)\n return false;\n if (prevProps.observeMutations !== nextProps.observeMutations) return false;\n\n // Assuming functions are memoized by parent and don't change unless necessary\n if (prevProps.onSelectElement !== nextProps.onSelectElement) return false;\n if (prevProps.onDuplicateLayer !== nextProps.onDuplicateLayer) return false;\n if (prevProps.onDeleteLayer !== nextProps.onDeleteLayer) return false;\n\n // Children comparison can be tricky. If children are simple ReactNodes or are memoized,\n // a shallow compare might be okay. If they are complex and change frequently, this might\n // still cause re-renders or hide necessary ones. For now, let's do a shallow compare.\n if (prevProps.children !== nextProps.children) return false;\n\n return true; // Props are equal\n }\n);\n\nInternalClickableWrapper.displayName = \"ClickableWrapperBase\"; // Give the base component a unique displayName\nClickableWrapper.displayName = \"ClickableWrapper\"; // The memoized component gets the original displayName\n\nexport { ClickableWrapper };\n",
+ "content": "import React, {\n useState,\n useRef,\n useEffect,\n memo,\n useCallback,\n useMemo,\n} from \"react\";\nimport { ComponentLayer } from \"@/components/ui/ui-builder/types\";\nimport { LayerMenu } from \"@/components/ui/ui-builder/internal/layer-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { getScrollParent } from \"@/lib/ui-builder/utils/get-scroll-parent\";\nimport isDeepEqual from \"fast-deep-equal\";\n\nconst MIN_SIZE = 2;\n\ninterface ClickableWrapperProps {\n layer: ComponentLayer;\n isSelected: boolean;\n zIndex: number;\n totalLayers: number;\n onSelectElement: (layerId: string) => void;\n children: React.ReactNode;\n onDuplicateLayer?: () => void;\n onDeleteLayer?: () => void;\n listenToScrollParent: boolean;\n observeMutations: boolean;\n}\n\n/**\n * ClickableWrapper gives a layer a bounding box that can be clicked. And provides a context menu for the layer.\n */\nconst InternalClickableWrapper: React.FC = ({\n layer,\n isSelected,\n zIndex,\n totalLayers,\n onSelectElement,\n children,\n onDuplicateLayer,\n onDeleteLayer,\n listenToScrollParent,\n observeMutations,\n}) => {\n const [boundingRect, setBoundingRect] = useState(null);\n const [touchPosition, setTouchPosition] = useState<{\n x: number;\n y: number;\n } | null>(null);\n const wrapperRef = useRef(null);\n\n // listen to resize and position changes\n useEffect(() => {\n // update bounding rect on page resize and scroll\n const element = wrapperRef.current?.firstElementChild as HTMLElement | null;\n if (!element) {\n setBoundingRect(null);\n return;\n }\n\n const updateBoundingRect = () => {\n const rect = element.getBoundingClientRect();\n // Prevent unnecessary re-renders if the rect is the same\n setBoundingRect((prevRect) => {\n if (\n prevRect &&\n rect.x === prevRect.x &&\n rect.y === prevRect.y &&\n rect.width === prevRect.width &&\n rect.height === prevRect.height &&\n rect.top === prevRect.top &&\n rect.left === prevRect.left &&\n rect.right === prevRect.right &&\n rect.bottom === prevRect.bottom\n ) {\n return prevRect;\n }\n return rect;\n });\n };\n\n updateBoundingRect();\n\n let resizeObserver: ResizeObserver | null = null;\n if (\"ResizeObserver\" in window) {\n resizeObserver = new ResizeObserver(updateBoundingRect);\n resizeObserver.observe(element);\n }\n\n let mutationObserver: MutationObserver | null = null;\n if (\"MutationObserver\" in window && observeMutations) {\n mutationObserver = new MutationObserver(updateBoundingRect);\n mutationObserver.observe(document.body, {\n attributeFilter: [\"style\", \"class\"],\n attributes: true,\n subtree: true,\n });\n }\n\n let scrollParent: HTMLElement | null = null;\n if (listenToScrollParent) {\n scrollParent = getScrollParent(element);\n if (scrollParent) {\n scrollParent.addEventListener(\"scroll\", updateBoundingRect);\n }\n }\n\n return () => {\n if (resizeObserver) {\n resizeObserver.unobserve(element);\n resizeObserver.disconnect();\n }\n if (scrollParent) {\n scrollParent.removeEventListener(\"scroll\", updateBoundingRect);\n }\n if (mutationObserver) {\n mutationObserver.disconnect();\n }\n };\n }, [isSelected, layer.id, children, listenToScrollParent, observeMutations]);\n\n // listen to window resize\n useEffect(() => {\n const element = wrapperRef.current?.firstElementChild as HTMLElement | null;\n if (!element) {\n setBoundingRect(null);\n return;\n }\n\n const updateBoundingRect = () => {\n const currentRect = element.getBoundingClientRect();\n setBoundingRect((prevRect) => {\n if (\n prevRect &&\n currentRect.x === prevRect.x &&\n currentRect.y === prevRect.y &&\n currentRect.width === prevRect.width &&\n currentRect.height === prevRect.height &&\n currentRect.top === prevRect.top &&\n currentRect.left === prevRect.left &&\n currentRect.right === prevRect.right &&\n currentRect.bottom === prevRect.bottom\n ) {\n return prevRect;\n }\n return currentRect;\n });\n };\n\n window.addEventListener(\"resize\", updateBoundingRect);\n return () => {\n window.removeEventListener(\"resize\", updateBoundingRect);\n };\n }, []);\n\n // listen to panel size changes\n useEffect(() => {\n //since we are using resizable panel, we need to track parent size changes\n if (!wrapperRef.current) return;\n\n const panelContainer = document.getElementById(\"editor-panel-container\");\n if (!panelContainer) return;\n\n const updateBoundingRect = () => {\n const element = wrapperRef.current\n ?.firstElementChild as HTMLElement | null;\n if (element) {\n const rect = element.getBoundingClientRect();\n // Prevent unnecessary re-renders if the rect is the same\n setBoundingRect((prevRect) => {\n if (\n prevRect &&\n rect.x === prevRect.x &&\n rect.y === prevRect.y &&\n rect.width === prevRect.width &&\n rect.height === prevRect.height &&\n rect.top === prevRect.top &&\n rect.left === prevRect.left &&\n rect.right === prevRect.right &&\n rect.bottom === prevRect.bottom\n ) {\n return prevRect;\n }\n return rect;\n });\n }\n };\n\n const resizeObserver = new ResizeObserver(updateBoundingRect);\n resizeObserver.observe(panelContainer);\n\n return () => {\n resizeObserver.disconnect();\n };\n }, [isSelected, layer.id, children]);\n\n const handleClick = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation();\n e.preventDefault();\n onSelectElement(layer.id);\n },\n [onSelectElement, layer.id]\n );\n\n // const handleDoubleClick = (e: React.MouseEvent) => {\n // e.stopPropagation();\n // e.preventDefault();\n // const targetElement = wrapperRef.current?.firstElementChild;\n // if (targetElement) {\n // // Create a new MouseEvent with the same properties as the original\n // const newEvent = new MouseEvent(\"click\", {\n // bubbles: true, // Ensure the event bubbles up if needed\n // cancelable: true, // Allow it to be cancelled\n // view: window, // Typically the window object\n // detail: e.detail, // Copy detail (usually click count)\n // screenX: e.screenX,\n // screenY: e.screenY,\n // clientX: e.clientX,\n // clientY: e.clientY,\n // ctrlKey: e.ctrlKey,\n // altKey: e.altKey,\n // shiftKey: e.shiftKey,\n // metaKey: e.metaKey,\n // button: e.button,\n // relatedTarget: e.relatedTarget,\n // });\n\n // // Dispatch the new event on the target element\n // targetElement.dispatchEvent(newEvent);\n // }\n // };\n\n const style = useMemo(() => {\n if (!boundingRect) return {};\n return {\n top:\n boundingRect.width < MIN_SIZE && boundingRect.height < MIN_SIZE\n ? boundingRect.top - MIN_SIZE\n : boundingRect.top,\n left:\n boundingRect.width < MIN_SIZE && boundingRect.height < MIN_SIZE\n ? boundingRect.left - MIN_SIZE\n : boundingRect.left,\n width: Math.max(boundingRect.width, MIN_SIZE),\n height: Math.max(boundingRect.height, MIN_SIZE),\n zIndex: zIndex,\n };\n }, [boundingRect, zIndex]);\n\n\n const handleWheel = useCallback((e: React.WheelEvent) => {\n const scrollParent = getScrollParent(e.target as HTMLElement);\n if (scrollParent) {\n scrollParent.scrollLeft += e.deltaX;\n scrollParent.scrollTop += e.deltaY;\n }\n }, []);\n\n const handleTouchStart = useCallback((e: React.TouchEvent) => {\n setTouchPosition({\n x: e.touches[0].clientX,\n y: e.touches[0].clientY,\n });\n }, []);\n\n const handleTouchMove = useCallback((e: React.TouchEvent) => {\n if (touchPosition) {\n const deltaX = touchPosition.x - e.touches[0].clientX;\n const deltaY = touchPosition.y - e.touches[0].clientY;\n const scrollParent = getScrollParent(e.target as HTMLElement);\n if (scrollParent) {\n scrollParent.scrollLeft += deltaX;\n scrollParent.scrollTop += deltaY;\n }\n setTouchPosition({\n x: e.touches[0].clientX,\n y: e.touches[0].clientY,\n });\n }\n }, [touchPosition]);\n\n const handleTouchEnd = useCallback(() => {\n setTouchPosition(null);\n }, []);\n\n return (\n <>\n \n {children}\n \n\n {isSelected && boundingRect && (\n \n )}\n\n {boundingRect && (\n \n {/* {small label with layer type floating above the bounding box} */}\n {isSelected && (\n \n {layer.name?.toLowerCase().startsWith(layer.type.toLowerCase())\n ? layer.type.replaceAll(\"_\", \"\")\n : `${layer.name} (${layer.type.replaceAll(\"_\", \"\")})`}\n \n )}\n
\n )}\n >\n );\n};\n\nconst ClickableWrapper = memo(\n InternalClickableWrapper,\n (prevProps, nextProps) => {\n if (prevProps.isSelected !== nextProps.isSelected) return false;\n if (prevProps.zIndex !== nextProps.zIndex) return false;\n if (prevProps.totalLayers !== nextProps.totalLayers) return false;\n if (!isDeepEqual(prevProps.layer, nextProps.layer)) return false;\n if (prevProps.listenToScrollParent !== nextProps.listenToScrollParent)\n return false;\n if (prevProps.observeMutations !== nextProps.observeMutations) return false;\n\n // Assuming functions are memoized by parent and don't change unless necessary\n if (prevProps.onSelectElement !== nextProps.onSelectElement) return false;\n if (prevProps.onDuplicateLayer !== nextProps.onDuplicateLayer) return false;\n if (prevProps.onDeleteLayer !== nextProps.onDeleteLayer) return false;\n\n // Children comparison can be tricky. If children are simple ReactNodes or are memoized,\n // a shallow compare might be okay. If they are complex and change frequently, this might\n // still cause re-renders or hide necessary ones. For now, let's do a shallow compare.\n if (prevProps.children !== nextProps.children) return false;\n\n return true; // Props are equal\n }\n);\n\nInternalClickableWrapper.displayName = \"ClickableWrapperBase\"; // Give the base component a unique displayName\nClickableWrapper.displayName = \"ClickableWrapper\"; // The memoized component gets the original displayName\n\nexport { ClickableWrapper };\n",
"type": "registry:ui",
"target": "components/ui/ui-builder/internal/clickable-wrapper.tsx"
},
@@ -364,7 +364,7 @@
},
{
"path": "lib/ui-builder/store/layer-store.ts",
- "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { create, StateCreator } from 'zustand';\nimport { persist, createJSONStorage, StorageValue } from 'zustand/middleware'\nimport { produce } from 'immer';\nimport { temporal } from 'zundo';\nimport isDeepEqual from 'fast-deep-equal';\n\nimport { visitLayer, addLayer, hasLayerChildren, findLayerRecursive, createId, countLayers, duplicateWithNewIdsAndName, findAllParentLayersRecursive, migrateV1ToV2, migrateV2ToV3 } from '@/lib/ui-builder/store/layer-utils';\nimport { getDefaultProps } from '@/lib/ui-builder/store/schema-utils';\nimport { useEditorStore } from '@/lib/ui-builder/store/editor-store';\nimport { ComponentLayer, Variable } from '@/components/ui/ui-builder/types';\n\nconst DEFAULT_PAGE_PROPS = {\n className: \"p-4 flex flex-col gap-2\",\n};\n\nexport interface LayerStore {\n pages: ComponentLayer[];\n selectedLayerId: string | null;\n selectedPageId: string;\n variables: Variable[];\n initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => void;\n addComponentLayer: (layerType: string, parentId: string, parentPosition?: number) => void;\n addPageLayer: (pageId: string) => void;\n duplicateLayer: (layerId: string, parentId?: string) => void;\n removeLayer: (layerId: string) => void;\n updateLayer: (layerId: string, newProps: Record, layerRest?: Partial>) => void;\n selectLayer: (layerId: string) => void;\n selectPage: (pageId: string) => void;\n findLayerById: (layerId: string | null) => ComponentLayer | undefined;\n findLayersForPageId: (pageId: string) => ComponentLayer[];\n\n addVariable: (name: string, type: Variable['type'], defaultValue: any) => void;\n updateVariable: (variableId: string, updates: Partial>) => void;\n removeVariable: (variableId: string) => void;\n bindPropToVariable: (layerId: string, propName: string, variableId: string) => void;\n unbindPropFromVariable: (layerId: string, propName: string) => void;\n}\n\nconst store: StateCreator = (set, get) => (\n {\n // Default to a single empty page\n pages: [\n {\n id: '1',\n type: 'div',\n name: 'Page 1',\n props: DEFAULT_PAGE_PROPS,\n children: [],\n }\n ],\n\n // Variables available for binding\n variables: [],\n selectedLayerId: null,\n selectedPageId: '1',\n initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => {\n set({ pages, selectedPageId: selectedPageId || pages[0].id, selectedLayerId: selectedLayerId || null, variables: variables || [] });\n },\n findLayerById: (layerId: string | null) => {\n const { selectedPageId, findLayersForPageId, pages } = get();\n if (!layerId) return undefined;\n if (layerId === selectedPageId) {\n return pages.find(page => page.id === selectedPageId);\n }\n const layers = findLayersForPageId(selectedPageId);\n if (!layers) return undefined;\n return findLayerRecursive(layers, layerId);\n },\n findLayersForPageId: (pageId: string) => {\n const { pages } = get();\n const page = pages.find(page => page.id === pageId);\n if(page && hasLayerChildren(page)) {\n return page?.children || [];\n }\n return [];\n },\n\n addComponentLayer: (layerType: string, parentId: string, parentPosition?: number) => set(produce((state: LayerStore) => {\n const { registry } = useEditorStore.getState();\n const defaultProps = getDefaultProps(registry[layerType].schema);\n const defaultChildrenRaw = registry[layerType].defaultChildren;\n const defaultChildren = typeof defaultChildrenRaw === \"string\" ? defaultChildrenRaw : (defaultChildrenRaw?.map(child => duplicateWithNewIdsAndName(child, false)) || []);\n\n const initialProps = Object.entries(defaultProps).reduce((acc, [key, propDef]) => {\n if (key !== \"children\") {\n acc[key] = propDef;\n }\n return acc;\n }, {} as Record);\n\n const newLayer: ComponentLayer = {\n id: createId(),\n type: layerType,\n name: layerType,\n props: initialProps,\n children: defaultChildren,\n };\n\n // Traverse and update the pages to add the new layer\n const updatedPages = addLayer(state.pages, newLayer, parentId, parentPosition);\n return {\n ...state,\n pages: updatedPages\n };\n })),\n\n addPageLayer: (pageName: string) => set(produce((state: LayerStore) => {\n const newPage: ComponentLayer = {\n id: createId(),\n type: 'div',\n name: pageName,\n props: DEFAULT_PAGE_PROPS,\n children: [],\n };\n return {\n pages: [...state.pages, newPage],\n selectedPageId: newPage.id,\n selectedLayerId: newPage.id,\n };\n })),\n\n duplicateLayer: (layerId: string) => set(produce((state: LayerStore) => {\n let layerToDuplicate: ComponentLayer | undefined;\n let parentId: string | undefined;\n let parentPosition: number | undefined;\n\n // Find the layer to duplicate\n state.pages.forEach((page) =>\n visitLayer(page, null, (layer, parent) => {\n if (layer.id === layerId) {\n layerToDuplicate = layer;\n parentId = parent?.id;\n if (parent && hasLayerChildren(parent)) {\n parentPosition = parent.children.indexOf(layer) + 1;\n }\n }\n return layer;\n })\n );\n if (!layerToDuplicate) {\n console.warn(`Layer with ID ${ layerId } not found.`);\n return;\n }\n\n const isNewLayerAPage = state.pages.some(page => page.id === layerId);\n\n const newLayer = duplicateWithNewIdsAndName(layerToDuplicate, true);\n\n if (isNewLayerAPage) {\n return {\n ...state,\n pages: [...state.pages, newLayer],\n selectedPageId: newLayer.id,\n };\n }\n\n //else add it as a child of the parent\n\n const updatedPages = addLayer(state.pages, newLayer, parentId, parentPosition);\n\n // Insert the duplicated layer\n return {\n ...state,\n pages: updatedPages\n };\n })),\n\n removeLayer: (layerId: string) => set(produce((state: LayerStore) => {\n const { selectedLayerId, pages } = get();\n\n let newSelectedLayerId = selectedLayerId;\n\n const isPage = state.pages.some(page => page.id === layerId);\n if (isPage && pages.length > 1) {\n const newPages = state.pages.filter(page => page.id !== layerId);\n return {\n ...state,\n pages: newPages,\n selectedPageId: newPages[0].id,\n };\n }\n\n // Traverse and update the pages to remove the specified layer\n const updatedPages = pages.map((page) =>\n visitLayer(page, null, (layer) => {\n\n if (hasLayerChildren(layer)) {\n\n // Remove the layer by filtering it out from the children\n const updatedChildren = layer.children.filter((child) => child.id !== layerId);\n return { ...layer, children: updatedChildren };\n }\n\n return layer;\n })\n );\n\n if (selectedLayerId === layerId) {\n // If the removed layer was selected, deselect it \n newSelectedLayerId = null;\n }\n return {\n ...state,\n selectedLayerId: newSelectedLayerId,\n pages: updatedPages,\n };\n })),\n\n updateLayer: (layerId: string, newProps: ComponentLayer['props'], layerRest?: Partial>) => set(\n produce((state: LayerStore) => {\n const { selectedPageId, findLayersForPageId, pages } = get();\n\n const pageExists = pages.some(page => page.id === selectedPageId);\n if (!pageExists) {\n console.warn(`No layers found for page ID: ${ selectedPageId }`);\n return state;\n }\n\n if (layerId === selectedPageId) {\n const updatedPages = pages.map(page =>\n page.id === selectedPageId\n ? { ...page, props: { ...page.props, ...newProps }, ...(layerRest || {}) }\n : page\n );\n return { ...state, pages: updatedPages };\n }\n\n const layers = findLayersForPageId(selectedPageId);\n\n\n // Visitor function to update layer properties\n const visitor = (layer: ComponentLayer): ComponentLayer => {\n if (layer.id === layerId) {\n return {\n ...layer,\n ...(layerRest || {}),\n props: { ...layer.props, ...newProps },\n } as ComponentLayer\n }\n return layer;\n };\n\n // Apply the visitor to update layers\n const updatedLayers = layers.map(layer => visitLayer(layer, null, visitor));\n\n const isUnchanged = updatedLayers.every((layer, index) => layer === layers[index]);\n\n if (isUnchanged) {\n console.warn(`Layer with ID ${ layerId } was not found.`);\n return state;\n }\n\n // Update the state with the modified layers\n const updatedPages = state.pages.map(page =>\n page.id === selectedPageId ? { ...page, children: updatedLayers } : page\n );\n\n return { ...state, pages: updatedPages };\n })\n ),\n\n\n selectLayer: (layerId: string) => set(produce((state: LayerStore) => {\n const { selectedPageId, findLayersForPageId } = get();\n const layers = findLayersForPageId(selectedPageId);\n if(selectedPageId === layerId) {\n return {\n selectedLayerId: layerId\n };\n }\n if (!layers) return state;\n const layer = findLayerRecursive(layers, layerId);\n if (layer) {\n return {\n selectedLayerId: layer.id\n };\n }\n return {};\n })),\n\n selectPage: (pageId: string) => set(produce((state: LayerStore) => {\n const page = state.pages.find(page => page.id === pageId);\n if (!page) return state;\n return {\n selectedPageId: pageId\n };\n })),\n\n // Add a new variable\n addVariable: (name, type, defaultValue) => set(produce((state: LayerStore) => {\n state.variables.push({ id: createId(), name, type, defaultValue });\n })),\n\n // Update an existing variable\n updateVariable: (variableId, updates) => set(produce((state: LayerStore) => {\n const v = state.variables.find(v => v.id === variableId);\n if (v) Object.assign(v, updates);\n })),\n\n // Remove a variable\n removeVariable: (variableId) => set(produce((state: LayerStore) => {\n state.variables = state.variables.filter(v => v.id !== variableId);\n \n // Remove any references to the variable in the layers and set default value from schema\n const { registry } = useEditorStore.getState();\n \n // Helper function to clean variable references from props\n const cleanVariableReferences = (layer: ComponentLayer): ComponentLayer => {\n const updatedProps = { ...layer.props };\n let hasChanges = false;\n \n // Check each prop for variable references\n Object.entries(updatedProps).forEach(([propName, propValue]) => {\n if (propValue && typeof propValue === 'object' && propValue.__variableRef === variableId) {\n // This prop references the variable being removed\n // Get the default value from the schema\n const layerSchema = registry[layer.type]?.schema;\n if (layerSchema && layerSchema.shape && layerSchema.shape[propName]) {\n const defaultProps = getDefaultProps(layerSchema);\n updatedProps[propName] = defaultProps[propName];\n hasChanges = true;\n } else {\n // Fallback: remove the prop entirely if no schema default\n delete updatedProps[propName];\n hasChanges = true;\n }\n }\n });\n \n return hasChanges ? { ...layer, props: updatedProps } : layer;\n };\n \n // Update all pages and their layers\n state.pages = state.pages.map(page => \n visitLayer(page, null, cleanVariableReferences)\n );\n })),\n\n // Bind a component prop to a variable reference\n bindPropToVariable: (layerId, propName, variableId) => {\n // Store a special object as prop to indicate binding\n get().updateLayer(layerId, { [propName]: { __variableRef: variableId } });\n },\n\n // Unbind a component prop from a variable reference and set default value from schema\n unbindPropFromVariable: (layerId, propName) => {\n const { registry } = useEditorStore.getState();\n const layer = get().findLayerById(layerId);\n \n if (!layer) {\n console.warn(`Layer with ID ${layerId} not found.`);\n return;\n }\n\n // Get the default value from the schema\n const layerSchema = registry[layer.type]?.schema;\n let defaultValue: any = undefined;\n \n if (layerSchema && layerSchema.shape && layerSchema.shape[propName]) {\n const defaultProps = getDefaultProps(layerSchema);\n defaultValue = defaultProps[propName];\n }\n \n // If no default value found in schema, use empty string for string-like props\n if (defaultValue === undefined) {\n defaultValue = \"\";\n }\n\n get().updateLayer(layerId, { [propName]: defaultValue });\n },\n }\n)\n\n// Custom storage adapter (mimics localStorage API for createJSONStorage)\nconst conditionalLocalStorage = {\n getItem: (name: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve(null);\n }\n const value = localStorage.getItem(name);\n return Promise.resolve(value);\n },\n setItem: (name: string, value: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve();\n }\n localStorage.setItem(name, value);\n return Promise.resolve();\n },\n removeItem: (name: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve();\n }\n localStorage.removeItem(name);\n return Promise.resolve();\n },\n};\n\nconst useLayerStore = create(persist(temporal(store,\n {\n equality: (pastState, currentState) =>\n isDeepEqual(pastState, currentState),\n }\n), {\n name: \"layer-store\",\n version: 4,\n storage: createJSONStorage(() => conditionalLocalStorage),\n migrate: (persistedState: unknown, version: number) => {\n /* istanbul ignore if*/\n if (version === 1) {\n return migrateV1ToV2(persistedState as LayerStore);\n } else if (version === 2) {\n return migrateV2ToV3(persistedState as LayerStore);\n } else if (version === 3) {\n // New variable support: ensure variables array exists\n return { ...(persistedState as LayerStore), variables: [] as Variable[] } as LayerStore;\n }\n return persistedState;\n }\n}))\n\nexport { useLayerStore, countLayers, findAllParentLayersRecursive };\n",
+ "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { create, StateCreator } from 'zustand';\nimport { persist, createJSONStorage, StorageValue } from 'zustand/middleware'\nimport { produce } from 'immer';\nimport { temporal } from 'zundo';\nimport isDeepEqual from 'fast-deep-equal';\n\nimport { visitLayer, addLayer, hasLayerChildren, findLayerRecursive, createId, countLayers, duplicateWithNewIdsAndName, findAllParentLayersRecursive, migrateV1ToV2, migrateV2ToV3 } from '@/lib/ui-builder/store/layer-utils';\nimport { getDefaultProps } from '@/lib/ui-builder/store/schema-utils';\nimport { useEditorStore } from '@/lib/ui-builder/store/editor-store';\nimport { ComponentLayer, Variable } from '@/components/ui/ui-builder/types';\n\nconst DEFAULT_PAGE_PROPS = {\n className: \"p-4 flex flex-col gap-2\",\n};\n\nexport interface LayerStore {\n pages: ComponentLayer[];\n selectedLayerId: string | null;\n selectedPageId: string;\n variables: Variable[];\n immutableBindings: Record>; // layerId -> propName -> isImmutable\n initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => void;\n addComponentLayer: (layerType: string, parentId: string, parentPosition?: number) => void;\n addPageLayer: (pageId: string) => void;\n duplicateLayer: (layerId: string, parentId?: string) => void;\n removeLayer: (layerId: string) => void;\n updateLayer: (layerId: string, newProps: Record, layerRest?: Partial>) => void;\n selectLayer: (layerId: string) => void;\n selectPage: (pageId: string) => void;\n findLayerById: (layerId: string | null) => ComponentLayer | undefined;\n findLayersForPageId: (pageId: string) => ComponentLayer[];\n isLayerAPage: (layerId: string) => boolean;\n\n addVariable: (name: string, type: Variable['type'], defaultValue: any) => void;\n updateVariable: (variableId: string, updates: Partial>) => void;\n removeVariable: (variableId: string) => void;\n bindPropToVariable: (layerId: string, propName: string, variableId: string) => void;\n unbindPropFromVariable: (layerId: string, propName: string) => void;\n isBindingImmutable: (layerId: string, propName: string) => boolean;\n setImmutableBinding: (layerId: string, propName: string, isImmutable: boolean) => void; // Test helper\n}\n\nconst store: StateCreator = (set, get) => (\n {\n // Default to a single empty page\n pages: [\n {\n id: '1',\n type: 'div',\n name: 'Page 1',\n props: DEFAULT_PAGE_PROPS,\n children: [],\n }\n ],\n\n // Variables available for binding\n variables: [],\n // Track immutable bindings: layerId -> propName -> isImmutable\n immutableBindings: {},\n selectedLayerId: null,\n selectedPageId: '1',\n initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => {\n set({ pages, selectedPageId: selectedPageId || pages[0].id, selectedLayerId: selectedLayerId || null, variables: variables || [] });\n },\n findLayerById: (layerId: string | null) => {\n const { selectedPageId, findLayersForPageId, pages } = get();\n if (!layerId) return undefined;\n if (layerId === selectedPageId) {\n return pages.find(page => page.id === selectedPageId);\n }\n const layers = findLayersForPageId(selectedPageId);\n if (!layers) return undefined;\n return findLayerRecursive(layers, layerId);\n },\n findLayersForPageId: (pageId: string) => {\n const { pages } = get();\n const page = pages.find(page => page.id === pageId);\n if(page && hasLayerChildren(page)) {\n return page?.children || [];\n }\n return [];\n },\n\n isLayerAPage: (layerId: string) => {\n const { pages } = get();\n return pages.some(page => page.id === layerId);\n },\n\n addComponentLayer: (layerType: string, parentId: string, parentPosition?: number) => set(produce((state: LayerStore) => {\n const { registry } = useEditorStore.getState();\n const defaultProps = getDefaultProps(registry[layerType].schema);\n const defaultChildrenRaw = registry[layerType].defaultChildren;\n const defaultChildren = typeof defaultChildrenRaw === \"string\" ? defaultChildrenRaw : (defaultChildrenRaw?.map(child => duplicateWithNewIdsAndName(child, false)) || []);\n const defaultVariableBindings = registry[layerType].defaultVariableBindings || [];\n\n const initialProps = Object.entries(defaultProps).reduce((acc, [key, propDef]) => {\n if (key !== \"children\") {\n acc[key] = propDef;\n }\n return acc;\n }, {} as Record);\n\n const newLayer: ComponentLayer = {\n id: createId(),\n type: layerType,\n name: layerType,\n props: initialProps,\n children: defaultChildren,\n };\n\n // Apply default variable bindings\n for (const binding of defaultVariableBindings) {\n const variable = state.variables.find(v => v.id === binding.variableId);\n if (variable) {\n // Set the variable reference in the props\n newLayer.props[binding.propName] = { __variableRef: binding.variableId };\n \n // Track immutable bindings\n if (binding.immutable) {\n if (!state.immutableBindings[newLayer.id]) {\n state.immutableBindings[newLayer.id] = {};\n }\n state.immutableBindings[newLayer.id][binding.propName] = true;\n }\n }\n }\n\n // Traverse and update the pages to add the new layer\n const updatedPages = addLayer(state.pages, newLayer, parentId, parentPosition);\n // Directly mutate the state instead of returning a new object\n state.pages = updatedPages;\n })),\n\n addPageLayer: (pageName: string) => set(produce((state: LayerStore) => {\n const newPage: ComponentLayer = {\n id: createId(),\n type: 'div',\n name: pageName,\n props: DEFAULT_PAGE_PROPS,\n children: [],\n };\n return {\n pages: [...state.pages, newPage],\n selectedPageId: newPage.id,\n selectedLayerId: newPage.id,\n };\n })),\n\n duplicateLayer: (layerId: string) => set(produce((state: LayerStore) => {\n let layerToDuplicate: ComponentLayer | undefined;\n let parentId: string | undefined;\n let parentPosition: number | undefined;\n\n // Find the layer to duplicate\n state.pages.forEach((page) =>\n visitLayer(page, null, (layer, parent) => {\n if (layer.id === layerId) {\n layerToDuplicate = layer;\n parentId = parent?.id;\n if (parent && hasLayerChildren(parent)) {\n parentPosition = parent.children.indexOf(layer) + 1;\n }\n }\n return layer;\n })\n );\n if (!layerToDuplicate) {\n console.warn(`Layer with ID ${ layerId } not found.`);\n return;\n }\n\n const isNewLayerAPage = state.pages.some(page => page.id === layerId);\n\n const newLayer = duplicateWithNewIdsAndName(layerToDuplicate, true);\n\n if (isNewLayerAPage) {\n return {\n ...state,\n pages: [...state.pages, newLayer],\n selectedPageId: newLayer.id,\n };\n }\n\n //else add it as a child of the parent\n\n const updatedPages = addLayer(state.pages, newLayer, parentId, parentPosition);\n\n // Insert the duplicated layer\n return {\n ...state,\n pages: updatedPages\n };\n })),\n\n removeLayer: (layerId: string) => set(produce((state: LayerStore) => {\n const { selectedLayerId, pages } = get();\n\n let newSelectedLayerId = selectedLayerId;\n\n const isPage = state.pages.some(page => page.id === layerId);\n if (isPage && pages.length > 1) {\n const newPages = state.pages.filter(page => page.id !== layerId);\n return {\n ...state,\n pages: newPages,\n selectedPageId: newPages[0].id,\n };\n }\n\n // Traverse and update the pages to remove the specified layer\n const updatedPages = pages.map((page) =>\n visitLayer(page, null, (layer) => {\n\n if (hasLayerChildren(layer)) {\n\n // Remove the layer by filtering it out from the children\n const updatedChildren = layer.children.filter((child) => child.id !== layerId);\n return { ...layer, children: updatedChildren };\n }\n\n return layer;\n })\n );\n\n if (selectedLayerId === layerId) {\n // If the removed layer was selected, deselect it \n newSelectedLayerId = null;\n }\n return {\n ...state,\n selectedLayerId: newSelectedLayerId,\n pages: updatedPages,\n };\n })),\n\n updateLayer: (layerId: string, newProps: ComponentLayer['props'], layerRest?: Partial>) => set(\n produce((state: LayerStore) => {\n const { selectedPageId, findLayersForPageId, pages } = get();\n\n const pageExists = pages.some(page => page.id === selectedPageId);\n if (!pageExists) {\n console.warn(`No layers found for page ID: ${ selectedPageId }`);\n return state;\n }\n\n if (layerId === selectedPageId) {\n const updatedPages = pages.map(page =>\n page.id === selectedPageId\n ? { ...page, props: { ...page.props, ...newProps }, ...(layerRest || {}) }\n : page\n );\n return { ...state, pages: updatedPages };\n }\n\n const layers = findLayersForPageId(selectedPageId);\n\n\n // Visitor function to update layer properties\n const visitor = (layer: ComponentLayer): ComponentLayer => {\n if (layer.id === layerId) {\n return {\n ...layer,\n ...(layerRest || {}),\n props: { ...layer.props, ...newProps },\n } as ComponentLayer\n }\n return layer;\n };\n\n // Apply the visitor to update layers\n const updatedLayers = layers.map(layer => visitLayer(layer, null, visitor));\n\n const isUnchanged = updatedLayers.every((layer, index) => layer === layers[index]);\n\n if (isUnchanged) {\n console.warn(`Layer with ID ${ layerId } was not found.`);\n return state;\n }\n\n // Update the state with the modified layers\n const updatedPages = state.pages.map(page =>\n page.id === selectedPageId ? { ...page, children: updatedLayers } : page\n );\n\n return { ...state, pages: updatedPages };\n })\n ),\n\n\n selectLayer: (layerId: string) => set(produce((state: LayerStore) => {\n const { selectedPageId, findLayersForPageId } = get();\n const layers = findLayersForPageId(selectedPageId);\n if(selectedPageId === layerId) {\n return {\n selectedLayerId: layerId\n };\n }\n if (!layers) return state;\n const layer = findLayerRecursive(layers, layerId);\n if (layer) {\n return {\n selectedLayerId: layer.id\n };\n }\n return {};\n })),\n\n selectPage: (pageId: string) => set(produce((state: LayerStore) => {\n const page = state.pages.find(page => page.id === pageId);\n if (!page) return state;\n return {\n selectedPageId: pageId\n };\n })),\n\n // Add a new variable\n addVariable: (name, type, defaultValue) => set(produce((state: LayerStore) => {\n state.variables.push({ id: createId(), name, type, defaultValue });\n })),\n\n // Update an existing variable\n updateVariable: (variableId, updates) => set(produce((state: LayerStore) => {\n const v = state.variables.find(v => v.id === variableId);\n if (v) Object.assign(v, updates);\n })),\n\n // Remove a variable\n removeVariable: (variableId) => set(produce((state: LayerStore) => {\n state.variables = state.variables.filter(v => v.id !== variableId);\n \n // Remove any references to the variable in the layers and set default value from schema\n const { registry } = useEditorStore.getState();\n \n // Helper function to clean variable references from props\n const cleanVariableReferences = (layer: ComponentLayer): ComponentLayer => {\n const updatedProps = { ...layer.props };\n let hasChanges = false;\n \n // Check each prop for variable references\n Object.entries(updatedProps).forEach(([propName, propValue]) => {\n if (propValue && typeof propValue === 'object' && propValue.__variableRef === variableId) {\n // This prop references the variable being removed\n // Get the default value from the schema\n const layerSchema = registry[layer.type]?.schema;\n if (layerSchema && layerSchema.shape && layerSchema.shape[propName]) {\n const defaultProps = getDefaultProps(layerSchema);\n updatedProps[propName] = defaultProps[propName];\n hasChanges = true;\n } else {\n // Fallback: remove the prop entirely if no schema default\n delete updatedProps[propName];\n hasChanges = true;\n }\n }\n });\n \n return hasChanges ? { ...layer, props: updatedProps } : layer;\n };\n \n // Update all pages and their layers\n state.pages = state.pages.map(page => \n visitLayer(page, null, cleanVariableReferences)\n );\n })),\n\n // Bind a component prop to a variable reference\n bindPropToVariable: (layerId, propName, variableId) => {\n // Store a special object as prop to indicate binding\n get().updateLayer(layerId, { [propName]: { __variableRef: variableId } });\n },\n\n // Unbind a component prop from a variable reference and set default value from schema\n unbindPropFromVariable: (layerId, propName) => {\n // Check if the binding is immutable\n if (get().isBindingImmutable(layerId, propName)) {\n console.warn(`Cannot unbind immutable variable binding for ${propName} on layer ${layerId}`);\n return;\n }\n\n const { registry } = useEditorStore.getState();\n const layer = get().findLayerById(layerId);\n \n if (!layer) {\n console.warn(`Layer with ID ${layerId} not found.`);\n return;\n }\n\n // Get the default value from the schema\n const layerSchema = registry[layer.type]?.schema;\n let defaultValue: any = undefined;\n \n if (layerSchema && layerSchema.shape && layerSchema.shape[propName]) {\n const defaultProps = getDefaultProps(layerSchema);\n defaultValue = defaultProps[propName];\n }\n \n // If no default value found in schema, use empty string for string-like props\n if (defaultValue === undefined) {\n defaultValue = \"\";\n }\n\n get().updateLayer(layerId, { [propName]: defaultValue });\n },\n\n // Check if a binding is immutable\n isBindingImmutable: (layerId: string, propName: string) => {\n const { immutableBindings } = get();\n return immutableBindings[layerId]?.[propName] === true;\n },\n\n // Test helper\n setImmutableBinding: (layerId: string, propName: string, isImmutable: boolean) => {\n set(produce((state: LayerStore) => {\n if (!state.immutableBindings[layerId]) {\n state.immutableBindings[layerId] = {};\n }\n state.immutableBindings[layerId][propName] = isImmutable;\n }));\n },\n }\n)\n\n// Custom storage adapter (mimics localStorage API for createJSONStorage)\nconst conditionalLocalStorage = {\n getItem: (name: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve(null);\n }\n const value = localStorage.getItem(name);\n return Promise.resolve(value);\n },\n setItem: (name: string, value: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve();\n }\n localStorage.setItem(name, value);\n return Promise.resolve();\n },\n removeItem: (name: string): Promise => {\n const { persistLayerStoreConfig } = useEditorStore.getState();\n if (!persistLayerStoreConfig) {\n return Promise.resolve();\n }\n localStorage.removeItem(name);\n return Promise.resolve();\n },\n};\n\nconst useLayerStore = create(persist(temporal(store,\n {\n equality: (pastState, currentState) =>\n isDeepEqual(pastState, currentState),\n }\n), {\n name: \"layer-store\",\n version: 5,\n storage: createJSONStorage(() => conditionalLocalStorage),\n migrate: (persistedState: unknown, version: number) => {\n /* istanbul ignore if*/\n if (version === 1) {\n return migrateV1ToV2(persistedState as LayerStore);\n } else if (version === 2) {\n return migrateV2ToV3(persistedState as LayerStore);\n } else if (version === 3) {\n // New variable support: ensure variables array exists\n return { ...(persistedState as LayerStore), variables: [] as Variable[], immutableBindings: {} } as LayerStore;\n } else if (version === 4) {\n // New immutable bindings support: ensure immutableBindings object exists\n return { ...(persistedState as LayerStore), immutableBindings: {} } as LayerStore;\n }\n return persistedState;\n }\n}))\n\nexport { useLayerStore, countLayers, findAllParentLayersRecursive };\n",
"type": "registry:lib",
"target": "lib/ui-builder/store/layer-store.ts"
},
@@ -376,7 +376,7 @@
},
{
"path": "lib/ui-builder/store/editor-store.ts",
- "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { create, StateCreator } from 'zustand';\nimport { ComponentType as ReactComponentType } from \"react\";\nimport { RegistryEntry, ComponentRegistry } from '@/components/ui/ui-builder/types';\n\n\n\nexport interface EditorStore {\n previewMode: 'mobile' | 'tablet' | 'desktop' | 'responsive';\n setPreviewMode: (mode: 'mobile' | 'tablet' | 'desktop' | 'responsive') => void;\n\n registry: ComponentRegistry;\n\n initialize: (registry: ComponentRegistry, persistLayerStoreConfig: boolean) => void;\n getComponentDefinition: (type: string) => RegistryEntry> | undefined;\n\n persistLayerStoreConfig: boolean;\n setPersistLayerStoreConfig: (shouldPersist: boolean) => void;\n\n // Revision counter to track state changes for form revalidation\n revisionCounter: number;\n incrementRevision: () => void;\n}\n\nconst store: StateCreator = (set, get) => ({\n previewMode: 'responsive',\n setPreviewMode: (mode) => set({ previewMode: mode }),\n\n registry: {},\n\n initialize: (registry, persistLayerStoreConfig) => {\n set(state => ({ ...state, registry, persistLayerStoreConfig }));\n },\n getComponentDefinition: (type: string) => {\n const { registry } = get();\n if (!registry) {\n console.warn(\"Registry accessed via editor store before initialization.\");\n return undefined;\n }\n return registry[type];\n },\n\n persistLayerStoreConfig: true,\n setPersistLayerStoreConfig: (shouldPersist) => set({ persistLayerStoreConfig: shouldPersist }),\n\n revisionCounter: 0,\n incrementRevision: () => set(state => ({ revisionCounter: state.revisionCounter + 1 })),\n});\n\nexport const useEditorStore = create()(store);",
+ "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { create, StateCreator } from 'zustand';\nimport { ComponentType as ReactComponentType } from \"react\";\nimport { RegistryEntry, ComponentRegistry } from '@/components/ui/ui-builder/types';\n\n\n\nexport interface EditorStore {\n previewMode: 'mobile' | 'tablet' | 'desktop' | 'responsive';\n setPreviewMode: (mode: 'mobile' | 'tablet' | 'desktop' | 'responsive') => void;\n\n registry: ComponentRegistry;\n\n initialize: (registry: ComponentRegistry, persistLayerStoreConfig: boolean, allowPagesCreation: boolean, allowPagesDeletion: boolean, allowVariableEditing: boolean) => void;\n getComponentDefinition: (type: string) => RegistryEntry> | undefined;\n\n persistLayerStoreConfig: boolean;\n setPersistLayerStoreConfig: (shouldPersist: boolean) => void;\n\n // Revision counter to track state changes for form revalidation\n revisionCounter: number;\n incrementRevision: () => void;\n\n allowPagesCreation: boolean;\n setAllowPagesCreation: (allow: boolean) => void;\n allowPagesDeletion: boolean;\n setAllowPagesDeletion: (allow: boolean) => void;\n allowVariableEditing: boolean;\n setAllowVariableEditing: (allow: boolean) => void;\n}\n\nconst store: StateCreator = (set, get) => ({\n previewMode: 'responsive',\n setPreviewMode: (mode) => set({ previewMode: mode }),\n\n registry: {},\n\n initialize: (registry, persistLayerStoreConfig, allowPagesCreation, allowPagesDeletion, allowVariableEditing) => {\n set(state => ({ ...state, registry, persistLayerStoreConfig, allowPagesCreation, allowPagesDeletion, allowVariableEditing }));\n },\n getComponentDefinition: (type: string) => {\n const { registry } = get();\n if (!registry) {\n console.warn(\"Registry accessed via editor store before initialization.\");\n return undefined;\n }\n return registry[type];\n },\n\n persistLayerStoreConfig: true,\n setPersistLayerStoreConfig: (shouldPersist) => set({ persistLayerStoreConfig: shouldPersist }),\n\n revisionCounter: 0,\n incrementRevision: () => set(state => ({ revisionCounter: state.revisionCounter + 1 })),\n\n allowPagesCreation: true,\n setAllowPagesCreation: (allow) => set({ allowPagesCreation: allow }),\n allowPagesDeletion: true,\n setAllowPagesDeletion: (allow) => set({ allowPagesDeletion: allow }),\n allowVariableEditing: true,\n setAllowVariableEditing: (allow) => set({ allowVariableEditing: allow }),\n});\n\nexport const useEditorStore = create()(store);",
"type": "registry:lib",
"target": "lib/ui-builder/store/editor-store.ts"
},
@@ -388,13 +388,13 @@
},
{
"path": "lib/ui-builder/registry/form-field-overrides.tsx",
- "content": "import {\n FormControl,\n FormDescription,\n FormItem,\n FormLabel,\n} from \"@/components/ui/form\";\nimport { ChildrenSearchableSelect } from \"@/components/ui/ui-builder/internal/children-searchable-select\";\nimport {\n AutoFormInputComponentProps,\n ComponentLayer,\n FieldConfigFunction,\n} from \"@/components/ui/ui-builder/types\";\nimport IconNameField from \"@/components/ui/ui-builder/internal/iconname-field\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { useLayerStore } from \"../store/layer-store\";\nimport { isVariableReference } from \"../utils/variable-resolver\";\nimport { Link, Unlink } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { useEditorStore } from \"../store/editor-store\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport BreakpointClassNameControl from \"@/components/ui/ui-builder/internal/classname-control\";\nimport { Label } from \"@/components/ui/label\";\n\nexport const classNameFieldOverrides: FieldConfigFunction = (\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n layer,\n allowBinding = false\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n field,\n fieldProps,\n fieldConfigItem,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport const childrenFieldOverrides: FieldConfigFunction = (\n layer,\n allowBinding = false\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\nexport const iconNameFieldOverrides: FieldConfigFunction = (layer) => {\n return {\n fieldType: ({\n label,\n isRequired,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n ),\n };\n};\n\nexport const childrenAsTextareaFieldOverrides: FieldConfigFunction = (\n layer,\n allowBinding = false\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\nexport const childrenAsTipTapFieldOverrides: FieldConfigFunction = (\n layer,\n allowBinding = false\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n {\n console.log({ content });\n //if string call field.onChange\n if (typeof content === \"string\") {\n field.onChange(content);\n } else {\n console.warn(\"Tiptap content is not a string\");\n }\n }}\n {...fieldProps}\n />\n \n ),\n };\n};\n\nexport const commonFieldOverrides = (allowBinding = false) => {\n return {\n className: (layer: ComponentLayer) => classNameFieldOverrides(layer),\n children: (layer: ComponentLayer) => childrenFieldOverrides(layer),\n };\n};\n\nexport const commonVariableRenderParentOverrides = (propName: string) => {\n return {\n renderParent: ({ children }: { children: React.ReactNode }) => (\n {children} \n ),\n };\n};\n\nexport const textInputFieldOverrides = (\n layer: ComponentLayer,\n allowVariableBinding = false,\n propName: string\n) => {\n return {\n renderParent: allowVariableBinding\n ? ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n )\n : undefined,\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n field.onChange(e.target.value)}\n {...fieldProps}\n />\n \n ),\n };\n};\n\nexport const renderParentWithVariableBinding = ({\n children,\n}: {\n children: React.ReactNode;\n}) => (\n \n);\n\nexport function VariableBindingWrapper({\n propName,\n children,\n}: {\n propName: string;\n children: React.ReactNode;\n}) {\n const variables = useLayerStore((state) => state.variables);\n const selectedLayerId = useLayerStore((state) => state.selectedLayerId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n const unbindPropFromVariable = useLayerStore(\n (state) => state.unbindPropFromVariable\n );\n const bindPropToVariable = useLayerStore((state) => state.bindPropToVariable);\n\n const selectedLayer = findLayerById(selectedLayerId);\n\n // If variable binding is not allowed or no propName provided, just render the form wrapper\n if (!selectedLayer) {\n return <>{children}>;\n }\n\n const currentValue = selectedLayer.props[propName];\n const isCurrentlyBound = isVariableReference(currentValue);\n const boundVariable = isCurrentlyBound\n ? variables.find((v) => v.id === currentValue.__variableRef)\n : null;\n\n const handleBindToVariable = (variableId: string) => {\n bindPropToVariable(selectedLayer.id, propName, variableId);\n incrementRevision();\n };\n\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop\n const handleUnbind = () => {\n // Use the new unbind function which sets default value from schema\n unbindPropFromVariable(selectedLayer.id, propName);\n incrementRevision();\n };\n\n return (\n \n {isCurrentlyBound && boundVariable ? (\n // Bound state - show variable info and unbind button\n
\n
{propName.charAt(0).toUpperCase() + propName.slice(1)} \n
\n
\n \n \n
\n
\n
\n {boundVariable.name} \n \n {boundVariable.type}\n \n
\n
\n {String(boundVariable.defaultValue)}\n \n
\n
\n \n \n
\n \n \n \n \n \n Unbind Variable \n \n
\n
\n ) : (\n // Unbound state - show normal field with bind button\n <>\n
{children}
\n
\n
\n \n \n \n \n \n \n \n \n Bind Variable \n \n \n \n Bind to Variable\n
\n {variables.length > 0 ? (\n variables.map((variable) => (\n handleBindToVariable(variable.id)}\n className=\"flex flex-col items-start p-3\"\n >\n \n
\n
\n
\n {variable.name} \n \n {variable.type}\n \n
\n
\n {String(variable.defaultValue)}\n \n
\n
\n \n ))\n ) : (\n \n No variables defined\n
\n )}\n \n \n
\n >\n )}\n
\n );\n}\n\nexport function FormFieldWrapper({\n label,\n isRequired,\n fieldConfigItem,\n children,\n}: {\n label: string;\n isRequired?: boolean;\n fieldConfigItem?: { description?: React.ReactNode };\n children: React.ReactNode;\n}) {\n return (\n \n \n {label}\n {isRequired && * }\n \n {children} \n {fieldConfigItem?.description && (\n {fieldConfigItem.description} \n )}\n \n );\n}\n",
+ "content": "import {\n FormControl,\n FormDescription,\n FormItem,\n FormLabel,\n} from \"@/components/ui/form\";\nimport { ChildrenSearchableSelect } from \"@/components/ui/ui-builder/internal/children-searchable-select\";\nimport {\n AutoFormInputComponentProps,\n ComponentLayer,\n FieldConfigFunction,\n} from \"@/components/ui/ui-builder/types\";\nimport IconNameField from \"@/components/ui/ui-builder/internal/iconname-field\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { useLayerStore } from \"../store/layer-store\";\nimport { isVariableReference } from \"../utils/variable-resolver\";\nimport { Link, LockKeyhole, Unlink } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { useEditorStore } from \"../store/editor-store\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport BreakpointClassNameControl from \"@/components/ui/ui-builder/internal/classname-control\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\n\nexport const classNameFieldOverrides: FieldConfigFunction = (\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n layer,\n allowBinding = false\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n field,\n fieldProps,\n fieldConfigItem,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport const childrenFieldOverrides: FieldConfigFunction = (\n layer,\n allowBinding = false\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\nexport const iconNameFieldOverrides: FieldConfigFunction = (layer) => {\n return {\n fieldType: ({\n label,\n isRequired,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n ),\n };\n};\n\nexport const childrenAsTextareaFieldOverrides: FieldConfigFunction = (\n layer,\n allowBinding = false\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n \n \n ),\n };\n};\n\nexport const childrenAsTipTapFieldOverrides: FieldConfigFunction = (\n layer,\n allowBinding = false\n) => {\n return {\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n {\n console.log({ content });\n //if string call field.onChange\n if (typeof content === \"string\") {\n field.onChange(content);\n } else {\n console.warn(\"Tiptap content is not a string\");\n }\n }}\n {...fieldProps}\n />\n \n ),\n };\n};\n\nexport const commonFieldOverrides = (allowBinding = false) => {\n return {\n className: (layer: ComponentLayer) => classNameFieldOverrides(layer),\n children: (layer: ComponentLayer) => childrenFieldOverrides(layer),\n };\n};\n\nexport const commonVariableRenderParentOverrides = (propName: string) => {\n return {\n renderParent: ({ children }: { children: React.ReactNode }) => (\n {children} \n ),\n };\n};\n\nexport const textInputFieldOverrides = (\n layer: ComponentLayer,\n allowVariableBinding = false,\n propName: string\n) => {\n return {\n renderParent: allowVariableBinding\n ? ({ children }: { children: React.ReactNode }) => (\n \n {children}\n \n )\n : undefined,\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n \n field.onChange(e.target.value)}\n {...fieldProps}\n />\n \n ),\n };\n};\n\nexport const renderParentWithVariableBinding = ({\n children,\n}: {\n children: React.ReactNode;\n}) => (\n \n);\n\nexport function VariableBindingWrapper({\n propName,\n children,\n}: {\n propName: string;\n children: React.ReactNode;\n}) {\n const variables = useLayerStore((state) => state.variables);\n const selectedLayerId = useLayerStore((state) => state.selectedLayerId);\n const findLayerById = useLayerStore((state) => state.findLayerById);\n const isBindingImmutable = useLayerStore((state) => state.isBindingImmutable);\n const incrementRevision = useEditorStore((state) => state.incrementRevision);\n const unbindPropFromVariable = useLayerStore(\n (state) => state.unbindPropFromVariable\n );\n const bindPropToVariable = useLayerStore((state) => state.bindPropToVariable);\n\n const selectedLayer = findLayerById(selectedLayerId);\n\n // If variable binding is not allowed or no propName provided, just render the form wrapper\n if (!selectedLayer) {\n return <>{children}>;\n }\n\n const currentValue = selectedLayer.props[propName];\n const isCurrentlyBound = isVariableReference(currentValue);\n const boundVariable = isCurrentlyBound\n ? variables.find((v) => v.id === currentValue.__variableRef)\n : null;\n const isImmutable = isBindingImmutable(selectedLayer.id, propName);\n\n const handleBindToVariable = (variableId: string) => {\n bindPropToVariable(selectedLayer.id, propName, variableId);\n incrementRevision();\n };\n\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop\n const handleUnbind = () => {\n // Use the new unbind function which sets default value from schema\n unbindPropFromVariable(selectedLayer.id, propName);\n incrementRevision();\n };\n\n return (\n \n {isCurrentlyBound && boundVariable ? (\n // Bound state - show variable info and unbind button\n
\n
{propName.charAt(0).toUpperCase() + propName.slice(1)} \n
\n
\n \n \n
\n
\n
\n {boundVariable.name} \n \n {boundVariable.type}\n \n {isImmutable && (\n \n \n \n )}\n
\n
\n {String(boundVariable.defaultValue)}\n \n
\n
\n \n \n {!isImmutable && (\n
\n \n \n \n \n \n Unbind Variable \n \n )}\n
\n
\n ) : (\n // Unbound state - show normal field with bind button\n <>\n
{children}
\n
\n
\n \n \n \n \n \n \n \n \n Bind Variable \n \n \n \n Bind to Variable\n
\n {variables.length > 0 ? (\n variables.map((variable) => (\n handleBindToVariable(variable.id)}\n className=\"flex flex-col items-start p-3\"\n >\n \n
\n
\n
\n {variable.name} \n \n {variable.type}\n \n
\n
\n {String(variable.defaultValue)}\n \n
\n
\n \n ))\n ) : (\n \n No variables defined\n
\n )}\n \n \n
\n >\n )}\n
\n );\n}\n\nexport function FormFieldWrapper({\n label,\n isRequired,\n fieldConfigItem,\n children,\n}: {\n label: string;\n isRequired?: boolean;\n fieldConfigItem?: { description?: React.ReactNode };\n children: React.ReactNode;\n}) {\n return (\n \n \n {label}\n {isRequired && * }\n \n {children} \n {fieldConfigItem?.description && (\n {fieldConfigItem.description} \n )}\n \n );\n}\n",
"type": "registry:lib",
"target": "lib/ui-builder/registry/form-field-overrides.tsx"
},
{
"path": "lib/ui-builder/registry/complex-component-definitions.ts",
- "content": "import { ComponentRegistry } from '@/components/ui/ui-builder/types';\nimport { z } from 'zod';\nimport { Button } from '@/components/ui/button';\nimport { Badge } from '@/components/ui/badge';\nimport { Flexbox } from '@/components/ui/ui-builder/flexbox';\nimport { Grid } from '@/components/ui/ui-builder/grid';\nimport { CodePanel } from '@/components/ui/ui-builder/code-panel';\nimport { Markdown } from \"@/components/ui/ui-builder/markdown\";\nimport { Icon, iconNames } from \"@/components/ui/ui-builder/icon\";\nimport { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from \"@/components/ui/accordion\";\nimport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from \"@/components/ui/card\";\nimport { classNameFieldOverrides, childrenFieldOverrides, iconNameFieldOverrides, commonFieldOverrides, childrenAsTipTapFieldOverrides, textInputFieldOverrides, commonVariableRenderParentOverrides } from \"@/lib/ui-builder/registry/form-field-overrides\";\nimport { ComponentLayer } from '@/components/ui/ui-builder/types';\nimport { ExampleComp } from '../../../app/platform/example-comp';\n\nexport const complexComponentDefinitions: ComponentRegistry = {\n ExampleComp: {\n component: ExampleComp,\n schema: z.object({\n name: z.string().default(\"World\"),\n age: z.coerce.number().default(20),\n birthDate: z.coerce.date().default(new Date()),\n married: z.boolean().default(false),\n work: z.enum([\"developer\", \"designer\", \"manager\"]).default(\"developer\"),\n children: z.any().optional(),\n }),\n from: \"@/components/ui/ui-builder/example-comp\",\n fieldOverrides: {\n name: (layer: ComponentLayer)=> textInputFieldOverrides(layer, true, \"name\"),\n age: (layer: ComponentLayer)=> commonVariableRenderParentOverrides(\"age\"),\n married: (layer: ComponentLayer)=> commonVariableRenderParentOverrides(\"married\"),\n children: (layer: ComponentLayer)=> childrenFieldOverrides(layer),\n },\n },\n Button: {\n component: Button,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n asChild: z.boolean().optional(),\n variant: z\n .enum([\n \"default\",\n \"destructive\",\n \"outline\",\n \"secondary\",\n \"ghost\",\n \"link\",\n ])\n .default(\"default\"),\n size: z.enum([\"default\", \"sm\", \"lg\", \"icon\"]).default(\"default\"),\n }),\n from: \"@/components/ui/button\",\n defaultChildren: [\n {\n id: \"button-text\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Button\",\n } satisfies ComponentLayer,\n ],\n fieldOverrides: commonFieldOverrides()\n },\n Badge: {\n component: Badge,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n variant: z\n .enum([\"default\", \"secondary\", \"destructive\", \"outline\"])\n .default(\"default\"),\n }),\n from: \"@/components/ui/badge\",\n defaultChildren: [\n {\n id: \"badge-text\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Badge\",\n } satisfies ComponentLayer,\n ],\n fieldOverrides: commonFieldOverrides()\n },\n Flexbox: {\n component: Flexbox,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n direction: z\n .enum([\"row\", \"column\", \"rowReverse\", \"columnReverse\"])\n .default(\"row\"),\n justify: z\n .enum([\"start\", \"end\", \"center\", \"between\", \"around\", \"evenly\"])\n .default(\"start\"),\n align: z\n .enum([\"start\", \"end\", \"center\", \"baseline\", \"stretch\"])\n .default(\"start\"),\n wrap: z.enum([\"wrap\", \"nowrap\", \"wrapReverse\"]).default(\"nowrap\"),\n gap: z\n .preprocess(\n (val) => (typeof val === 'number' ? String(val) : val),\n z.enum([\"0\", \"1\", \"2\", \"4\", \"8\"]).default(\"1\")\n )\n .transform(Number),\n }),\n from: \"@/components/ui/ui-builder/flexbox\",\n fieldOverrides: commonFieldOverrides()\n },\n Grid: {\n component: Grid,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n columns: z\n .enum([\"auto\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\"])\n .default(\"1\"),\n autoRows: z.enum([\"none\", \"min\", \"max\", \"fr\"]).default(\"none\"),\n justify: z\n .enum([\"start\", \"end\", \"center\", \"between\", \"around\", \"evenly\"])\n .default(\"start\"),\n align: z\n .enum([\"start\", \"end\", \"center\", \"baseline\", \"stretch\"])\n .default(\"start\"),\n templateRows: z\n .enum([\"none\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\"])\n .default(\"none\")\n .transform(val => (val === \"none\" ? val : Number(val))),\n gap: z\n .preprocess(\n (val) => (typeof val === 'number' ? String(val) : val),\n z.enum([\"0\", \"1\", \"2\", \"4\", \"8\"]).default(\"0\")\n )\n .transform(Number),\n }),\n from: \"@/components/ui/ui-builder/grid\",\n fieldOverrides: commonFieldOverrides()\n },\n CodePanel: {\n component: CodePanel,\n schema: z.object({\n className: z.string().optional(),\n }),\n from: \"@/components/ui/ui-builder/code-panel\",\n fieldOverrides: {\n className:(layer)=> classNameFieldOverrides(layer)\n }\n },\n Markdown: {\n component: Markdown,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: \"@/components/ui/ui-builder/markdown\",\n fieldOverrides: {\n className:(layer)=> classNameFieldOverrides(layer),\n children: (layer)=> childrenAsTipTapFieldOverrides(layer)\n }\n },\n Icon: {\n component: Icon,\n schema: z.object({\n className: z.string().optional(),\n iconName: z.enum([...iconNames]).default(\"Image\"),\n size: z.enum([\"small\", \"medium\", \"large\"]).default(\"medium\"),\n color: z\n .enum([\n \"accent\",\n \"accentForeground\",\n \"primary\",\n \"primaryForeground\",\n \"secondary\",\n \"secondaryForeground\",\n \"destructive\",\n \"destructiveForeground\",\n \"muted\",\n \"mutedForeground\",\n \"background\",\n \"foreground\",\n ])\n .optional(),\n rotate: z.enum([\"none\", \"90\", \"180\", \"270\"]).default(\"none\"),\n }),\n from: \"@/components/ui/ui-builder/icon\",\n fieldOverrides: {\n className:(layer)=> classNameFieldOverrides(layer),\n iconName: (layer)=> iconNameFieldOverrides(layer)\n }\n },\n\n //Accordion\n Accordion: {\n component: Accordion,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n type: z.enum([\"single\", \"multiple\"]).default(\"single\"),\n collapsible: z.boolean().optional(),\n }),\n from: \"@/components/ui/accordion\",\n defaultChildren: [\n {\n id: \"acc-item-1\",\n type: \"AccordionItem\",\n name: \"AccordionItem\",\n props: {\n value: \"item-1\",\n },\n children: [\n {\n id: \"acc-trigger-1\",\n type: \"AccordionTrigger\",\n name: \"AccordionTrigger\",\n props: {},\n children: [\n {\n id: \"WEz8Yku\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Item #1\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"acc-content-1\",\n type: \"AccordionContent\",\n name: \"AccordionContent\",\n props: {},\n children: [\n {\n id: \"acc-content-1-text-1\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Content Text\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n },\n {\n id: \"acc-item-2\",\n type: \"AccordionItem\",\n name: \"AccordionItem\",\n props: {\n value: \"item-2\",\n },\n children: [\n {\n id: \"acc-trigger-2\",\n type: \"AccordionTrigger\",\n name: \"AccordionTrigger\",\n props: {},\n children: [\n {\n id: \"acc-trigger-2-text-1\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Item #2\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"acc-content-2\",\n type: \"AccordionContent\",\n name: \"AccordionContent (Copy)\",\n props: {},\n children: [\n {\n id: \"acc-content-2-text-1\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Content Text\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n },\n ],\n fieldOverrides: commonFieldOverrides()\n },\n AccordionItem: {\n component: AccordionItem,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n value: z.string(),\n }),\n from: \"@/components/ui/accordion\",\n defaultChildren: [\n {\n id: \"acc-trigger-1\",\n type: \"AccordionTrigger\",\n name: \"AccordionTrigger\",\n props: {},\n children: [\n {\n id: \"WEz8Yku\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Item #1\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"acc-content-1\",\n type: \"AccordionContent\",\n name: \"AccordionContent\",\n props: {},\n children: [\n {\n id: \"acc-content-1-text-1\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Content Text\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n fieldOverrides: commonFieldOverrides()\n },\n AccordionTrigger: {\n component: AccordionTrigger,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: \"@/components/ui/accordion\",\n fieldOverrides: {\n className:(layer)=> classNameFieldOverrides(layer),\n children: (layer)=> childrenFieldOverrides(layer)\n }\n },\n AccordionContent: {\n component: AccordionContent,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: \"@/components/ui/accordion\",\n fieldOverrides: commonFieldOverrides()\n },\n\n //Card\n Card: {\n component: Card,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n defaultChildren: [\n {\n id: \"card-header\",\n type: \"CardHeader\",\n name: \"CardHeader\",\n props: {},\n children: [\n {\n id: \"card-title\",\n type: \"CardTitle\",\n name: \"CardTitle\",\n props: {},\n children: [\n {\n id: \"card-title-text\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Card Title\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"card-description\",\n type: \"CardDescription\",\n name: \"CardDescription\",\n props: {},\n children: [\n {\n id: \"card-description-text\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Card Description\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n },\n {\n id: \"card-content\",\n type: \"CardContent\",\n name: \"CardContent\",\n props: {},\n children: [\n {\n id: \"card-content-paragraph\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Card Content\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"card-footer\",\n type: \"CardFooter\",\n name: \"CardFooter\",\n props: {},\n children: [\n {\n id: \"card-footer-paragraph\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Card Footer\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n fieldOverrides: commonFieldOverrides()\n },\n CardHeader: {\n component: CardHeader,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n CardFooter: {\n component: CardFooter,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n CardTitle: {\n component: CardTitle,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n CardDescription: {\n component: CardDescription,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n CardContent: {\n component: CardContent,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n};",
+ "content": "import { ComponentRegistry } from '@/components/ui/ui-builder/types';\nimport { z } from 'zod';\nimport { Button } from '@/components/ui/button';\nimport { Badge } from '@/components/ui/badge';\nimport { Flexbox } from '@/components/ui/ui-builder/flexbox';\nimport { Grid } from '@/components/ui/ui-builder/grid';\nimport { CodePanel } from '@/components/ui/ui-builder/code-panel';\nimport { Markdown } from \"@/components/ui/ui-builder/markdown\";\nimport { Icon, iconNames } from \"@/components/ui/ui-builder/icon\";\nimport { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from \"@/components/ui/accordion\";\nimport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from \"@/components/ui/card\";\nimport { classNameFieldOverrides, childrenFieldOverrides, iconNameFieldOverrides, commonFieldOverrides, childrenAsTipTapFieldOverrides, textInputFieldOverrides, commonVariableRenderParentOverrides } from \"@/lib/ui-builder/registry/form-field-overrides\";\nimport { ComponentLayer } from '@/components/ui/ui-builder/types';\n\nexport const complexComponentDefinitions: ComponentRegistry = {\n Button: {\n component: Button,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n asChild: z.boolean().optional(),\n variant: z\n .enum([\n \"default\",\n \"destructive\",\n \"outline\",\n \"secondary\",\n \"ghost\",\n \"link\",\n ])\n .default(\"default\"),\n size: z.enum([\"default\", \"sm\", \"lg\", \"icon\"]).default(\"default\"),\n }),\n from: \"@/components/ui/button\",\n defaultChildren: [\n {\n id: \"button-text\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Button\",\n } satisfies ComponentLayer,\n ],\n fieldOverrides: commonFieldOverrides()\n },\n Badge: {\n component: Badge,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n variant: z\n .enum([\"default\", \"secondary\", \"destructive\", \"outline\"])\n .default(\"default\"),\n }),\n from: \"@/components/ui/badge\",\n defaultChildren: [\n {\n id: \"badge-text\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Badge\",\n } satisfies ComponentLayer,\n ],\n fieldOverrides: commonFieldOverrides()\n },\n Flexbox: {\n component: Flexbox,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n direction: z\n .enum([\"row\", \"column\", \"rowReverse\", \"columnReverse\"])\n .default(\"row\"),\n justify: z\n .enum([\"start\", \"end\", \"center\", \"between\", \"around\", \"evenly\"])\n .default(\"start\"),\n align: z\n .enum([\"start\", \"end\", \"center\", \"baseline\", \"stretch\"])\n .default(\"start\"),\n wrap: z.enum([\"wrap\", \"nowrap\", \"wrapReverse\"]).default(\"nowrap\"),\n gap: z\n .preprocess(\n (val) => (typeof val === 'number' ? String(val) : val),\n z.enum([\"0\", \"1\", \"2\", \"4\", \"8\"]).default(\"1\")\n )\n .transform(Number),\n }),\n from: \"@/components/ui/ui-builder/flexbox\",\n fieldOverrides: commonFieldOverrides()\n },\n Grid: {\n component: Grid,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n columns: z\n .enum([\"auto\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\"])\n .default(\"1\"),\n autoRows: z.enum([\"none\", \"min\", \"max\", \"fr\"]).default(\"none\"),\n justify: z\n .enum([\"start\", \"end\", \"center\", \"between\", \"around\", \"evenly\"])\n .default(\"start\"),\n align: z\n .enum([\"start\", \"end\", \"center\", \"baseline\", \"stretch\"])\n .default(\"start\"),\n templateRows: z\n .enum([\"none\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\"])\n .default(\"none\")\n .transform(val => (val === \"none\" ? val : Number(val))),\n gap: z\n .preprocess(\n (val) => (typeof val === 'number' ? String(val) : val),\n z.enum([\"0\", \"1\", \"2\", \"4\", \"8\"]).default(\"0\")\n )\n .transform(Number),\n }),\n from: \"@/components/ui/ui-builder/grid\",\n fieldOverrides: commonFieldOverrides()\n },\n CodePanel: {\n component: CodePanel,\n schema: z.object({\n className: z.string().optional(),\n }),\n from: \"@/components/ui/ui-builder/code-panel\",\n fieldOverrides: {\n className:(layer)=> classNameFieldOverrides(layer)\n }\n },\n Markdown: {\n component: Markdown,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: \"@/components/ui/ui-builder/markdown\",\n fieldOverrides: {\n className:(layer)=> classNameFieldOverrides(layer),\n children: (layer)=> childrenAsTipTapFieldOverrides(layer)\n }\n },\n Icon: {\n component: Icon,\n schema: z.object({\n className: z.string().optional(),\n iconName: z.enum([...iconNames]).default(\"Image\"),\n size: z.enum([\"small\", \"medium\", \"large\"]).default(\"medium\"),\n color: z\n .enum([\n \"accent\",\n \"accentForeground\",\n \"primary\",\n \"primaryForeground\",\n \"secondary\",\n \"secondaryForeground\",\n \"destructive\",\n \"destructiveForeground\",\n \"muted\",\n \"mutedForeground\",\n \"background\",\n \"foreground\",\n ])\n .optional(),\n rotate: z.enum([\"none\", \"90\", \"180\", \"270\"]).default(\"none\"),\n }),\n from: \"@/components/ui/ui-builder/icon\",\n fieldOverrides: {\n className:(layer)=> classNameFieldOverrides(layer),\n iconName: (layer)=> iconNameFieldOverrides(layer)\n }\n },\n\n //Accordion\n Accordion: {\n component: Accordion,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n type: z.enum([\"single\", \"multiple\"]).default(\"single\"),\n collapsible: z.boolean().optional(),\n }),\n from: \"@/components/ui/accordion\",\n defaultChildren: [\n {\n id: \"acc-item-1\",\n type: \"AccordionItem\",\n name: \"AccordionItem\",\n props: {\n value: \"item-1\",\n },\n children: [\n {\n id: \"acc-trigger-1\",\n type: \"AccordionTrigger\",\n name: \"AccordionTrigger\",\n props: {},\n children: [\n {\n id: \"WEz8Yku\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Item #1\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"acc-content-1\",\n type: \"AccordionContent\",\n name: \"AccordionContent\",\n props: {},\n children: [\n {\n id: \"acc-content-1-text-1\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Content Text\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n },\n {\n id: \"acc-item-2\",\n type: \"AccordionItem\",\n name: \"AccordionItem\",\n props: {\n value: \"item-2\",\n },\n children: [\n {\n id: \"acc-trigger-2\",\n type: \"AccordionTrigger\",\n name: \"AccordionTrigger\",\n props: {},\n children: [\n {\n id: \"acc-trigger-2-text-1\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Item #2\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"acc-content-2\",\n type: \"AccordionContent\",\n name: \"AccordionContent (Copy)\",\n props: {},\n children: [\n {\n id: \"acc-content-2-text-1\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Content Text\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n },\n ],\n fieldOverrides: commonFieldOverrides()\n },\n AccordionItem: {\n component: AccordionItem,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n value: z.string(),\n }),\n from: \"@/components/ui/accordion\",\n defaultChildren: [\n {\n id: \"acc-trigger-1\",\n type: \"AccordionTrigger\",\n name: \"AccordionTrigger\",\n props: {},\n children: [\n {\n id: \"WEz8Yku\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Item #1\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"acc-content-1\",\n type: \"AccordionContent\",\n name: \"AccordionContent\",\n props: {},\n children: [\n {\n id: \"acc-content-1-text-1\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Accordion Content Text\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n fieldOverrides: commonFieldOverrides()\n },\n AccordionTrigger: {\n component: AccordionTrigger,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: \"@/components/ui/accordion\",\n fieldOverrides: {\n className:(layer)=> classNameFieldOverrides(layer),\n children: (layer)=> childrenFieldOverrides(layer)\n }\n },\n AccordionContent: {\n component: AccordionContent,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: \"@/components/ui/accordion\",\n fieldOverrides: commonFieldOverrides()\n },\n\n //Card\n Card: {\n component: Card,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n defaultChildren: [\n {\n id: \"card-header\",\n type: \"CardHeader\",\n name: \"CardHeader\",\n props: {},\n children: [\n {\n id: \"card-title\",\n type: \"CardTitle\",\n name: \"CardTitle\",\n props: {},\n children: [\n {\n id: \"card-title-text\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Card Title\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"card-description\",\n type: \"CardDescription\",\n name: \"CardDescription\",\n props: {},\n children: [\n {\n id: \"card-description-text\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Card Description\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n },\n {\n id: \"card-content\",\n type: \"CardContent\",\n name: \"CardContent\",\n props: {},\n children: [\n {\n id: \"card-content-paragraph\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Card Content\",\n } satisfies ComponentLayer,\n ],\n },\n {\n id: \"card-footer\",\n type: \"CardFooter\",\n name: \"CardFooter\",\n props: {},\n children: [\n {\n id: \"card-footer-paragraph\",\n type: \"span\",\n name: \"span\",\n props: {},\n children: \"Card Footer\",\n } satisfies ComponentLayer,\n ],\n },\n ],\n fieldOverrides: commonFieldOverrides()\n },\n CardHeader: {\n component: CardHeader,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n CardFooter: {\n component: CardFooter,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n CardTitle: {\n component: CardTitle,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n CardDescription: {\n component: CardDescription,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n CardContent: {\n component: CardContent,\n schema: z.object({\n className: z.string().optional(),\n children: z.any().optional(),\n }),\n from: '@/components/ui/card',\n fieldOverrides: commonFieldOverrides()\n },\n};",
"type": "registry:lib",
"target": "lib/ui-builder/registry/complex-component-definitions.ts"
},