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} + ) : ( + (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 ( + + ); +}; + +// 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" > - - + {(!isPage || allowPagesCreation) && ( + + )} + {(!isPage || allowPagesDeletion) && ( + + )} ); }; 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 && (
- {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
\n
\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
\n
\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
\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 \n {errors.name && (\n

{errors.name}

\n )}\n
\n\n
\n \n \n
\n\n
\n \n \n {errors.defaultValue && (\n

{errors.defaultValue}

\n )}\n
\n\n
\n \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 \n {errors.name && (\n

{errors.name}

\n )}\n
\n\n
\n \n \n
\n\n
\n \n \n {errors.defaultValue && (\n

{errors.defaultValue}

\n )}\n
\n\n
\n \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\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
\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 \n {errors.name && (\n

{errors.name}

\n )}\n
\n\n
\n \n \n
\n\n
\n \n \n {errors.defaultValue && (\n

{errors.defaultValue}

\n )}\n
\n\n
\n \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 \n {errors.name && (\n

{errors.name}

\n )}\n
\n\n
\n \n \n
\n\n
\n \n \n {errors.defaultValue && (\n

{errors.defaultValue}

\n )}\n
\n\n
\n \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\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 );\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 );\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 <h3 className=\"text-base font-medium mb-4\">\n Type: {selectedLayer.type.replaceAll(\"_\", \"\")}\n </h3>\n </>\n )}\n\n {!selectedLayer && (\n <>\n <h2 className=\"text-xl font-semibold mb-2\">Component Properties</h2>\n <p>No component selected</p>\n </>\n )}\n {selectedLayer && (\n <ComponentPropsAutoForm\n key={selectedLayer.id}\n componentRegistry={componentRegistry}\n selectedLayerId={selectedLayer.id}\n removeLayer={handleDeleteLayer}\n duplicateLayer={handleDuplicateLayer}\n updateLayer={handleUpdateLayer}\n addComponentLayer={handleAddComponentLayer}\n />\n )}\n </div>\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<string, any>,\n rest?: Partial<Omit<ComponentLayer, \"props\">>\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<ComponentPropsAutoFormProps> = ({\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<typeof schema> & { 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<string, any> = {};\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<string, any>).forEach(key => {\n const originalValue = selectedLayer.props[key];\n const newValue = (dataProps as Record<string, any>)[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<string, any> = {};\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 <AutoForm\n key={formKey} // Force re-render when layer changes or undo/redo occurs\n formSchema={autoFormSchema}\n values={formValues} // Use the memoized and transformed values\n onParsedValuesChange={onParsedValuesChange}\n fieldConfig={autoFormFieldConfig}\n className=\"space-y-4 mt-4\"\n >\n <Button\n type=\"button\"\n variant=\"secondary\"\n className=\"mt-4 w-full\"\n onClick={handleDuplicateLayer}\n >\n Duplicate Component\n </Button>\n <Button\n type=\"button\"\n variant=\"destructive\"\n className=\"mt-4 w-full\"\n onClick={handleDeleteLayer}\n >\n Delete Component\n </Button>\n </AutoForm>\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 <h2 className=\"text-xl font-semibold mb-2\">\n {selectedLayer ? nameForLayer(selectedLayer) : \"\"} Properties\n </h2>\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<PropsPanelProps> = ({ 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<string, any>,\n rest?: Partial<Omit<ComponentLayer, \"props\">>\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 <div className={className}>\n {selectedLayer && (\n <>\n <Title />\n <h3 className=\"text-base font-medium mb-4\">\n Type: {selectedLayer.type.replaceAll(\"_\", \"\")}\n </h3>\n </>\n )}\n\n {!selectedLayer && (\n <>\n <h2 className=\"text-xl font-semibold mb-2\">Component Properties</h2>\n <p>No component selected</p>\n </>\n )}\n {selectedLayer && (\n <ComponentPropsAutoForm\n key={selectedLayer.id}\n componentRegistry={componentRegistry}\n selectedLayerId={selectedLayer.id}\n removeLayer={handleDeleteLayer}\n duplicateLayer={handleDuplicateLayer}\n updateLayer={handleUpdateLayer}\n addComponentLayer={handleAddComponentLayer}\n />\n )}\n </div>\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<string, any>,\n rest?: Partial<Omit<ComponentLayer, \"props\">>\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<ComponentPropsAutoFormProps> = ({\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<typeof schema> & {\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<string, any> = {};\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<string, any>).forEach((key) => {\n const originalValue = selectedLayer.props[key];\n const newValue = (dataProps as Record<string, any>)[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<string, any> = {};\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 <AutoForm\n key={formKey} // Force re-render when layer changes or undo/redo occurs\n formSchema={autoFormSchema}\n values={formValues} // Use the memoized and transformed values\n onParsedValuesChange={onParsedValuesChange}\n fieldConfig={autoFormFieldConfig}\n className=\"space-y-4 mt-4\"\n >\n {(!isPage || allowPagesCreation) && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n className=\"mt-4 w-full\"\n onClick={handleDuplicateLayer}\n data-testid={`button-Duplicate ,${isPage ? \"Page\" : \"Component\"}`}\n >\n Duplicate {isPage ? \"Page\" : \"Component\"}\n </Button>\n )}\n {(!isPage || allowPagesDeletion) && (\n <Button\n type=\"button\"\n variant=\"destructive\"\n className=\"mt-4 w-full\"\n onClick={handleDeleteLayer}\n data-testid={`button-Delete ,${isPage ? \"Page\" : \"Component\"}`}\n >\n Delete {isPage ? \"Page\" : \"Component\"}\n </Button>\n )}\n </AutoForm>\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 <h2 className=\"text-xl font-semibold mb-2\">\n {selectedLayer ? nameForLayer(selectedLayer) : \"\"} Properties\n </h2>\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<KeyCombination[]>(\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 <div\n className=\"flex items-center justify-between bg-background px-2 md:px-6 py-4 border-b\"\n style={style}\n >\n <div className=\"flex items-center gap-2\">\n <h1 className=\"block text-lg md:text-2xl font-bold whitespace-nowrap\">\n UI Builder\n </h1>\n <div className=\"flex h-10 w-px bg-border\"></div>\n <PagesPopover />\n {useCanvas && <PreviewModeToggle />}\n </div>\n\n <div className=\"w-full flex items-center justify-end gap-2\">\n {/* Action Buttons for Larger Screens */}\n <div className=\"hidden md:flex space-x-2\">\n <ActionButtons\n canUndo={canUndo}\n canRedo={canRedo}\n onUndo={handleUndo}\n onRedo={handleRedo}\n onOpenPreview={handleOpenPreview}\n onOpenExport={handleOpenExport}\n />\n <div className=\"h-10 flex w-px bg-border\"></div>\n </div>\n\n <ModeToggle />\n\n {/* Dropdown for Smaller Screens */}\n <div className=\"flex md:hidden space-x-2\">\n <div className=\"h-10 flex w-px bg-border\"></div>\n <ResponsiveDropdown\n canUndo={canUndo}\n canRedo={canRedo}\n onUndo={handleUndo}\n onRedo={handleRedo}\n onOpenPreview={handleOpenPreview}\n onOpenExport={handleOpenExport}\n />\n </div>\n </div>\n\n {/* **Dialogs Controlled by NavBar State** */}\n <PreviewDialog\n isOpen={isPreviewModalOpen}\n onOpenChange={setIsPreviewModalOpen}\n page={page}\n componentRegistry={componentRegistry}\n />\n <CodeDialog\n isOpen={isExportModalOpen}\n onOpenChange={setIsExportModalOpen}\n />\n </div>\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<ActionButtonsProps> = ({\n canUndo,\n canRedo,\n onUndo,\n onRedo,\n onOpenPreview,\n onOpenExport,\n}) => {\n return (\n <>\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n onClick={onUndo}\n variant=\"secondary\"\n size=\"icon\"\n disabled={!canUndo}\n className=\"flex flex-col justify-center\"\n >\n <span className=\"sr-only\">Undo</span>\n <Undo className=\"w-4 h-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent className=\"flex items-center gap-2\">\n Undo\n <CommandShortcut className=\"ml-0 text-sm leading-3\">\n ⌘Z\n </CommandShortcut>\n </TooltipContent>\n </Tooltip>\n\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n onClick={onRedo}\n variant=\"secondary\"\n size=\"icon\"\n disabled={!canRedo}\n className=\"flex flex-col justify-center\"\n >\n <span className=\"sr-only\">Redo</span>\n <Redo className=\"w-4 h-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent className=\"flex items-center gap-2\">\n Redo\n <CommandShortcut className=\"ml-0 text-sm leading-3\">\n ⌘+⇧+Z\n </CommandShortcut>\n </TooltipContent>\n </Tooltip>\n\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n onClick={onOpenPreview}\n variant=\"secondary\"\n size=\"icon\"\n className=\"flex flex-col justify-center\"\n >\n <span className=\"sr-only\">Preview</span>\n <Eye className=\"w-4 h-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent className=\"flex items-center gap-2\">\n Preview\n <CommandShortcut className=\"ml-0 text-sm leading-3\">\n ⌘+⇧+P\n </CommandShortcut>\n </TooltipContent>\n </Tooltip>\n\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n onClick={onOpenExport}\n variant=\"secondary\"\n size=\"icon\"\n className=\"flex flex-col justify-center\"\n >\n <span className=\"sr-only\">Export</span>\n <FileUp className=\"w-4 h-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent className=\"flex items-center gap-2\">\n Export Code\n <CommandShortcut className=\"ml-0 text-sm leading-3\">\n ⌘+⇧+E\n </CommandShortcut>\n </TooltipContent>\n </Tooltip>\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<ResponsiveDropdownProps> = ({\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 <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant=\"outline\" size=\"icon\">\n <span className=\"sr-only\">Actions</span>\n <MoreVertical className=\"w-4 h-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" style={style}>\n <DropdownMenuItem\n className=\"gap-2\"\n onClick={onUndo}\n disabled={!canUndo}\n >\n <Undo className=\"w-4 h-4\" />\n Undo\n <span className=\"ml-auto text-xs text-muted-foreground\">⌘Z</span>\n </DropdownMenuItem>\n <DropdownMenuItem\n className=\"gap-2\"\n onClick={onRedo}\n disabled={!canRedo}\n >\n <Redo className=\"w-4 h-4\" />\n Redo\n <span className=\"ml-auto text-xs text-muted-foreground\">⌘+⇧+Z</span>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem className=\"gap-2\" onClick={onOpenPreview}>\n <Eye className=\"w-4 h-4\" />\n Preview\n <span className=\"ml-auto text-xs text-muted-foreground\">⌘+⇧+P</span>\n </DropdownMenuItem>\n <DropdownMenuItem className=\"gap-2\" onClick={onOpenExport}>\n <FileUp className=\"w-4 h-4\" />\n Export\n <span className=\"ml-auto text-xs text-muted-foreground\">⌘+⇧+E</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\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<PreviewDialogProps> = ({\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 <Dialog open={isOpen} onOpenChange={onOpenChange}>\n <DialogTrigger />\n <DialogContentWithZIndex\n className=\"max-w-[calc(100dvw)] max-h-[calc(100dvh)] overflow-auto p-0 gap-0\"\n style={style}\n >\n <DialogHeader>\n <DialogTitle className=\"py-3 bg-yellow-600 text-center\">\n <span className=\"text-lg font-semibold\">Page Preview</span>\n </DialogTitle>\n </DialogHeader>\n <LayerRenderer\n className=\"w-full h-full flex flex-col overflow-x-hidden\"\n page={page}\n componentRegistry={componentRegistry}\n />\n </DialogContentWithZIndex>\n </Dialog>\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<CodeDialogProps> = ({ 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 <Dialog open={isOpen} onOpenChange={onOpenChange}>\n <DialogTrigger />\n <DialogContentWithZIndex\n className=\"sm:max-w-[625px] max-h-[625px]\"\n style={style}\n >\n <DialogHeader>\n <DialogTitle>Generated Code</DialogTitle>\n </DialogHeader>\n <CodePanel />\n </DialogContentWithZIndex>\n </Dialog>\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 <DropdownMenu>\n <Tooltip>\n <DropdownMenuTrigger asChild>\n <TooltipTrigger asChild>\n <Button variant=\"outline\" size=\"icon\">\n <SunIcon className=\"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0\" />\n <MoonIcon className=\"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100\" />\n <span className=\"sr-only\">Toggle theme</span>\n </Button>\n </TooltipTrigger>\n </DropdownMenuTrigger>\n <TooltipContent>Toggle theme</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\" style={style}>\n <DropdownMenuItem onClick={handleSetLightTheme}>Light</DropdownMenuItem>\n <DropdownMenuItem onClick={handleSetDarkTheme}>Dark</DropdownMenuItem>\n <DropdownMenuItem onClick={handleSetSystemTheme}>\n System\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\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<string | null>(\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<HTMLFormElement>) => {\n e.preventDefault();\n handleAddPageLayer(textInputValue);\n },\n [handleAddPageLayer, textInputValue]\n );\n\n const handleTextInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n setTextInputValue(e.target.value);\n },\n [setTextInputValue]\n );\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent<HTMLInputElement>) => {\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 <form className=\"w-full\" onSubmit={handleSubmit}>\n <div className=\"w-full flex items-center space-x-2\">\n <Input\n className=\"w-full flex-grow\"\n placeholder=\"New page name...\"\n value={textInputValue}\n onChange={handleTextInputChange}\n onKeyDown={handleKeyDown}\n />\n <Button type=\"submit\" variant=\"secondary\">\n <PlusIcon className=\"w-4 h-4\" />\n </Button>\n </div>\n </form>\n );\n return (\n <div className=\"relative flex justify-center\">\n <Popover open={open} onOpenChange={setOpen}>\n <Tooltip>\n <PopoverTrigger asChild>\n <TooltipTrigger asChild>\n <Button\n variant=\"outline\"\n size=\"default\"\n className=\"max-w-30 overflow-hidden\"\n >\n {selectedPageData?.name}\n </Button>\n </TooltipTrigger>\n </PopoverTrigger>\n <TooltipContent>Select page</TooltipContent>\n </Tooltip>\n <PopoverContent className=\"w-[300px] p-0\" style={style}>\n <Command>\n <CommandInput\n placeholder=\"Select page or create new...\"\n value={inputValue}\n onValueChange={setInputValue}\n />\n <CommandList>\n <CommandEmpty>\n No pages found\n {textInputForm}\n </CommandEmpty>\n {pages.map((page) => (\n <PageItem\n key={page.id}\n selectedPageId={selectedPageId}\n page={page}\n onSelect={handleSelect}\n />\n ))}\n <CommandSeparator />\n <CommandGroup heading=\"Create new page\">\n <CommandItem>{textInputForm}</CommandItem>\n </CommandGroup>\n </CommandList>\n </Command>\n </PopoverContent>\n </Popover>\n </div>\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 <CommandItem\n value={page.name}\n onSelect={handleSelect}\n className={cn(selectedPageId === page.id && \"font-bold\")}\n >\n {selectedPageId === page.id ? (\n <CheckIcon className=\"w-4 h-4 mr-2\" />\n ) : null}\n {page.name}\n </CommandItem>\n );\n};\n\nconst DialogContentWithZIndex = forwardRef<\n React.ElementRef<typeof DialogContent>,\n React.ComponentPropsWithoutRef<typeof DialogContent>\n>(({ className, children, ...props }, ref) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n return (\n <DialogPortal>\n <DialogOverlay style={style} />\n <DialogContent\n ref={ref}\n className={cn(\n \"fixed left-[50%] top-[50%] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n className\n )}\n {...props}\n >\n {children}\n <DialogClose className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n <X className=\"h-4 w-4 rounded-full p-1\" />\n <span className=\"sr-only\">Close</span>\n </DialogClose>\n </DialogContent>\n </DialogPortal>\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: <Smartphone className=\"h-4 w-4\" />,\n tablet: <Tablet className=\"h-4 w-4\" />,\n desktop: <Monitor className=\"h-4 w-4\" />,\n responsive: <Maximize className=\"h-4 w-4\" />,\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 <DropdownMenu>\n <Tooltip>\n <DropdownMenuTrigger asChild>\n <TooltipTrigger asChild>\n <Button variant=\"outline\" size=\"icon\">\n {previewModeIcon}\n <span className=\"sr-only\">Select screen size</span>\n </Button>\n </TooltipTrigger>\n </DropdownMenuTrigger>\n <TooltipContent>Select screen size</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\" style={style}>\n <DropdownMenuItem\n onSelect={handleSelectMobile}\n className={\n previewMode === \"mobile\"\n ? \"bg-secondary text-secondary-foreground\"\n : \"\"\n }\n >\n <Smartphone className=\"mr-2 h-4 w-4\" />\n <span>Mobile</span>\n </DropdownMenuItem>\n <DropdownMenuItem\n onSelect={handleSelectTablet}\n className={\n previewMode === \"tablet\"\n ? \"bg-secondary text-secondary-foreground\"\n : \"\"\n }\n >\n <Tablet className=\"mr-2 h-4 w-4\" />\n <span>Tablet</span>\n </DropdownMenuItem>\n <DropdownMenuItem\n onSelect={handleSelectDesktop}\n className={\n previewMode === \"desktop\"\n ? \"bg-secondary text-secondary-foreground\"\n : \"\"\n }\n >\n <Monitor className=\"mr-2 h-4 w-4\" />\n <span>Desktop</span>\n </DropdownMenuItem>\n <DropdownMenuItem\n onSelect={handleSelectResponsive}\n className={\n previewMode === \"responsive\"\n ? \"bg-secondary text-secondary-foreground\"\n : \"\"\n }\n >\n <Maximize className=\"mr-2 h-4 w-4\" />\n <span>Responsive</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\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<KeyCombination[]>(\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 <div\n className=\"flex items-center justify-between bg-background px-2 md:px-6 py-4 border-b\"\n style={style}\n >\n <div className=\"flex items-center gap-2\">\n <h1 className=\"block text-lg md:text-2xl font-bold whitespace-nowrap\">\n UI Builder\n </h1>\n <div className=\"flex h-10 w-px bg-border\"></div>\n <PagesPopover />\n {useCanvas && <PreviewModeToggle />}\n </div>\n\n <div className=\"w-full flex items-center justify-end gap-2\">\n {/* Action Buttons for Larger Screens */}\n <div className=\"hidden md:flex space-x-2\">\n <ActionButtons\n canUndo={canUndo}\n canRedo={canRedo}\n onUndo={handleUndo}\n onRedo={handleRedo}\n onOpenPreview={handleOpenPreview}\n onOpenExport={handleOpenExport}\n />\n <div className=\"h-10 flex w-px bg-border\"></div>\n </div>\n\n <ModeToggle />\n\n {/* Dropdown for Smaller Screens */}\n <div className=\"flex md:hidden space-x-2\">\n <div className=\"h-10 flex w-px bg-border\"></div>\n <ResponsiveDropdown\n canUndo={canUndo}\n canRedo={canRedo}\n onUndo={handleUndo}\n onRedo={handleRedo}\n onOpenPreview={handleOpenPreview}\n onOpenExport={handleOpenExport}\n />\n </div>\n </div>\n\n {/* **Dialogs Controlled by NavBar State** */}\n <PreviewDialog\n isOpen={isPreviewModalOpen}\n onOpenChange={setIsPreviewModalOpen}\n page={page}\n componentRegistry={componentRegistry}\n />\n <CodeDialog\n isOpen={isExportModalOpen}\n onOpenChange={setIsExportModalOpen}\n />\n </div>\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<ActionButtonsProps> = ({\n canUndo,\n canRedo,\n onUndo,\n onRedo,\n onOpenPreview,\n onOpenExport,\n}) => {\n return (\n <>\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n onClick={onUndo}\n variant=\"secondary\"\n size=\"icon\"\n disabled={!canUndo}\n className=\"flex flex-col justify-center\"\n >\n <span className=\"sr-only\">Undo</span>\n <Undo className=\"w-4 h-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent className=\"flex items-center gap-2\">\n Undo\n <CommandShortcut className=\"ml-0 text-sm leading-3\">\n ⌘Z\n </CommandShortcut>\n </TooltipContent>\n </Tooltip>\n\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n onClick={onRedo}\n variant=\"secondary\"\n size=\"icon\"\n disabled={!canRedo}\n className=\"flex flex-col justify-center\"\n >\n <span className=\"sr-only\">Redo</span>\n <Redo className=\"w-4 h-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent className=\"flex items-center gap-2\">\n Redo\n <CommandShortcut className=\"ml-0 text-sm leading-3\">\n ⌘+⇧+Z\n </CommandShortcut>\n </TooltipContent>\n </Tooltip>\n\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n onClick={onOpenPreview}\n variant=\"secondary\"\n size=\"icon\"\n className=\"flex flex-col justify-center\"\n >\n <span className=\"sr-only\">Preview</span>\n <Eye className=\"w-4 h-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent className=\"flex items-center gap-2\">\n Preview\n <CommandShortcut className=\"ml-0 text-sm leading-3\">\n ⌘+⇧+P\n </CommandShortcut>\n </TooltipContent>\n </Tooltip>\n\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n onClick={onOpenExport}\n variant=\"secondary\"\n size=\"icon\"\n className=\"flex flex-col justify-center\"\n >\n <span className=\"sr-only\">Export</span>\n <FileUp className=\"w-4 h-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent className=\"flex items-center gap-2\">\n Export Code\n <CommandShortcut className=\"ml-0 text-sm leading-3\">\n ⌘+⇧+E\n </CommandShortcut>\n </TooltipContent>\n </Tooltip>\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<ResponsiveDropdownProps> = ({\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 <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant=\"outline\" size=\"icon\">\n <span className=\"sr-only\">Actions</span>\n <MoreVertical className=\"w-4 h-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" style={style}>\n <DropdownMenuItem\n className=\"gap-2\"\n onClick={onUndo}\n disabled={!canUndo}\n >\n <Undo className=\"w-4 h-4\" />\n Undo\n <span className=\"ml-auto text-xs text-muted-foreground\">⌘Z</span>\n </DropdownMenuItem>\n <DropdownMenuItem\n className=\"gap-2\"\n onClick={onRedo}\n disabled={!canRedo}\n >\n <Redo className=\"w-4 h-4\" />\n Redo\n <span className=\"ml-auto text-xs text-muted-foreground\">⌘+⇧+Z</span>\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem className=\"gap-2\" onClick={onOpenPreview}>\n <Eye className=\"w-4 h-4\" />\n Preview\n <span className=\"ml-auto text-xs text-muted-foreground\">⌘+⇧+P</span>\n </DropdownMenuItem>\n <DropdownMenuItem className=\"gap-2\" onClick={onOpenExport}>\n <FileUp className=\"w-4 h-4\" />\n Export\n <span className=\"ml-auto text-xs text-muted-foreground\">⌘+⇧+E</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\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<PreviewDialogProps> = ({\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 <Dialog open={isOpen} onOpenChange={onOpenChange}>\n <DialogTrigger />\n <DialogContentWithZIndex\n className=\"max-w-[calc(100dvw)] max-h-[calc(100dvh)] overflow-auto p-0 gap-0\"\n style={style}\n >\n <DialogHeader>\n <DialogTitle className=\"py-3 bg-yellow-600 text-center\">\n <span className=\"text-lg font-semibold\">Page Preview</span>\n </DialogTitle>\n </DialogHeader>\n <LayerRenderer\n className=\"w-full h-full flex flex-col overflow-x-hidden\"\n page={page}\n componentRegistry={componentRegistry}\n />\n </DialogContentWithZIndex>\n </Dialog>\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<CodeDialogProps> = ({ 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 <Dialog open={isOpen} onOpenChange={onOpenChange}>\n <DialogTrigger />\n <DialogContentWithZIndex\n className=\"sm:max-w-[625px] max-h-[625px]\"\n style={style}\n >\n <DialogHeader>\n <DialogTitle>Generated Code</DialogTitle>\n </DialogHeader>\n <CodePanel />\n </DialogContentWithZIndex>\n </Dialog>\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 <DropdownMenu>\n <Tooltip>\n <DropdownMenuTrigger asChild>\n <TooltipTrigger asChild>\n <Button variant=\"outline\" size=\"icon\">\n <SunIcon className=\"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0\" />\n <MoonIcon className=\"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100\" />\n <span className=\"sr-only\">Toggle theme</span>\n </Button>\n </TooltipTrigger>\n </DropdownMenuTrigger>\n <TooltipContent>Toggle theme</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\" style={style}>\n <DropdownMenuItem onClick={handleSetLightTheme}>Light</DropdownMenuItem>\n <DropdownMenuItem onClick={handleSetDarkTheme}>Dark</DropdownMenuItem>\n <DropdownMenuItem onClick={handleSetSystemTheme}>\n System\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\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<string | null>(\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<HTMLFormElement>) => {\n e.preventDefault();\n handleAddPageLayer(textInputValue);\n },\n [handleAddPageLayer, textInputValue]\n );\n\n const handleTextInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n setTextInputValue(e.target.value);\n },\n [setTextInputValue]\n );\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent<HTMLInputElement>) => {\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 <form className=\"w-full\" onSubmit={handleSubmit}>\n <div className=\"w-full flex items-center space-x-2\">\n <Input\n className=\"w-full flex-grow\"\n placeholder=\"New page name...\"\n value={textInputValue}\n onChange={handleTextInputChange}\n onKeyDown={handleKeyDown}\n />\n <Button type=\"submit\" variant=\"secondary\">\n <PlusIcon className=\"w-4 h-4\" />\n </Button>\n </div>\n </form>\n );\n return (\n <div className=\"relative flex justify-center\">\n <Popover open={open} onOpenChange={setOpen}>\n <Tooltip>\n <PopoverTrigger asChild>\n <TooltipTrigger asChild>\n <Button\n variant=\"outline\"\n size=\"default\"\n className=\"max-w-30 overflow-hidden\"\n >\n {selectedPageData?.name}\n </Button>\n </TooltipTrigger>\n </PopoverTrigger>\n <TooltipContent>Select page</TooltipContent>\n </Tooltip>\n <PopoverContent className=\"w-[300px] p-0\" style={style}>\n <Command>\n <CommandInput\n placeholder=\"Select page or create new...\"\n value={inputValue}\n onValueChange={setInputValue}\n />\n <CommandList>\n <CommandEmpty>\n No pages found\n {allowPagesCreation && textInputForm}\n </CommandEmpty>\n {pages.map((page) => (\n <PageItem\n key={page.id}\n selectedPageId={selectedPageId}\n page={page}\n onSelect={handleSelect}\n />\n ))}\n <CommandSeparator />\n {allowPagesCreation && (\n <CommandGroup heading=\"Create new page\">\n <CommandItem>{textInputForm}</CommandItem>\n </CommandGroup>\n )}\n </CommandList>\n </Command>\n </PopoverContent>\n </Popover>\n </div>\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 <CommandItem\n value={page.name}\n onSelect={handleSelect}\n className={cn(selectedPageId === page.id && \"font-bold\")}\n >\n {selectedPageId === page.id ? (\n <CheckIcon className=\"w-4 h-4 mr-2\" />\n ) : null}\n {page.name}\n </CommandItem>\n );\n};\n\nconst DialogContentWithZIndex = forwardRef<\n React.ElementRef<typeof DialogContent>,\n React.ComponentPropsWithoutRef<typeof DialogContent>\n>(({ className, children, ...props }, ref) => {\n const style = useMemo(() => ({ zIndex: Z_INDEX + 1 }), []);\n return (\n <DialogPortal>\n <DialogOverlay style={style} />\n <DialogContent\n ref={ref}\n className={cn(\n \"fixed left-[50%] top-[50%] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n className\n )}\n {...props}\n >\n {children}\n <DialogClose className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n <X className=\"h-4 w-4 rounded-full p-1\" />\n <span className=\"sr-only\">Close</span>\n </DialogClose>\n </DialogContent>\n </DialogPortal>\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: <Smartphone className=\"h-4 w-4\" />,\n tablet: <Tablet className=\"h-4 w-4\" />,\n desktop: <Monitor className=\"h-4 w-4\" />,\n responsive: <Maximize className=\"h-4 w-4\" />,\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 <DropdownMenu>\n <Tooltip>\n <DropdownMenuTrigger asChild>\n <TooltipTrigger asChild>\n <Button variant=\"outline\" size=\"icon\">\n {previewModeIcon}\n <span className=\"sr-only\">Select screen size</span>\n </Button>\n </TooltipTrigger>\n </DropdownMenuTrigger>\n <TooltipContent>Select screen size</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\" style={style}>\n <DropdownMenuItem\n onSelect={handleSelectMobile}\n className={\n previewMode === \"mobile\"\n ? \"bg-secondary text-secondary-foreground\"\n : \"\"\n }\n >\n <Smartphone className=\"mr-2 h-4 w-4\" />\n <span>Mobile</span>\n </DropdownMenuItem>\n <DropdownMenuItem\n onSelect={handleSelectTablet}\n className={\n previewMode === \"tablet\"\n ? \"bg-secondary text-secondary-foreground\"\n : \"\"\n }\n >\n <Tablet className=\"mr-2 h-4 w-4\" />\n <span>Tablet</span>\n </DropdownMenuItem>\n <DropdownMenuItem\n onSelect={handleSelectDesktop}\n className={\n previewMode === \"desktop\"\n ? \"bg-secondary text-secondary-foreground\"\n : \"\"\n }\n >\n <Monitor className=\"mr-2 h-4 w-4\" />\n <span>Desktop</span>\n </DropdownMenuItem>\n <DropdownMenuItem\n onSelect={handleSelectResponsive}\n className={\n previewMode === \"responsive\"\n ? \"bg-secondary text-secondary-foreground\"\n : \"\"\n }\n >\n <Maximize className=\"mr-2 h-4 w-4\" />\n <span>Responsive</span>\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\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<MenuProps> = ({\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 <div\n className=\"fixed\"\n style={style}\n >\n <span\n className={cn(\n \"h-5 group flex items-center rounded-bl-full rounded-r-full bg-blue-500/85 p-0 text-sm font-semibold text-secondary-foreground shadow-sm ring-1 ring-inset ring-blue-500 hover:bg-secondary/85 hover:h-10 hover:ring-2 transition-all duration-200 ease-in-out overflow-hidden cursor-pointer hover:cursor-auto\",\n popoverOpen ? \"h-10 ring-2\" : \"\"\n )}\n >\n <ChevronRight\n className={cn(\n \"h-5 w-5 text-secondary-foreground group-hover:size-8 transition-all duration-200 ease-in-out group-hover:opacity-30\",\n popoverOpen ? \"size-8 opacity-30\" : \"\"\n )}\n />\n\n <div\n className={cn(\n \"flex flex-nowrap overflow-hidden max-w-0 group-hover:max-w-xs transition-all duration-200 ease-in-out\",\n popoverOpen ? \"max-w-xs\" : \"\"\n )}\n >\n {hasChildrenInSchema && (\n <AddComponentsPopover\n parentLayerId={layerId}\n className=\"flex-shrink w-min inline-flex\"\n onOpenChange={setPopoverOpen}\n >\n <div\n className={cn(\n buttonVariantsValues,\n \"cursor-pointer\"\n )}\n >\n <span className=\"sr-only\">Add Component</span>\n <Plus className=\"h-5 w-5 text-secondary-foreground\" />\n </div>\n </AddComponentsPopover>\n )}\n <div\n className={cn(\n buttonVariantsValues,\n \"cursor-pointer\"\n )}\n onClick={handleDuplicateComponent}\n >\n <span className=\"sr-only\">Duplicate</span>\n <Copy className=\"h-5 w-5 text-secondary-foreground\" />\n </div>\n <div\n className={cn(\n buttonVariantsValues,\n \"rounded-r-full mr-1 cursor-pointer\"\n )}\n onClick={handleDeleteComponent}\n >\n <span className=\"sr-only\">Delete</span>\n <Trash className=\"h-5 w-5 text-secondary-foreground\" />\n </div>\n </div>\n </span>\n </div>\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<MenuProps> = ({\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 <div\n className=\"fixed\"\n style={style}\n >\n <span\n className={cn(\n \"h-5 group flex items-center rounded-bl-full rounded-r-full bg-blue-500/85 p-0 text-sm font-semibold text-secondary-foreground shadow-sm ring-1 ring-inset ring-blue-500 hover:bg-secondary/85 hover:h-10 hover:ring-2 transition-all duration-200 ease-in-out overflow-hidden cursor-pointer hover:cursor-auto\",\n popoverOpen ? \"h-10 ring-2\" : \"\"\n )}\n >\n <ChevronRight\n className={cn(\n \"h-5 w-5 text-secondary-foreground group-hover:size-8 transition-all duration-200 ease-in-out group-hover:opacity-30\",\n popoverOpen ? \"size-8 opacity-30\" : \"\"\n )}\n />\n\n <div\n className={cn(\n \"flex flex-nowrap overflow-hidden max-w-0 group-hover:max-w-xs transition-all duration-200 ease-in-out\",\n popoverOpen ? \"max-w-xs\" : \"\"\n )}\n >\n {canRenderAddChild && (\n <AddComponentsPopover\n parentLayerId={layerId}\n className=\"flex-shrink w-min inline-flex\"\n onOpenChange={setPopoverOpen}\n >\n <div\n className={cn(\n buttonVariantsValues,\n \"cursor-pointer\"\n )}\n >\n <span className=\"sr-only\">Add Component</span>\n <Plus className=\"h-5 w-5 text-secondary-foreground\" />\n </div>\n </AddComponentsPopover>\n )}\n {canDuplicate && (\n <div\n className={cn(\n buttonVariantsValues,\n \"cursor-pointer\"\n )}\n onClick={handleDuplicateComponent}\n >\n <span className=\"sr-only\">Duplicate {isLayerAPage ? \"Page\" : \"Component\"}</span>\n <Copy className=\"h-5 w-5 text-secondary-foreground\" />\n </div>\n )}\n {canDelete && (\n <div\n className={cn(\n buttonVariantsValues,\n \"rounded-r-full mr-1 cursor-pointer\"\n )}\n onClick={handleDeleteComponent}\n >\n <span className=\"sr-only\">Delete {isLayerAPage ? \"Page\" : \"Component\"}</span>\n <Trash className=\"h-5 w-5 text-secondary-foreground\" />\n </div>\n )}\n </div>\n </span>\n </div>\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<EditorPanelProps> = ({ 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 <div id=\"editor-panel-container\" className=\"overflow-visible pt-3 pb-10 pr-20\">\n <LayerRenderer page={selectedPage} editorConfig={editorConfig} componentRegistry={componentRegistry} />\n </div>\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 <div\n id=\"editor-panel-container\"\n className={cn(\n \"flex flex-col relative size-full bg-fixed bg-[radial-gradient(hsl(var(--border))_1px,hsl(var(--primary)/0.05)_1px)] [background-size:16px_16px] will-change-auto\",\n className\n )}\n >\n {useCanvas ? (\n <InteractiveCanvas\n frameId=\"editor-panel-frame\"\n disableWheel={layers.length === 0}\n disablePinch={layers.length === 0}\n disableDrag={!isMobileScreen}\n >\n <IframeWrapper\n key={previewMode}\n frameId=\"editor-panel-frame\"\n resizable={previewMode === \"responsive\" && layers.length > 0}\n className={cn(`block`, widthClass)}\n >\n {renderer}\n </IframeWrapper>\n </InteractiveCanvas>\n ) : (\n renderer\n )}\n <AddComponentsPopover\n parentLayerId={selectedPageId}\n >\n <Button\n variant=\"secondary\"\n size=\"icon\"\n className=\"absolute bottom-2 md:left-2 left-4 flex items-center rounded-full bg-secondary md:p-4 p-6 shadow\"\n >\n <Plus className=\"h-5 w-5 text-secondary-foreground\" />\n </Button>\n </AddComponentsPopover>\n </div>\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<EditorPanelProps> = ({ 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 <div id=\"editor-panel-container\" className=\"overflow-visible pt-3 pb-10 pr-20\">\n <LayerRenderer page={selectedPage} editorConfig={editorConfig} componentRegistry={componentRegistry} />\n </div>\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 <div\n id=\"editor-panel-container\"\n className={cn(\n \"flex flex-col relative size-full bg-fixed bg-[radial-gradient(hsl(var(--border))_1px,hsl(var(--primary)/0.05)_1px)] [background-size:16px_16px] will-change-auto\",\n className\n )}\n >\n {useCanvas ? (\n <InteractiveCanvas\n frameId=\"editor-panel-frame\"\n disableWheel={layers.length === 0}\n disablePinch={layers.length === 0}\n disableDrag={!isMobileScreen}\n >\n <IframeWrapper\n key={previewMode}\n frameId=\"editor-panel-frame\"\n resizable={previewMode === \"responsive\" && layers.length > 0}\n className={cn(`block`, widthClass)}\n >\n {renderer}\n </IframeWrapper>\n </InteractiveCanvas>\n ) : (\n renderer\n )}\n <AddComponentsPopover\n parentLayerId={selectedPageId}\n >\n <Button\n variant=\"secondary\"\n size=\"icon\"\n className=\"absolute bottom-2 md:left-2 left-4 flex items-center rounded-full bg-secondary md:p-4 p-6 shadow\"\n >\n <Plus className=\"h-5 w-5 text-secondary-foreground\" />\n </Button>\n </AddComponentsPopover>\n </div>\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<ClickableWrapperProps> = ({\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<DOMRect | null>(null);\n const [touchPosition, setTouchPosition] = useState<{\n x: number;\n y: number;\n } | null>(null);\n const wrapperRef = useRef<HTMLSpanElement | null>(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<HTMLDivElement>) => {\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 <span\n data-testid=\"clickable-overlay\"\n className=\"contents\" // Preserves layout\n ref={wrapperRef}\n >\n {children}\n </span>\n\n {isSelected && boundingRect && (\n <LayerMenu\n layerId={layer.id}\n x={boundingRect.left + window.scrollX}\n y={boundingRect.bottom + window.scrollY}\n zIndex={totalLayers + zIndex}\n width={boundingRect.width}\n height={boundingRect.height}\n handleDuplicateComponent={onDuplicateLayer}\n handleDeleteComponent={onDeleteLayer}\n />\n )}\n\n {boundingRect && (\n <div\n onClick={handleClick}\n // onDoubleClick={handleDoubleClick}\n className={cn(\n \"fixed box-border hover:border-blue-300 hover:border-2\",\n isSelected ? \"border-2 border-blue-500 hover:border-blue-500\" : \"\"\n )}\n onWheel={handleWheel}\n onTouchStart={handleTouchStart}\n onTouchMove={handleTouchMove}\n onTouchEnd={handleTouchEnd}\n style={style}\n >\n {/* {small label with layer type floating above the bounding box} */}\n {isSelected && (\n <span className=\"absolute top-[-16px] left-[-2px] text-xs text-white bg-blue-500 px-[1px] whitespace-nowrap\">\n {layer.name?.toLowerCase().startsWith(layer.type.toLowerCase())\n ? layer.type.replaceAll(\"_\", \"\")\n : `${layer.name} (${layer.type.replaceAll(\"_\", \"\")})`}\n </span>\n )}\n </div>\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<ClickableWrapperProps> = ({\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<DOMRect | null>(null);\n const [touchPosition, setTouchPosition] = useState<{\n x: number;\n y: number;\n } | null>(null);\n const wrapperRef = useRef<HTMLSpanElement | null>(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<HTMLDivElement>) => {\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 <span\n data-testid=\"clickable-overlay\"\n className=\"contents\" // Preserves layout\n ref={wrapperRef}\n >\n {children}\n </span>\n\n {isSelected && boundingRect && (\n <LayerMenu\n layerId={layer.id}\n x={boundingRect.left + window.scrollX}\n y={boundingRect.bottom + window.scrollY}\n zIndex={totalLayers + zIndex}\n width={boundingRect.width}\n height={boundingRect.height}\n handleDuplicateComponent={onDuplicateLayer}\n handleDeleteComponent={onDeleteLayer}\n />\n )}\n\n {boundingRect && (\n <div\n onClick={handleClick}\n // onDoubleClick={handleDoubleClick}\n className={cn(\n \"fixed box-border hover:border-blue-300 hover:border-2\",\n isSelected ? \"border-2 border-blue-500 hover:border-blue-500\" : \"\"\n )}\n onWheel={handleWheel}\n onTouchStart={handleTouchStart}\n onTouchMove={handleTouchMove}\n onTouchEnd={handleTouchEnd}\n style={style}\n >\n {/* {small label with layer type floating above the bounding box} */}\n {isSelected && (\n <span className=\"absolute top-[-16px] left-[-2px] text-xs text-white bg-blue-500 px-[1px] whitespace-nowrap\">\n {layer.name?.toLowerCase().startsWith(layer.type.toLowerCase())\n ? layer.type.replaceAll(\"_\", \"\")\n : `${layer.name} (${layer.type.replaceAll(\"_\", \"\")})`}\n </span>\n )}\n </div>\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<string, any>, layerRest?: Partial<Omit<ComponentLayer, 'props'>>) => 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<Omit<Variable, 'id'>>) => 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<LayerStore, [], []> = (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<string, any>);\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<Omit<ComponentLayer, 'props'>>) => 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<string | null> => {\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<void> => {\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<void> => {\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<LayerStore>(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<string, Record<string, boolean>>; // 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<string, any>, layerRest?: Partial<Omit<ComponentLayer, 'props'>>) => 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<Omit<Variable, 'id'>>) => 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<LayerStore, [], []> = (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<string, any>);\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<Omit<ComponentLayer, 'props'>>) => 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<string | null> => {\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<void> => {\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<void> => {\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<LayerStore>(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<ReactComponentType<any>> | 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<EditorStore, [], []> = (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<EditorStore>()(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<ReactComponentType<any>> | 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<EditorStore, [], []> = (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<EditorStore>()(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 <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <BreakpointClassNameControl\n value={field.value}\n onChange={field.onChange}\n />\n </FormFieldWrapper>\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 <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <ChildrenSearchableSelect\n layer={layer}\n onChange={field.onChange}\n {...fieldProps}\n />\n </FormFieldWrapper>\n ),\n };\n};\n\nexport const iconNameFieldOverrides: FieldConfigFunction = (layer) => {\n return {\n fieldType: ({\n label,\n isRequired,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n <IconNameField\n label={label}\n isRequired={isRequired}\n value={layer.props.iconName}\n onChange={field.onChange}\n {...fieldProps}\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 <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <Textarea\n value={layer.children as string}\n onChange={field.onChange}\n {...fieldProps}\n />\n </FormFieldWrapper>\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 <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <MinimalTiptapEditor\n immediatelyRender={false}\n output=\"markdown\"\n editable={true}\n value={layer.children as string}\n editorClassName=\"focus:outline-none px-4 py-2 h-full\"\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop\n onChange={(content) => {\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 </FormFieldWrapper>\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 <VariableBindingWrapper propName={propName}>{children}</VariableBindingWrapper>\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 <VariableBindingWrapper propName={propName}>\n {children}\n </VariableBindingWrapper>\n )\n : undefined,\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <Input\n value={field.value as string}\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop\n onChange={(e) => field.onChange(e.target.value)}\n {...fieldProps}\n />\n </FormFieldWrapper>\n ),\n };\n};\n\nexport const renderParentWithVariableBinding = ({\n children,\n}: {\n children: React.ReactNode;\n}) => (\n <div className=\"flex bg-red-500\">\n {children}\n <div>YO!</div>\n </div>\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 <div className=\"flex w-full gap-2 items-end\">\n {isCurrentlyBound && boundVariable ? (\n // Bound state - show variable info and unbind button\n <div className=\"flex flex-col gap-2 w-full\">\n <Label>{propName.charAt(0).toUpperCase() + propName.slice(1)}</Label>\n <div className=\"flex items-end gap-2 w-full\">\n <Card className=\"w-full\">\n <CardContent className=\"py-1 px-4\">\n <div className=\"flex items-center gap-2 w-full\">\n <Link className=\"h-4 w-4 flex-shrink-0\" />\n <div className=\"flex flex-col min-w-0 flex-1\">\n <div className=\"flex items-center gap-2 w-full\">\n <span className=\"font-medium\">{boundVariable.name}</span>\n <span className=\"px-1.5 py-0.5 bg-muted rounded text-xs font-mono\">\n {boundVariable.type}\n </span>\n </div>\n <span className=\"text-xs text-muted-foreground truncate\">\n {String(boundVariable.defaultValue)}\n </span>\n </div>\n </div>\n </CardContent>\n </Card>\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n variant=\"outline\"\n onClick={handleUnbind}\n className=\"px-3 h-10\"\n >\n <Unlink className=\"h-4 w-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent>Unbind Variable</TooltipContent>\n </Tooltip>\n </div>\n </div>\n ) : (\n // Unbound state - show normal field with bind button\n <>\n <div className=\"flex-1\">{children}</div>\n <div className=\"flex justify-end\">\n <DropdownMenu>\n <Tooltip>\n <DropdownMenuTrigger asChild>\n <TooltipTrigger asChild>\n <Button variant=\"outline\" size=\"sm\" className=\"px-3 h-10\">\n <Link className=\"h-4 w-4 my-1\" />\n </Button>\n </TooltipTrigger>\n </DropdownMenuTrigger>\n <TooltipContent>Bind Variable</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\" className=\"w-56\">\n <div className=\"px-2 py-1.5 text-xs font-medium text-muted-foreground border-b\">\n Bind to Variable\n </div>\n {variables.length > 0 ? (\n variables.map((variable) => (\n <DropdownMenuItem\n key={variable.id}\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop\n onClick={() => handleBindToVariable(variable.id)}\n className=\"flex flex-col items-start p-3\"\n >\n <div className=\"flex items-center gap-2 w-full\">\n <Link className=\"h-4 w-4 flex-shrink-0\" />\n <div className=\"flex flex-col min-w-0 flex-1\">\n <div className=\"flex items-center gap-2 \">\n <span className=\"font-medium\">{variable.name}</span>\n <span className=\"px-1.5 py-0.5 bg-muted rounded text-xs font-mono\">\n {variable.type}\n </span>\n </div>\n <span className=\"text-xs text-muted-foreground truncate\">\n {String(variable.defaultValue)}\n </span>\n </div>\n </div>\n </DropdownMenuItem>\n ))\n ) : (\n <div className=\"px-3 py-2 text-xs text-muted-foreground\">\n No variables defined\n </div>\n )}\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n </>\n )}\n </div>\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 <FormItem className=\"flex flex-col\">\n <FormLabel>\n {label}\n {isRequired && <span className=\"text-destructive\"> *</span>}\n </FormLabel>\n <FormControl>{children}</FormControl>\n {fieldConfigItem?.description && (\n <FormDescription>{fieldConfigItem.description}</FormDescription>\n )}\n </FormItem>\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 <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <BreakpointClassNameControl\n value={field.value}\n onChange={field.onChange}\n />\n </FormFieldWrapper>\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 <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <ChildrenSearchableSelect\n layer={layer}\n onChange={field.onChange}\n {...fieldProps}\n />\n </FormFieldWrapper>\n ),\n };\n};\n\nexport const iconNameFieldOverrides: FieldConfigFunction = (layer) => {\n return {\n fieldType: ({\n label,\n isRequired,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n <IconNameField\n label={label}\n isRequired={isRequired}\n value={layer.props.iconName}\n onChange={field.onChange}\n {...fieldProps}\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 <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <Textarea\n value={layer.children as string}\n onChange={field.onChange}\n {...fieldProps}\n />\n </FormFieldWrapper>\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 <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <MinimalTiptapEditor\n immediatelyRender={false}\n output=\"markdown\"\n editable={true}\n value={layer.children as string}\n editorClassName=\"focus:outline-none px-4 py-2 h-full\"\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop\n onChange={(content) => {\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 </FormFieldWrapper>\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 <VariableBindingWrapper propName={propName}>{children}</VariableBindingWrapper>\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 <VariableBindingWrapper propName={propName}>\n {children}\n </VariableBindingWrapper>\n )\n : undefined,\n fieldType: ({\n label,\n isRequired,\n fieldConfigItem,\n field,\n fieldProps,\n }: AutoFormInputComponentProps) => (\n <FormFieldWrapper\n label={label}\n isRequired={isRequired}\n fieldConfigItem={fieldConfigItem}\n >\n <Input\n value={field.value as string}\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop\n onChange={(e) => field.onChange(e.target.value)}\n {...fieldProps}\n />\n </FormFieldWrapper>\n ),\n };\n};\n\nexport const renderParentWithVariableBinding = ({\n children,\n}: {\n children: React.ReactNode;\n}) => (\n <div className=\"flex bg-red-500\">\n {children}\n <div>YO!</div>\n </div>\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 <div className=\"flex w-full gap-2 items-end\">\n {isCurrentlyBound && boundVariable ? (\n // Bound state - show variable info and unbind button\n <div className=\"flex flex-col gap-2 w-full\">\n <Label>{propName.charAt(0).toUpperCase() + propName.slice(1)}</Label>\n <div className=\"flex items-end gap-2 w-full\">\n <Card className=\"w-full\">\n <CardContent className=\"py-1 px-4\">\n <div className=\"flex items-center gap-2 w-full\">\n <Link className=\"h-4 w-4 flex-shrink-0\" />\n <div className=\"flex flex-col min-w-0 flex-1\">\n <div className=\"flex items-center gap-2 w-full\">\n <span className=\"font-medium\">{boundVariable.name}</span>\n <span className=\"px-1.5 py-0.5 bg-muted rounded text-xs font-mono\">\n {boundVariable.type}\n </span>\n {isImmutable && (\n <Badge data-testid=\"immutable-badge\" className=\"rounded\">\n <LockKeyhole strokeWidth={3} className=\"w-3 h-3\" />\n </Badge>\n )}\n </div>\n <span className=\"text-xs text-muted-foreground truncate\">\n {String(boundVariable.defaultValue)}\n </span>\n </div>\n </div>\n </CardContent>\n </Card>\n {!isImmutable && (\n <Tooltip>\n <TooltipTrigger asChild>\n <Button\n variant=\"outline\"\n onClick={handleUnbind}\n className=\"px-3 h-10\"\n >\n <Unlink className=\"h-4 w-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent>Unbind Variable</TooltipContent>\n </Tooltip>\n )}\n </div>\n </div>\n ) : (\n // Unbound state - show normal field with bind button\n <>\n <div className=\"flex-1\">{children}</div>\n <div className=\"flex justify-end\">\n <DropdownMenu>\n <Tooltip>\n <DropdownMenuTrigger asChild>\n <TooltipTrigger asChild>\n <Button variant=\"outline\" size=\"sm\" className=\"px-3 h-10\">\n <Link className=\"h-4 w-4 my-1\" />\n </Button>\n </TooltipTrigger>\n </DropdownMenuTrigger>\n <TooltipContent>Bind Variable</TooltipContent>\n </Tooltip>\n <DropdownMenuContent align=\"end\" className=\"w-56\">\n <div className=\"px-2 py-1.5 text-xs font-medium text-muted-foreground border-b\">\n Bind to Variable\n </div>\n {variables.length > 0 ? (\n variables.map((variable) => (\n <DropdownMenuItem\n key={variable.id}\n // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop\n onClick={() => handleBindToVariable(variable.id)}\n className=\"flex flex-col items-start p-3\"\n >\n <div className=\"flex items-center gap-2 w-full\">\n <Link className=\"h-4 w-4 flex-shrink-0\" />\n <div className=\"flex flex-col min-w-0 flex-1\">\n <div className=\"flex items-center gap-2 \">\n <span className=\"font-medium\">{variable.name}</span>\n <span className=\"px-1.5 py-0.5 bg-muted rounded text-xs font-mono\">\n {variable.type}\n </span>\n </div>\n <span className=\"text-xs text-muted-foreground truncate\">\n {String(variable.defaultValue)}\n </span>\n </div>\n </div>\n </DropdownMenuItem>\n ))\n ) : (\n <div className=\"px-3 py-2 text-xs text-muted-foreground\">\n No variables defined\n </div>\n )}\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n </>\n )}\n </div>\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 <FormItem className=\"flex flex-col\">\n <FormLabel>\n {label}\n {isRequired && <span className=\"text-destructive\"> *</span>}\n </FormLabel>\n <FormControl>{children}</FormControl>\n {fieldConfigItem?.description && (\n <FormDescription>{fieldConfigItem.description}</FormDescription>\n )}\n </FormItem>\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" },