Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 91 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -373,6 +375,77 @@ export const myComponentRegistry: ComponentRegistry = {
// <UIBuilder componentRegistry={myComponentRegistry} />
```

### 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 (
<UIBuilder
componentRegistry={myComponentRegistry}
initialVariables={systemVariables}
// When users add UserProfile or BrandedButton components,
// they'll automatically be bound to the appropriate variables
/>
);
};
```

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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
265 changes: 265 additions & 0 deletions __tests__/layer-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="add-components-popover">{children}</div>
),
}));

const mockedUseLayerStore = useLayerStore as jest.MockedFunction<typeof useLayerStore>;
const mockedUseEditorStore = useEditorStore as jest.MockedFunction<typeof useEditorStore>;

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(<LayerMenu {...defaultProps} />);

// 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(<LayerMenu {...defaultProps} layerId="test-page-1" />);

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(<LayerMenu {...defaultProps} layerId="test-page-1" />);

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(<LayerMenu {...defaultProps} layerId="test-page-1" />);

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(<LayerMenu {...defaultProps} layerId="test-page-1" />);

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(<LayerMenu {...defaultProps} handleDuplicateComponent={mockHandleDuplicate} />);

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(<LayerMenu {...defaultProps} handleDeleteComponent={mockHandleDelete} />);

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(<LayerMenu {...defaultProps} />);

// Check that the component renders successfully
const menuContainer = container.querySelector('div.fixed');
expect(menuContainer).toBeInTheDocument();
});
});
});
Loading