Skip to content

Commit 16d802f

Browse files
authored
Merge pull request #22 from olliethedev/feat/option-to-lock-page-creation
feat: add option to lock page, variable binding
2 parents 61535bd + aa2c36e commit 16d802f

26 files changed

+2431
-209
lines changed

README.md

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,9 @@ Note: Variables are optional, but they are a powerful way to make your pages dyn
263263
- `onVariablesChange`: Optional callback triggered when the variables change, providing the updated variables. Can be used to persist the variable state to a database.
264264
- `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.
265265
- `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`.
266-
- `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`.
266+
- `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`.
267+
- `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`.
268+
- `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`.
267269

268270

269271
## Rendering from Serialized Layer Data
@@ -373,6 +375,77 @@ export const myComponentRegistry: ComponentRegistry = {
373375
// <UIBuilder componentRegistry={myComponentRegistry} />
374376
```
375377

378+
### Example with Default Variable Bindings
379+
380+
Here's a practical example showing how to use `defaultVariableBindings` to create components that automatically bind to system variables:
381+
382+
```tsx
383+
import { z } from 'zod';
384+
import { UserProfile } from '@/components/ui/user-profile';
385+
import { BrandedButton } from '@/components/ui/branded-button';
386+
import { ComponentRegistry } from "@/components/ui/ui-builder/types";
387+
388+
// First, define your variables (these would typically come from your app's state)
389+
const systemVariables = [
390+
{ id: 'user-id-var', name: 'currentUserId', type: 'string', defaultValue: 'user123' },
391+
{ id: 'user-name-var', name: 'currentUserName', type: 'string', defaultValue: 'John Doe' },
392+
{ id: 'brand-color-var', name: 'primaryBrandColor', type: 'string', defaultValue: '#3b82f6' },
393+
{ id: 'company-name-var', name: 'companyName', type: 'string', defaultValue: 'Acme Corp' },
394+
];
395+
396+
// Define components with automatic variable bindings
397+
const myComponentRegistry: ComponentRegistry = {
398+
UserProfile: {
399+
component: UserProfile,
400+
schema: z.object({
401+
userId: z.string().default(''),
402+
displayName: z.string().default('Anonymous'),
403+
showAvatar: z.boolean().default(true),
404+
}),
405+
from: "@/components/ui/user-profile",
406+
// Automatically bind user data when component is added
407+
defaultVariableBindings: [
408+
{ propName: 'userId', variableId: 'user-id-var', immutable: true }, // System data - can't be changed
409+
{ propName: 'displayName', variableId: 'user-name-var', immutable: false }, // Can be overridden
410+
],
411+
},
412+
413+
BrandedButton: {
414+
component: BrandedButton,
415+
schema: z.object({
416+
text: z.string().default('Click me'),
417+
brandColor: z.string().default('#000000'),
418+
companyName: z.string().default('Company'),
419+
}),
420+
from: "@/components/ui/branded-button",
421+
// Automatically apply branding when component is added
422+
defaultVariableBindings: [
423+
{ propName: 'brandColor', variableId: 'brand-color-var', immutable: true }, // Brand consistency
424+
{ propName: 'companyName', variableId: 'company-name-var', immutable: true }, // Brand consistency
425+
// 'text' is not bound, allowing content editors to customize button text
426+
],
427+
},
428+
};
429+
430+
// Usage in your app
431+
const App = () => {
432+
return (
433+
<UIBuilder
434+
componentRegistry={myComponentRegistry}
435+
initialVariables={systemVariables}
436+
// When users add UserProfile or BrandedButton components,
437+
// they'll automatically be bound to the appropriate variables
438+
/>
439+
);
440+
};
441+
```
442+
443+
In this example:
444+
- **UserProfile** components automatically bind to current user data, with `userId` locked (immutable) for security
445+
- **BrandedButton** components automatically inherit brand colors and company name, ensuring visual consistency
446+
- Content editors can still customize the button text, but can't accidentally break branding
447+
- The immutable bindings prevent accidental unbinding of critical system or brand data
448+
376449
**Component Definition Fields:**
377450

378451
- `component`: **Required**. The React component function or class.
@@ -391,6 +464,23 @@ export const myComponentRegistry: ComponentRegistry = {
391464
* Implementing conditional logic (e.g., showing/hiding a field based on another prop's value).
392465
- 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.
393466
- `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.
467+
- `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.
468+
- Each binding object contains:
469+
* `propName`: The name of the component property to bind
470+
* `variableId`: The ID of the variable to bind to this property
471+
* `immutable`: Optional boolean (defaults to `false`). When `true`, prevents users from unbinding this variable in the UI, ensuring the binding remains intact
472+
- **Use cases for immutable bindings:**
473+
* **System-level data**: Bind user ID, tenant ID, or other system variables that shouldn't be changed by content editors
474+
* **Branding consistency**: Lock brand colors, logos, or company names to maintain visual consistency
475+
* **Security**: Prevent modification of security-related variables like permissions or access levels
476+
* **Template integrity**: Ensure critical template variables remain bound in white-label or multi-tenant scenarios
477+
- Example:
478+
```tsx
479+
defaultVariableBindings: [
480+
{ propName: 'userEmail', variableId: 'user-email-var', immutable: true },
481+
{ propName: 'welcomeMessage', variableId: 'welcome-msg-var', immutable: false }
482+
]
483+
```
394484

395485

396486
### Customizing the Page Config Panel Tabs
@@ -538,7 +628,6 @@ npm run test
538628

539629
## Roadmap
540630

541-
- [ ] Config options to make pages and variables immutable
542631
- [ ] Add variable binding to layer children and not just props
543632
- [ ] Improve DX. End to end type safety.
544633
- [ ] Documentation site for UI Builder with more hands-on examples

__tests__/layer-menu.test.tsx

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import React from "react";
3+
import { render, screen, fireEvent } from "@testing-library/react";
4+
import { LayerMenu } from "@/components/ui/ui-builder/internal/layer-menu";
5+
import { useLayerStore } from "@/lib/ui-builder/store/layer-store";
6+
import { useEditorStore } from "@/lib/ui-builder/store/editor-store";
7+
import { ComponentLayer } from "@/components/ui/ui-builder/types";
8+
import { z } from "zod";
9+
10+
// Mock dependencies
11+
jest.mock("@/lib/ui-builder/store/layer-store", () => ({
12+
useLayerStore: jest.fn(),
13+
}));
14+
15+
jest.mock("@/lib/ui-builder/store/editor-store", () => ({
16+
useEditorStore: jest.fn(),
17+
}));
18+
19+
// Mock the AddComponentsPopover
20+
jest.mock("@/components/ui/ui-builder/internal/add-component-popover", () => ({
21+
AddComponentsPopover: ({ children }: { children: React.ReactNode }) => (
22+
<div data-testid="add-components-popover">{children}</div>
23+
),
24+
}));
25+
26+
const mockedUseLayerStore = useLayerStore as jest.MockedFunction<typeof useLayerStore>;
27+
const mockedUseEditorStore = useEditorStore as jest.MockedFunction<typeof useEditorStore>;
28+
29+
const mockRegistry = {
30+
"test-component": {
31+
name: "Test Component",
32+
schema: z.object({
33+
children: z.array(z.any()).optional(),
34+
}),
35+
props: {},
36+
},
37+
"page": {
38+
name: "Page",
39+
schema: z.object({
40+
children: z.array(z.any()).optional(),
41+
}),
42+
props: {},
43+
},
44+
};
45+
46+
const mockComponentLayer: ComponentLayer = {
47+
id: "test-layer-1",
48+
name: "Test Layer",
49+
type: "test-component",
50+
props: {},
51+
children: [],
52+
};
53+
54+
const mockPageLayer: ComponentLayer = {
55+
id: "test-page-1",
56+
name: "Test Page",
57+
type: "page",
58+
props: {},
59+
children: [],
60+
};
61+
62+
describe("LayerMenu", () => {
63+
const defaultProps = {
64+
layerId: "test-layer-1",
65+
x: 100,
66+
y: 200,
67+
width: 300,
68+
height: 400,
69+
zIndex: 1000,
70+
onClose: jest.fn(),
71+
handleDuplicateComponent: jest.fn(),
72+
handleDeleteComponent: jest.fn(),
73+
};
74+
75+
beforeEach(() => {
76+
jest.clearAllMocks();
77+
78+
// Default mock setup for component layer
79+
mockedUseLayerStore.mockImplementation((selector) => {
80+
if (typeof selector === 'function') {
81+
return selector({
82+
findLayerById: jest.fn().mockReturnValue(mockComponentLayer),
83+
isLayerAPage: jest.fn().mockReturnValue(false),
84+
} as any);
85+
}
86+
return null;
87+
});
88+
89+
mockedUseEditorStore.mockImplementation((selector) => {
90+
if (typeof selector === 'function') {
91+
return selector({
92+
registry: mockRegistry,
93+
allowPagesCreation: true,
94+
allowPagesDeletion: true,
95+
} as any);
96+
}
97+
return null;
98+
});
99+
});
100+
101+
describe("permission controls for component layers", () => {
102+
it("should show both duplicate and delete buttons for component layers regardless of page permissions", () => {
103+
// Override with strict page permissions
104+
mockedUseEditorStore.mockImplementation((selector) => {
105+
if (typeof selector === 'function') {
106+
return selector({
107+
registry: mockRegistry,
108+
allowPagesCreation: false,
109+
allowPagesDeletion: false,
110+
} as any);
111+
}
112+
return null;
113+
});
114+
115+
render(<LayerMenu {...defaultProps} />);
116+
117+
// Should show both buttons for component layers regardless of page permissions
118+
// Find buttons by their SVG icons
119+
const duplicateIcon = document.querySelector('svg.lucide-copy');
120+
const deleteIcon = document.querySelector('svg.lucide-trash');
121+
122+
expect(duplicateIcon).toBeInTheDocument();
123+
expect(deleteIcon).toBeInTheDocument();
124+
});
125+
});
126+
127+
describe("permission controls for page layers", () => {
128+
beforeEach(() => {
129+
// Setup for page layer
130+
mockedUseLayerStore.mockImplementation((selector) => {
131+
if (typeof selector === 'function') {
132+
return selector({
133+
findLayerById: jest.fn().mockReturnValue(mockPageLayer),
134+
isLayerAPage: jest.fn().mockReturnValue(true),
135+
} as any);
136+
}
137+
return null;
138+
});
139+
});
140+
141+
it("should hide duplicate button when allowPagesCreation is false", () => {
142+
mockedUseEditorStore.mockImplementation((selector) => {
143+
if (typeof selector === 'function') {
144+
return selector({
145+
registry: mockRegistry,
146+
allowPagesCreation: false,
147+
allowPagesDeletion: true,
148+
} as any);
149+
}
150+
return null;
151+
});
152+
153+
render(<LayerMenu {...defaultProps} layerId="test-page-1" />);
154+
155+
const duplicateIcon = document.querySelector('svg.lucide-copy');
156+
const deleteIcon = document.querySelector('svg.lucide-trash');
157+
158+
expect(duplicateIcon).not.toBeInTheDocument();
159+
expect(deleteIcon).toBeInTheDocument();
160+
});
161+
162+
it("should hide delete button when allowPagesDeletion is false", () => {
163+
mockedUseEditorStore.mockImplementation((selector) => {
164+
if (typeof selector === 'function') {
165+
return selector({
166+
registry: mockRegistry,
167+
allowPagesCreation: true,
168+
allowPagesDeletion: false,
169+
} as any);
170+
}
171+
return null;
172+
});
173+
174+
render(<LayerMenu {...defaultProps} layerId="test-page-1" />);
175+
176+
const duplicateIcon = document.querySelector('svg.lucide-copy');
177+
const deleteIcon = document.querySelector('svg.lucide-trash');
178+
179+
expect(duplicateIcon).toBeInTheDocument();
180+
expect(deleteIcon).not.toBeInTheDocument();
181+
});
182+
183+
it("should hide both buttons when both permissions are false", () => {
184+
mockedUseEditorStore.mockImplementation((selector) => {
185+
if (typeof selector === 'function') {
186+
return selector({
187+
registry: mockRegistry,
188+
allowPagesCreation: false,
189+
allowPagesDeletion: false,
190+
} as any);
191+
}
192+
return null;
193+
});
194+
195+
render(<LayerMenu {...defaultProps} layerId="test-page-1" />);
196+
197+
const duplicateIcon = document.querySelector('svg.lucide-copy');
198+
const deleteIcon = document.querySelector('svg.lucide-trash');
199+
200+
expect(duplicateIcon).not.toBeInTheDocument();
201+
expect(deleteIcon).not.toBeInTheDocument();
202+
});
203+
204+
it("should show both buttons when both permissions are true", () => {
205+
mockedUseEditorStore.mockImplementation((selector) => {
206+
if (typeof selector === 'function') {
207+
return selector({
208+
registry: mockRegistry,
209+
allowPagesCreation: true,
210+
allowPagesDeletion: true,
211+
} as any);
212+
}
213+
return null;
214+
});
215+
216+
render(<LayerMenu {...defaultProps} layerId="test-page-1" />);
217+
218+
const duplicateIcon = document.querySelector('svg.lucide-copy');
219+
const deleteIcon = document.querySelector('svg.lucide-trash');
220+
221+
expect(duplicateIcon).toBeInTheDocument();
222+
expect(deleteIcon).toBeInTheDocument();
223+
});
224+
});
225+
226+
describe("interaction handling", () => {
227+
it("should call handleDuplicateComponent when duplicate button is clicked", () => {
228+
const mockHandleDuplicate = jest.fn();
229+
230+
render(<LayerMenu {...defaultProps} handleDuplicateComponent={mockHandleDuplicate} />);
231+
232+
const duplicateIcon = document.querySelector('svg.lucide-copy');
233+
expect(duplicateIcon).toBeInTheDocument();
234+
235+
if (duplicateIcon?.parentElement) {
236+
fireEvent.click(duplicateIcon.parentElement);
237+
expect(mockHandleDuplicate).toHaveBeenCalledTimes(1);
238+
}
239+
});
240+
241+
it("should call handleDeleteComponent when delete button is clicked", () => {
242+
const mockHandleDelete = jest.fn();
243+
244+
render(<LayerMenu {...defaultProps} handleDeleteComponent={mockHandleDelete} />);
245+
246+
const deleteIcon = document.querySelector('svg.lucide-trash');
247+
expect(deleteIcon).toBeInTheDocument();
248+
249+
if (deleteIcon?.parentElement) {
250+
fireEvent.click(deleteIcon.parentElement);
251+
expect(mockHandleDelete).toHaveBeenCalledTimes(1);
252+
}
253+
});
254+
});
255+
256+
describe("basic rendering", () => {
257+
it("should render the layer menu with correct positioning", () => {
258+
const { container } = render(<LayerMenu {...defaultProps} />);
259+
260+
// Check that the component renders successfully
261+
const menuContainer = container.querySelector('div.fixed');
262+
expect(menuContainer).toBeInTheDocument();
263+
});
264+
});
265+
});

0 commit comments

Comments
 (0)