Skip to content

Commit a4b2870

Browse files
authored
Merge pull request #25 from olliethedev/fix/immutable-bindings
fix: immutable binding init
2 parents 6072c3a + d583477 commit a4b2870

File tree

4 files changed

+303
-6
lines changed

4 files changed

+303
-6
lines changed

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -629,10 +629,8 @@ npm run test
629629
## Roadmap
630630

631631
- [ ] Add variable binding to layer children and not just props
632-
- [ ] Documentation site for UI Builder with more hands-on examples
633-
- [ ] Configurable Tailwind Class subset for things like React-Email components
634632
- [ ] Drag and drop component in the editor panel and not just in the layers panel
635-
- [ ] Add string templates for variable-bound props. (ex, "Hello {name}" in a span)
633+
- [ ] Documentation site for UI Builder with more hands-on examples
636634
- [ ] Update to React 19
637635
- [ ] Update to latest Shadcn/ui + Tailwind CSS v4
638636
- [ ] Add Blocks. Reusable component blocks that can be used in multiple pages

__tests__/layer-store.test.tsx

Lines changed: 262 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { renderHook, act } from '@testing-library/react';
22
import { useLayerStore } from '@/lib/ui-builder/store/layer-store';
3-
import { ComponentLayer } from '@/components/ui/ui-builder/types';
3+
import { ComponentLayer, Variable } from '@/components/ui/ui-builder/types';
44
import { z } from 'zod';
55

66
import { useEditorStore } from '@/lib/ui-builder/store/editor-store';
@@ -60,6 +60,13 @@ describe('LayerStore', () => {
6060
}
6161
],
6262
},
63+
ComponentWithoutBindings: {
64+
schema: z.object({
65+
text: z.string().default('Default Text'),
66+
}),
67+
from: '@/components/ui/component-without-bindings',
68+
component: () => null,
69+
},
6370
// Add other components as needed with appropriate Zod schemas
6471
}
6572
});
@@ -869,6 +876,260 @@ describe('LayerStore', () => {
869876

870877
expect(result.current.variables).toEqual(variables);
871878
});
879+
880+
it('should initialize immutable bindings for existing layers with variable references', () => {
881+
const { result } = renderHook(() => useLayerStore());
882+
883+
// Create layers with existing variable references
884+
const initialLayers: ComponentLayer[] = [
885+
{
886+
id: 'page1',
887+
type: 'div',
888+
name: 'Page 1',
889+
props: { className: 'p-4' },
890+
children: [
891+
{
892+
id: 'component-with-bindings',
893+
type: 'ComponentWithDefaultBindings',
894+
name: 'Test Component',
895+
props: {
896+
title: { __variableRef: 'var-id-1' }, // This should be immutable
897+
description: { __variableRef: 'var-id-2' }, // This should be mutable
898+
count: 42
899+
},
900+
children: []
901+
}
902+
]
903+
}
904+
];
905+
906+
const initialVariables: Variable[] = [
907+
{ id: 'var-id-1', name: 'Title Variable', type: 'string', defaultValue: 'Test Title' },
908+
{ id: 'var-id-2', name: 'Description Variable', type: 'string', defaultValue: 'Test Description' },
909+
];
910+
911+
// Update registry to use the correct variable IDs
912+
useEditorStore.setState({
913+
registry: {
914+
...useEditorStore.getState().registry,
915+
ComponentWithDefaultBindings: {
916+
schema: z.object({
917+
title: z.string().default('Default Title'),
918+
description: z.string().default('Default Description'),
919+
count: z.number().default(0),
920+
}),
921+
from: '@/components/ui/component-with-default-bindings',
922+
component: () => null,
923+
defaultVariableBindings: [
924+
{ propName: 'title', variableId: 'var-id-1', immutable: true },
925+
{ propName: 'description', variableId: 'var-id-2', immutable: false },
926+
],
927+
},
928+
}
929+
});
930+
931+
// Initialize with the layers that already have variable bindings
932+
act(() => {
933+
result.current.initialize(initialLayers, 'page1', undefined, initialVariables);
934+
});
935+
936+
// Check that immutable bindings were properly set up during initialization
937+
expect(result.current.isBindingImmutable('component-with-bindings', 'title')).toBe(true);
938+
expect(result.current.isBindingImmutable('component-with-bindings', 'description')).toBe(false);
939+
expect(result.current.isBindingImmutable('component-with-bindings', 'count')).toBe(false);
940+
941+
// Verify that the layer was initialized correctly
942+
const component = result.current.findLayerById('component-with-bindings');
943+
expect(component?.props.title).toEqual({ __variableRef: 'var-id-1' });
944+
expect(component?.props.description).toEqual({ __variableRef: 'var-id-2' });
945+
expect(component?.props.count).toBe(42);
946+
947+
// Test that immutable binding prevents unbinding
948+
act(() => {
949+
result.current.unbindPropFromVariable('component-with-bindings', 'title');
950+
});
951+
952+
// Should still be bound (immutable)
953+
const componentAfterUnbind = result.current.findLayerById('component-with-bindings');
954+
expect(componentAfterUnbind?.props.title).toEqual({ __variableRef: 'var-id-1' });
955+
956+
// Test that mutable binding allows unbinding
957+
act(() => {
958+
result.current.unbindPropFromVariable('component-with-bindings', 'description');
959+
});
960+
961+
// Should be unbound (mutable)
962+
const componentAfterMutableUnbind = result.current.findLayerById('component-with-bindings');
963+
expect(componentAfterMutableUnbind?.props.description).toBe('Default Description');
964+
});
965+
966+
it('should apply default variable bindings when adding a component', () => {
967+
const { result } = renderHook(() => useLayerStore());
968+
969+
// Manually set variable IDs to match what we expect in the binding definitions
970+
act(() => {
971+
const variables = result.current.variables;
972+
if (variables.length >= 2) {
973+
// Update the registry to use actual variable IDs
974+
const registry = useEditorStore.getState().registry;
975+
useEditorStore.setState({
976+
registry: {
977+
...registry,
978+
ComponentWithDefaultBindings: {
979+
...registry.ComponentWithDefaultBindings,
980+
defaultVariableBindings: [
981+
{ propName: 'title', variableId: variables[0].id, immutable: true },
982+
{ propName: 'description', variableId: variables[1].id, immutable: false },
983+
],
984+
},
985+
}
986+
});
987+
988+
result.current.addComponentLayer('ComponentWithDefaultBindings', '1');
989+
}
990+
});
991+
992+
const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
993+
expect(addedLayer.type).toBe('ComponentWithDefaultBindings');
994+
995+
// Check that variable bindings were applied
996+
const variables = result.current.variables;
997+
expect(addedLayer.props.title).toEqual({ __variableRef: variables[0].id });
998+
expect(addedLayer.props.description).toEqual({ __variableRef: variables[1].id });
999+
1000+
// Check that immutable bindings were tracked
1001+
expect(result.current.isBindingImmutable(addedLayer.id, 'title')).toBe(true);
1002+
expect(result.current.isBindingImmutable(addedLayer.id, 'description')).toBe(false);
1003+
});
1004+
1005+
it('should not apply bindings for non-existent variables', () => {
1006+
const { result } = renderHook(() => useLayerStore());
1007+
1008+
// Use registry with non-existent variable IDs
1009+
useEditorStore.setState({
1010+
registry: {
1011+
...useEditorStore.getState().registry,
1012+
ComponentWithInvalidBindings: {
1013+
schema: z.object({
1014+
title: z.string().default('Default Title'),
1015+
}),
1016+
from: '@/components/ui/component-with-invalid-bindings',
1017+
component: () => null,
1018+
defaultVariableBindings: [
1019+
{ propName: 'title', variableId: 'non-existent-var', immutable: true },
1020+
],
1021+
},
1022+
}
1023+
});
1024+
1025+
act(() => {
1026+
result.current.addComponentLayer('ComponentWithInvalidBindings', '1');
1027+
});
1028+
1029+
const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
1030+
1031+
// Should use default value from schema, not variable binding
1032+
expect(addedLayer.props.title).toBe('Default Title');
1033+
expect(result.current.isBindingImmutable(addedLayer.id, 'title')).toBe(false);
1034+
});
1035+
1036+
it('should handle components without default variable bindings', () => {
1037+
const { result } = renderHook(() => useLayerStore());
1038+
1039+
act(() => {
1040+
result.current.addComponentLayer('ComponentWithoutBindings', '1');
1041+
});
1042+
1043+
const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
1044+
expect(addedLayer.type).toBe('ComponentWithoutBindings');
1045+
expect(addedLayer.props.text).toBe('Default Text');
1046+
expect(result.current.isBindingImmutable(addedLayer.id, 'text')).toBe(false);
1047+
});
1048+
1049+
it('should prevent unbinding immutable variable bindings', () => {
1050+
const { result } = renderHook(() => useLayerStore());
1051+
1052+
// Set up component with immutable binding
1053+
act(() => {
1054+
const variables = result.current.variables;
1055+
if (variables.length >= 1) {
1056+
useEditorStore.setState({
1057+
registry: {
1058+
...useEditorStore.getState().registry,
1059+
ComponentWithDefaultBindings: {
1060+
...useEditorStore.getState().registry.ComponentWithDefaultBindings,
1061+
defaultVariableBindings: [
1062+
{ propName: 'title', variableId: variables[0].id, immutable: true },
1063+
],
1064+
},
1065+
}
1066+
});
1067+
1068+
result.current.addComponentLayer('ComponentWithDefaultBindings', '1');
1069+
}
1070+
});
1071+
1072+
const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
1073+
1074+
// Verify binding exists
1075+
expect(addedLayer.props.title).toEqual({ __variableRef: result.current.variables[0].id });
1076+
expect(result.current.isBindingImmutable(addedLayer.id, 'title')).toBe(true);
1077+
1078+
// Try to unbind immutable binding (should fail)
1079+
act(() => {
1080+
result.current.unbindPropFromVariable(addedLayer.id, 'title');
1081+
});
1082+
1083+
// Binding should still exist
1084+
const layerAfterUnbind = result.current.findLayerById(addedLayer.id) as ComponentLayer;
1085+
expect(layerAfterUnbind.props.title).toEqual({ __variableRef: result.current.variables[0].id });
1086+
});
1087+
1088+
it('should allow unbinding mutable variable bindings', () => {
1089+
const { result } = renderHook(() => useLayerStore());
1090+
1091+
// Set up component with mutable binding
1092+
act(() => {
1093+
const variables = result.current.variables;
1094+
if (variables.length >= 1) {
1095+
useEditorStore.setState({
1096+
registry: {
1097+
...useEditorStore.getState().registry,
1098+
ComponentWithDefaultBindings: {
1099+
...useEditorStore.getState().registry.ComponentWithDefaultBindings,
1100+
defaultVariableBindings: [
1101+
{ propName: 'description', variableId: variables[0].id, immutable: false },
1102+
],
1103+
},
1104+
}
1105+
});
1106+
1107+
result.current.addComponentLayer('ComponentWithDefaultBindings', '1');
1108+
}
1109+
});
1110+
1111+
const addedLayer = (result.current.pages[0].children[0] as ComponentLayer);
1112+
1113+
// Verify binding exists
1114+
expect(addedLayer.props.description).toEqual({ __variableRef: result.current.variables[0].id });
1115+
expect(result.current.isBindingImmutable(addedLayer.id, 'description')).toBe(false);
1116+
1117+
// Unbind mutable binding (should succeed)
1118+
act(() => {
1119+
result.current.unbindPropFromVariable(addedLayer.id, 'description');
1120+
});
1121+
1122+
// Binding should be removed and default value set
1123+
const layerAfterUnbind = result.current.findLayerById(addedLayer.id) as ComponentLayer;
1124+
expect(layerAfterUnbind.props.description).toBe('Default Description');
1125+
});
1126+
1127+
it('should correctly report binding immutability', () => {
1128+
const { result } = renderHook(() => useLayerStore());
1129+
1130+
expect(result.current.isBindingImmutable('non-existent-layer', 'prop')).toBe(false);
1131+
expect(result.current.isBindingImmutable('layer-id', 'non-existent-prop')).toBe(false);
1132+
});
8721133
});
8731134

8741135
describe('Edge Cases and Error Handling', () => {

app/platform/builder-with-immutable-bindings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,4 +365,4 @@ export const BuilderWithImmutableBindings = () => {
365365
allowVariableEditing={false}
366366
/>
367367
);
368-
};
368+
};

lib/ui-builder/store/layer-store.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,45 @@ const store: StateCreator<LayerStore, [], []> = (set, get) => (
6161
selectedLayerId: null,
6262
selectedPageId: '1',
6363
initialize: (pages: ComponentLayer[], selectedPageId?: string, selectedLayerId?: string, variables?: Variable[]) => {
64-
set({ pages, selectedPageId: selectedPageId || pages[0].id, selectedLayerId: selectedLayerId || null, variables: variables || [] });
64+
set(produce((state: LayerStore) => {
65+
// Set the basic state
66+
state.pages = pages;
67+
state.selectedPageId = selectedPageId || pages[0].id;
68+
state.selectedLayerId = selectedLayerId || null;
69+
state.variables = variables || [];
70+
71+
// Initialize immutable bindings for existing layers
72+
const { registry } = useEditorStore.getState();
73+
74+
// Helper function to set up immutable bindings for layers
75+
const setupImmutableBindings = (layer: ComponentLayer) => {
76+
const componentDef = registry[layer.type];
77+
const defaultVariableBindings = componentDef?.defaultVariableBindings || [];
78+
79+
// Check each default variable binding to see if this layer has a matching variable reference
80+
for (const binding of defaultVariableBindings) {
81+
const propValue = layer.props[binding.propName];
82+
83+
// If the prop has a variable reference and it matches the binding's variable ID
84+
if (isVariableReference(propValue) && propValue.__variableRef === binding.variableId) {
85+
// Set up immutable binding if specified
86+
if (binding.immutable) {
87+
if (!state.immutableBindings[layer.id]) {
88+
state.immutableBindings[layer.id] = {};
89+
}
90+
state.immutableBindings[layer.id][binding.propName] = true;
91+
}
92+
}
93+
}
94+
95+
return layer;
96+
};
97+
98+
// Process all pages and their layers to set up immutable bindings
99+
state.pages = state.pages.map(page =>
100+
visitLayer(page, null, setupImmutableBindings)
101+
);
102+
}));
65103
},
66104
findLayerById: (layerId: string | null) => {
67105
const { selectedPageId, findLayersForPageId, pages } = get();

0 commit comments

Comments
 (0)