Skip to content

Commit 414de73

Browse files
authored
Merge pull request #23 from olliethedev/feat/type-safety
feat: update type safety
2 parents 16d802f + 14bb2c0 commit 414de73

File tree

14 files changed

+189
-85
lines changed

14 files changed

+189
-85
lines changed

.github/workflows/pull-request-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,8 @@ jobs:
2424
- name: Lint code
2525
run: npx eslint components/ui/ui-builder/ lib/ --max-warnings 0
2626

27+
- name: Type check
28+
run: npx tsc --noEmit
29+
2730
- name: Run tests
2831
run: npm run test:ci

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,6 @@ npm run test
629629
## Roadmap
630630

631631
- [ ] Add variable binding to layer children and not just props
632-
- [ ] Improve DX. End to end type safety.
633632
- [ ] Documentation site for UI Builder with more hands-on examples
634633
- [ ] Configurable Tailwind Class subset for things like React-Email components
635634
- [ ] Drag and drop component in the editor panel and not just in the layers panel

__tests__/index.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,17 @@ it("UIBuilder: accepts both initialLayers and initialVariables", async () => {
106106
expect(componentEditor).toBeInTheDocument();
107107
});
108108

109-
it("UIBuilder: hides Add Variable button when editVariables is false", async () => {
109+
it("UIBuilder: hides Add Variable button when allowVariableEditing is false", async () => {
110110
render(
111111
<UIBuilder
112112
componentRegistry={componentRegistry}
113-
editVariables={false}
113+
allowVariableEditing={false}
114114
/>
115115
);
116116
const componentEditor = await screen.findByTestId("component-editor");
117117
expect(componentEditor).toBeInTheDocument();
118118

119-
// The editVariables prop should be passed through to the default config
119+
// The allowVariableEditing prop should be passed through to the default config
120120
// We can verify this by checking that the prop was processed correctly
121121
expect(componentEditor).toBeInTheDocument();
122122
});
@@ -130,7 +130,7 @@ it("UIBuilder: shows Add Variable button by default", async () => {
130130
const componentEditor = await screen.findByTestId("component-editor");
131131
expect(componentEditor).toBeInTheDocument();
132132

133-
// The editVariables prop should default to true
133+
// The allowVariableEditing prop should default to true
134134
// We can verify this by checking that the component renders without errors
135135
expect(componentEditor).toBeInTheDocument();
136136
});

components/ui/ui-builder/index.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import { useEditorStore } from "@/lib/ui-builder/store/editor-store";
2424
import {
2525
ComponentRegistry,
2626
ComponentLayer,
27-
Variable
27+
Variable,
28+
LayerChangeHandler,
29+
VariableChangeHandler
2830
} from "@/components/ui/ui-builder/types";
2931
import { TailwindThemePanel } from "@/components/ui/ui-builder/internal/tailwind-theme-panel";
3032
import { ConfigPanel } from "@/components/ui/ui-builder/internal/config-panel";
@@ -52,14 +54,14 @@ interface PanelConfig {
5254
}
5355

5456
/**
55-
* UIBuilderProps defines the props for the UIBuilder component.
57+
* UIBuilderProps defines the props for the UIBuilder component with enhanced type safety.
5658
*/
57-
interface UIBuilderProps {
59+
interface UIBuilderProps<TRegistry extends ComponentRegistry = ComponentRegistry> {
5860
initialLayers?: ComponentLayer[];
59-
onChange?: (pages: ComponentLayer[]) => void;
61+
onChange?: LayerChangeHandler<TRegistry>;
6062
initialVariables?: Variable[];
61-
onVariablesChange?: (variables: Variable[]) => void;
62-
componentRegistry: ComponentRegistry;
63+
onVariablesChange?: VariableChangeHandler;
64+
componentRegistry: TRegistry;
6365
panelConfig?: PanelConfig;
6466
persistLayerStore?: boolean;
6567
allowVariableEditing?: boolean;
@@ -73,7 +75,7 @@ interface UIBuilderProps {
7375
* @param {UIBuilderProps} props - The props for the UIBuilder component.
7476
* @returns {JSX.Element} The UIBuilder component wrapped in a ThemeProvider.
7577
*/
76-
const UIBuilder = ({
78+
const UIBuilder = <TRegistry extends ComponentRegistry = ComponentRegistry>({
7779
initialLayers,
7880
onChange,
7981
initialVariables,
@@ -84,7 +86,7 @@ const UIBuilder = ({
8486
allowVariableEditing = true,
8587
allowPagesCreation = true,
8688
allowPagesDeletion = true,
87-
}: UIBuilderProps) => {
89+
}: UIBuilderProps<TRegistry>) => {
8890
const layerStore = useStore(useLayerStore, (state) => state);
8991
const editorStore = useStore(useEditorStore, (state) => state);
9092

components/ui/ui-builder/internal/layer-menu.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,14 @@ export const LayerMenu: React.FC<MenuProps> = ({
5454
const componentDef = componentRegistry[selectedLayer.type as keyof typeof componentRegistry];
5555
if (!componentDef) return false;
5656

57+
// Safely check if schema has shape property (ZodObject) and children field
58+
const hasChildrenField = 'shape' in componentDef.schema &&
59+
componentDef.schema.shape &&
60+
componentDef.schema.shape.children !== undefined;
61+
5762
return (
5863
hasLayerChildren(selectedLayer) &&
59-
componentDef.schema.shape.children !== undefined
64+
hasChildrenField
6065
);
6166
}, [selectedLayer, componentRegistry]);
6267

components/ui/ui-builder/internal/props-panel.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ const ComponentPropsAutoForm: React.FC<ComponentPropsAutoFormProps> = ({
182182
Object.keys(dataProps as Record<string, any>).forEach((key) => {
183183
const originalValue = selectedLayer.props[key];
184184
const newValue = (dataProps as Record<string, any>)[key];
185-
const fieldDef = schema?.shape?.[key];
185+
const fieldDef = ('shape' in schema && schema.shape) ? schema.shape[key] : undefined;
186186
const baseType = fieldDef
187187
? getBaseType(fieldDef as z.ZodAny)
188188
: undefined;
@@ -235,7 +235,7 @@ const ComponentPropsAutoForm: React.FC<ComponentPropsAutoFormProps> = ({
235235
);
236236

237237
const transformedProps: Record<string, any> = {};
238-
const schemaShape = schema?.shape as z.ZodRawShape | undefined; // Get shape from the memoized schema
238+
const schemaShape = ('shape' in schema && schema.shape) ? schema.shape as z.ZodRawShape : undefined; // Get shape from the memoized schema
239239

240240
if (schemaShape) {
241241
for (const [key, value] of Object.entries(resolvedProps)) {
@@ -273,7 +273,16 @@ const ComponentPropsAutoForm: React.FC<ComponentPropsAutoFormProps> = ({
273273
}, [selectedLayer, schema, revisionCounter]); // Include revisionCounter to detect undo/redo changes
274274

275275
const autoFormSchema = useMemo(() => {
276-
return addDefaultValues(schema, formValues);
276+
// Only pass ZodObject schemas to addDefaultValues, otherwise return the original schema
277+
if ('shape' in schema && typeof schema.shape === 'object') {
278+
try {
279+
return addDefaultValues(schema as any, formValues);
280+
} catch (error) {
281+
console.warn('Failed to add default values to schema:', error);
282+
return schema;
283+
}
284+
}
285+
return schema;
277286
}, [schema, formValues]);
278287

279288
const autoFormFieldConfig = useMemo(() => {

components/ui/ui-builder/internal/render-utils.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,11 @@ import { ErrorFallback } from "@/components/ui/ui-builder/internal/error-fallbac
99
import { isPrimitiveComponent } from "@/lib/ui-builder/store/editor-utils";
1010
import { hasLayerChildren } from "@/lib/ui-builder/store/layer-utils";
1111
import { DevProfiler } from "@/components/ui/ui-builder/internal/dev-profiler";
12-
import { ComponentRegistry, ComponentLayer, Variable } from '@/components/ui/ui-builder/types';
12+
import { ComponentRegistry, ComponentLayer, Variable, PropValue } from '@/components/ui/ui-builder/types';
1313
import { useLayerStore } from "@/lib/ui-builder/store/layer-store";
1414
import { resolveVariableReferences } from "@/lib/ui-builder/utils/variable-resolver";
1515

1616
export interface EditorConfig {
17-
1817
zIndex: number;
1918
totalLayers: number;
2019
selectedLayer: ComponentLayer;
@@ -30,7 +29,7 @@ export const RenderLayer: React.FC<{
3029
componentRegistry: ComponentRegistry;
3130
editorConfig?: EditorConfig;
3231
variables?: Variable[];
33-
variableValues?: Record<string, any>;
32+
variableValues?: Record<string, PropValue>;
3433
}> = memo(
3534
({ layer, componentRegistry, editorConfig, variables, variableValues }) => {
3635
const storeVariables = useLayerStore((state) => state.variables);
@@ -52,7 +51,7 @@ export const RenderLayer: React.FC<{
5251

5352
// Resolve variable references in props
5453
const resolvedProps = resolveVariableReferences(layer.props, effectiveVariables, variableValues);
55-
const childProps: Record<string, any> = useMemo(() => ({ ...resolvedProps }), [resolvedProps]);
54+
const childProps: Record<string, PropValue> = useMemo(() => ({ ...resolvedProps }), [resolvedProps]);
5655

5756
// Memoize child editor config to avoid creating objects in JSX
5857
const childEditorConfig = useMemo(() => {

components/ui/ui-builder/internal/tailwind-theme-panel.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,27 +74,35 @@ function ThemePicker({
7474
}) {
7575
const { updateLayer: updateLayerProps } = useLayerStore();
7676

77+
// Safely extract values with type checking
78+
const colorThemeValue = pageLayer.props?.["data-color-theme"];
79+
const modeValue = pageLayer.props?.["data-mode"];
80+
const borderRadiusValue = pageLayer.props?.borderRadius;
81+
7782
const [colorTheme, setColorTheme] = useState<BaseColor["name"]>(
78-
pageLayer.props?.["data-color-theme"] || "red"
83+
(typeof colorThemeValue === 'string' ? colorThemeValue : "red") as BaseColor["name"]
7984
);
8085
const [borderRadius, setBorderRadius] = useState(
81-
pageLayer.props?.["data-border-radius"] || 0.3
86+
typeof borderRadiusValue === 'number' ? borderRadiusValue : 0.5
8287
);
8388
const [mode, setMode] = useState<"light" | "dark">(
84-
pageLayer.props?.["data-mode"] || "light"
89+
(typeof modeValue === 'string' ? modeValue : "light") as "light" | "dark"
8590
);
8691

92+
93+
8794
useEffect(() => {
8895
if (isDisabled) return;
8996

90-
const colorData = baseColors.find((color) => color.name === colorTheme);
91-
if (colorData) {
97+
const colorThemeData = baseColors.find((color) => color.name === colorTheme);
98+
99+
if (colorThemeData) {
92100
const colorDataWithBorder = {
93-
...colorData,
101+
...colorThemeData,
94102
cssVars: {
95-
...colorData.cssVars,
103+
...colorThemeData.cssVars,
96104
[mode]: {
97-
...colorData.cssVars[mode],
105+
...colorThemeData.cssVars[mode],
98106
radius: `${borderRadius}rem`,
99107
},
100108
},

components/ui/ui-builder/layer-renderer.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,27 @@ import React from "react";
33
import { EditorConfig, RenderLayer } from "@/components/ui/ui-builder/internal/render-utils";
44
import { DevProfiler } from "@/components/ui/ui-builder/internal/dev-profiler";
55

6-
import { Variable, ComponentLayer, ComponentRegistry } from '@/components/ui/ui-builder/types';
6+
import { Variable, ComponentLayer, ComponentRegistry, PropValue } from '@/components/ui/ui-builder/types';
77

8-
interface LayerRendererProps {
8+
interface LayerRendererProps<TRegistry extends ComponentRegistry = ComponentRegistry> {
99
className?: string;
1010
page: ComponentLayer;
1111
editorConfig?: EditorConfig;
12-
componentRegistry: ComponentRegistry;
12+
componentRegistry: TRegistry;
1313
/** Optional variable definitions */
1414
variables?: Variable[];
1515
/** Optional variable values to override defaults */
16-
variableValues?: Record<string, any>;
16+
variableValues?: Record<string, PropValue>;
1717
}
1818

19-
const LayerRenderer: React.FC<LayerRendererProps> = ({
19+
const LayerRenderer = <TRegistry extends ComponentRegistry = ComponentRegistry>({
2020
className,
2121
page,
2222
editorConfig,
2323
componentRegistry,
2424
variables,
2525
variableValues,
26-
}: LayerRendererProps) => {
26+
}: LayerRendererProps<TRegistry>): JSX.Element => {
2727

2828
return (
2929
<DevProfiler id="LayerRenderer" threshold={30}>

0 commit comments

Comments
 (0)