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
3 changes: 3 additions & 0 deletions .github/workflows/pull-request-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ jobs:
- name: Lint code
run: npx eslint components/ui/ui-builder/ lib/ --max-warnings 0

- name: Type check
run: npx tsc --noEmit

- name: Run tests
run: npm run test:ci
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,6 @@ npm run test
## Roadmap

- [ ] 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
- [ ] Configurable Tailwind Class subset for things like React-Email components
- [ ] Drag and drop component in the editor panel and not just in the layers panel
Expand Down
8 changes: 4 additions & 4 deletions __tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,17 @@ it("UIBuilder: accepts both initialLayers and initialVariables", async () => {
expect(componentEditor).toBeInTheDocument();
});

it("UIBuilder: hides Add Variable button when editVariables is false", async () => {
it("UIBuilder: hides Add Variable button when allowVariableEditing is false", async () => {
render(
<UIBuilder
componentRegistry={componentRegistry}
editVariables={false}
allowVariableEditing={false}
/>
);
const componentEditor = await screen.findByTestId("component-editor");
expect(componentEditor).toBeInTheDocument();

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

// The editVariables prop should default to true
// The allowVariableEditing prop should default to true
// We can verify this by checking that the component renders without errors
expect(componentEditor).toBeInTheDocument();
});
18 changes: 10 additions & 8 deletions components/ui/ui-builder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import { useEditorStore } from "@/lib/ui-builder/store/editor-store";
import {
ComponentRegistry,
ComponentLayer,
Variable
Variable,
LayerChangeHandler,
VariableChangeHandler
} from "@/components/ui/ui-builder/types";
import { TailwindThemePanel } from "@/components/ui/ui-builder/internal/tailwind-theme-panel";
import { ConfigPanel } from "@/components/ui/ui-builder/internal/config-panel";
Expand Down Expand Up @@ -52,14 +54,14 @@ interface PanelConfig {
}

/**
* UIBuilderProps defines the props for the UIBuilder component.
* UIBuilderProps defines the props for the UIBuilder component with enhanced type safety.
*/
interface UIBuilderProps {
interface UIBuilderProps<TRegistry extends ComponentRegistry = ComponentRegistry> {
initialLayers?: ComponentLayer[];
onChange?: (pages: ComponentLayer[]) => void;
onChange?: LayerChangeHandler<TRegistry>;
initialVariables?: Variable[];
onVariablesChange?: (variables: Variable[]) => void;
componentRegistry: ComponentRegistry;
onVariablesChange?: VariableChangeHandler;
componentRegistry: TRegistry;
panelConfig?: PanelConfig;
persistLayerStore?: boolean;
allowVariableEditing?: boolean;
Expand All @@ -73,7 +75,7 @@ interface UIBuilderProps {
* @param {UIBuilderProps} props - The props for the UIBuilder component.
* @returns {JSX.Element} The UIBuilder component wrapped in a ThemeProvider.
*/
const UIBuilder = ({
const UIBuilder = <TRegistry extends ComponentRegistry = ComponentRegistry>({
initialLayers,
onChange,
initialVariables,
Expand All @@ -84,7 +86,7 @@ const UIBuilder = ({
allowVariableEditing = true,
allowPagesCreation = true,
allowPagesDeletion = true,
}: UIBuilderProps) => {
}: UIBuilderProps<TRegistry>) => {
const layerStore = useStore(useLayerStore, (state) => state);
const editorStore = useStore(useEditorStore, (state) => state);

Expand Down
7 changes: 6 additions & 1 deletion components/ui/ui-builder/internal/layer-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,14 @@ export const LayerMenu: React.FC<MenuProps> = ({
const componentDef = componentRegistry[selectedLayer.type as keyof typeof componentRegistry];
if (!componentDef) return false;

// Safely check if schema has shape property (ZodObject) and children field
const hasChildrenField = 'shape' in componentDef.schema &&
componentDef.schema.shape &&
componentDef.schema.shape.children !== undefined;

return (
hasLayerChildren(selectedLayer) &&
componentDef.schema.shape.children !== undefined
hasChildrenField
);
}, [selectedLayer, componentRegistry]);

Expand Down
15 changes: 12 additions & 3 deletions components/ui/ui-builder/internal/props-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ const ComponentPropsAutoForm: React.FC<ComponentPropsAutoFormProps> = ({
Object.keys(dataProps as Record<string, any>).forEach((key) => {
const originalValue = selectedLayer.props[key];
const newValue = (dataProps as Record<string, any>)[key];
const fieldDef = schema?.shape?.[key];
const fieldDef = ('shape' in schema && schema.shape) ? schema.shape[key] : undefined;
const baseType = fieldDef
? getBaseType(fieldDef as z.ZodAny)
: undefined;
Expand Down Expand Up @@ -235,7 +235,7 @@ const ComponentPropsAutoForm: React.FC<ComponentPropsAutoFormProps> = ({
);

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

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

const autoFormSchema = useMemo(() => {
return addDefaultValues(schema, formValues);
// Only pass ZodObject schemas to addDefaultValues, otherwise return the original schema
if ('shape' in schema && typeof schema.shape === 'object') {
try {
return addDefaultValues(schema as any, formValues);
} catch (error) {
console.warn('Failed to add default values to schema:', error);
return schema;
}
}
return schema;
}, [schema, formValues]);

const autoFormFieldConfig = useMemo(() => {
Expand Down
7 changes: 3 additions & 4 deletions components/ui/ui-builder/internal/render-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ import { ErrorFallback } from "@/components/ui/ui-builder/internal/error-fallbac
import { isPrimitiveComponent } from "@/lib/ui-builder/store/editor-utils";
import { hasLayerChildren } from "@/lib/ui-builder/store/layer-utils";
import { DevProfiler } from "@/components/ui/ui-builder/internal/dev-profiler";
import { ComponentRegistry, ComponentLayer, Variable } from '@/components/ui/ui-builder/types';
import { ComponentRegistry, ComponentLayer, Variable, PropValue } from '@/components/ui/ui-builder/types';
import { useLayerStore } from "@/lib/ui-builder/store/layer-store";
import { resolveVariableReferences } from "@/lib/ui-builder/utils/variable-resolver";

export interface EditorConfig {

zIndex: number;
totalLayers: number;
selectedLayer: ComponentLayer;
Expand All @@ -30,7 +29,7 @@ export const RenderLayer: React.FC<{
componentRegistry: ComponentRegistry;
editorConfig?: EditorConfig;
variables?: Variable[];
variableValues?: Record<string, any>;
variableValues?: Record<string, PropValue>;
}> = memo(
({ layer, componentRegistry, editorConfig, variables, variableValues }) => {
const storeVariables = useLayerStore((state) => state.variables);
Expand All @@ -52,7 +51,7 @@ export const RenderLayer: React.FC<{

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

// Memoize child editor config to avoid creating objects in JSX
const childEditorConfig = useMemo(() => {
Expand Down
24 changes: 16 additions & 8 deletions components/ui/ui-builder/internal/tailwind-theme-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,27 +74,35 @@ function ThemePicker({
}) {
const { updateLayer: updateLayerProps } = useLayerStore();

// Safely extract values with type checking
const colorThemeValue = pageLayer.props?.["data-color-theme"];
const modeValue = pageLayer.props?.["data-mode"];
const borderRadiusValue = pageLayer.props?.borderRadius;

const [colorTheme, setColorTheme] = useState<BaseColor["name"]>(
pageLayer.props?.["data-color-theme"] || "red"
(typeof colorThemeValue === 'string' ? colorThemeValue : "red") as BaseColor["name"]
);
const [borderRadius, setBorderRadius] = useState(
pageLayer.props?.["data-border-radius"] || 0.3
typeof borderRadiusValue === 'number' ? borderRadiusValue : 0.5
);
const [mode, setMode] = useState<"light" | "dark">(
pageLayer.props?.["data-mode"] || "light"
(typeof modeValue === 'string' ? modeValue : "light") as "light" | "dark"
);



useEffect(() => {
if (isDisabled) return;

const colorData = baseColors.find((color) => color.name === colorTheme);
if (colorData) {
const colorThemeData = baseColors.find((color) => color.name === colorTheme);

if (colorThemeData) {
const colorDataWithBorder = {
...colorData,
...colorThemeData,
cssVars: {
...colorData.cssVars,
...colorThemeData.cssVars,
[mode]: {
...colorData.cssVars[mode],
...colorThemeData.cssVars[mode],
radius: `${borderRadius}rem`,
},
},
Expand Down
12 changes: 6 additions & 6 deletions components/ui/ui-builder/layer-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,27 @@ import React from "react";
import { EditorConfig, RenderLayer } from "@/components/ui/ui-builder/internal/render-utils";
import { DevProfiler } from "@/components/ui/ui-builder/internal/dev-profiler";

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

interface LayerRendererProps {
interface LayerRendererProps<TRegistry extends ComponentRegistry = ComponentRegistry> {
className?: string;
page: ComponentLayer;
editorConfig?: EditorConfig;
componentRegistry: ComponentRegistry;
componentRegistry: TRegistry;
/** Optional variable definitions */
variables?: Variable[];
/** Optional variable values to override defaults */
variableValues?: Record<string, any>;
variableValues?: Record<string, PropValue>;
}

const LayerRenderer: React.FC<LayerRendererProps> = ({
const LayerRenderer = <TRegistry extends ComponentRegistry = ComponentRegistry>({
className,
page,
editorConfig,
componentRegistry,
variables,
variableValues,
}: LayerRendererProps) => {
}: LayerRendererProps<TRegistry>): JSX.Element => {

return (
<DevProfiler id="LayerRenderer" threshold={30}>
Expand Down
Loading