Skip to content

Commit

Permalink
Fixed issue with nested elements
Browse files Browse the repository at this point in the history
- Custom and placeholder elements were not working when nesting them inside of another component
- This is now fixed by searching recursively through all children
- Added typesafety with modelNames and typedModelFields
  • Loading branch information
kirankunigiri committed Dec 2, 2024
1 parent 7b353de commit fd6171c
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 56 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ This is all the code you need to generate the forms seen in the demo! Any change

```tsx
// Create form
<ZenstackCreateForm model="Item" />
<ZenstackCreateForm model={modelNames.item} />

// Update form (for example, you would pass an id from a page parameter)
<ZenstackUpdateForm model="Item" id={0} />
<ZenstackUpdateForm model={modelNames.item} id={0} />
```

You can see the results in the demo!
28 changes: 28 additions & 0 deletions example-client/src/form/form-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import * as schemas from '~zenstack/zod/models';
import { type Field, FieldType } from '~zenstack-ui/metadata';
import type { MapFieldTypeToElement, MapSubmitTypeToButton, SubmitButtonProps, ZenstackUIConfigType } from '~zenstack-ui/utils/provider';

// --------------------------------------------------------------------------------
// Form Config - Element Mapping
// --------------------------------------------------------------------------------
const mapFieldTypeToElement: MapFieldTypeToElement = {
[FieldType.Boolean]: Checkbox,
[FieldType.String]: TextInput,
Expand Down Expand Up @@ -39,6 +42,26 @@ const submitButtonMap: MapSubmitTypeToButton = {
update: UpdateButton,
};

// --------------------------------------------------------------------------------
// Extract model names from metadata
// --------------------------------------------------------------------------------
type ModelNames = {
[K in keyof typeof metadata['models']]: typeof metadata['models'][K]['name']
};
/** List of all model names */
export const modelNames = Object.fromEntries(
Object.entries(metadata.models).map(([key, model]) => [key, model.name]),
) as ModelNames;

export const typedModelFields = <T extends keyof typeof meta.models>(modelName: T) => {
return Object.fromEntries(
Object.entries(meta.models[modelName].fields).map(([key, field]) => [key, field.name]),
) as Record<keyof typeof meta.models[T]['fields'], string>;
};

// --------------------------------------------------------------------------------
// Enhanced metadata
// --------------------------------------------------------------------------------
/** Enhance original zenstack metadata with custom fields for ZenstackForm */
type EnhancedMetadata<T> = T & {
models: {
Expand All @@ -50,7 +73,9 @@ type EnhancedMetadata<T> = T & {
}
};

// --------------------------------------------------------------------------------
// Customize metadata
// --------------------------------------------------------------------------------
export const meta = metadata as EnhancedMetadata<typeof metadata>;
meta.models.item.fields.id.hidden = true;
meta.models.room.fields.id.hidden = true;
Expand All @@ -68,6 +93,9 @@ meta.models.item.fields.ownerName.filter = (itemFields: typeof meta.models.item.
return ownerFields.roomId === itemFields.roomId;
};

// --------------------------------------------------------------------------------
// Export config
// --------------------------------------------------------------------------------
export const baseZenstackUIConfig = {
hooks,
schemas,
Expand Down
7 changes: 5 additions & 2 deletions example-client/src/routes/items/$id.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { Accordion } from '@mantine/core';
import { createFileRoute } from '@tanstack/react-router';

import { modelNames } from '~client/form/form-config';
import { DetailHeader } from '~client/form/lib/detail-header';
import MantineZenstackUpdateForm from '~client/form/lib/mantine-update-form';

export const Route = createFileRoute('/items/$id')({
component: ItemsDetail,
});

console.log(modelNames.item);

function ItemsDetail() {
const params = Route.useParams() as { id: string };
const id = Number(params.id);

return (
<div className="form-container">
<div>
<DetailHeader model="Item" id={id} route="/items" />
<MantineZenstackUpdateForm model="Item" id={id} route="/items/$id" />
<DetailHeader model={modelNames.item} id={id} route="/items" />
<MantineZenstackUpdateForm model={modelNames.item} id={id} route="/items/$id" />
</div>

<ItemsDetailCode />
Expand Down
5 changes: 3 additions & 2 deletions example-client/src/routes/people/$id.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';

import { modelNames } from '~client/form/form-config';
import { DetailHeader } from '~client/form/lib/detail-header';
import MantineZenstackUpdateForm from '~client/form/lib/mantine-update-form';

Expand All @@ -13,8 +14,8 @@ function PeopleDetail() {

return (
<div>
<DetailHeader model="Person" id={name} route="/people" />
<MantineZenstackUpdateForm model="Person" id={name} route="/people/$id" />
<DetailHeader model={modelNames.person} id={name} route="/people" />
<MantineZenstackUpdateForm model={modelNames.person} id={name} route="/people/$id" />
</div>
);
}
26 changes: 18 additions & 8 deletions example-client/src/routes/rooms/$id.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Textarea } from '@mantine/core';
import { Divider, Textarea } from '@mantine/core';
import { createFileRoute } from '@tanstack/react-router';
import { useRef } from 'react';

import { meta } from '~client/form/form-config';
import { modelNames, typedModelFields } from '~client/form/form-config';
import { DetailHeader } from '~client/form/lib/detail-header';
import MantineZenstackUpdateForm from '~client/form/lib/mantine-update-form';
import { type ZenstackFormRef } from '~zenstack-ui/index';
import { ZenstackFormPlaceholder, type ZenstackFormRef } from '~zenstack-ui/index';

export const Route = createFileRoute('/rooms/$id')({
component: PeopleDetail,
});

const roomFields = meta.models.room.fields;
const roomFields = typedModelFields('room');

function PeopleDetail() {
const params = Route.useParams() as { id: string };
Expand All @@ -22,17 +22,20 @@ function PeopleDetail() {

return (
<div>
<DetailHeader model="Room" id={id} route="/rooms" />
<MantineZenstackUpdateForm formRef={formRef} model="Room" id={id} route="/rooms/$id">
<DetailHeader model={modelNames.room} id={id} route="/rooms" />
<MantineZenstackUpdateForm formRef={formRef} model={modelNames.room} id={id} route="/rooms/$id">
<Divider mt="lg" my="md" variant="dashed" />
<div className="flex w-full gap-4">

{/* A placeholder example. This gets replaced by an input component */}
<div className="grow" data-placeholder-name={roomFields.description.name} />
<ZenstackFormPlaceholder className="grow" fieldName={roomFields.description} />
{/* <ZenstackTest /> */}

{/* A custom element example. This will be directly used by the form */}
<Textarea
className="grow"
autosize
data-field-name={roomFields.aiSummary.name}
data-field-name={roomFields.aiSummary}
label="AI Summary"
placeholder="AI Summary"
/>
Expand All @@ -41,3 +44,10 @@ function PeopleDetail() {
</div>
);
}

/** Test to make sure wrapped components still work with ZenstackFormPlaceholder */
const ZenstackTest = () => {
return (
<ZenstackFormPlaceholder className="grow" fieldName={roomFields.description} />
);
};
6 changes: 3 additions & 3 deletions example-client/src/routes/rooms/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';

import { meta } from '~client/form/form-config';
import { meta, modelNames } from '~client/form/form-config';
import { ListHeader } from '~client/form/lib/list-header';
import ListSkeleton from '~client/form/lib/skeleton';
import { trpc } from '~client/main';
Expand Down Expand Up @@ -29,9 +29,9 @@ function RoomsLayout() {
<div className="page">
{/* List View */}
<div className="left-list">
<ListHeader title="Rooms" model="Room" schemaOverride={CustomRoomCreateSchema} overrideSubmit={createRoom.mutateAsync} metadataOverride={metadataOverride} />
<ListHeader title="Rooms" model={modelNames.room} schemaOverride={CustomRoomCreateSchema} overrideSubmit={createRoom.mutateAsync} metadataOverride={metadataOverride} />
<ZenstackList<typeof RoomSchema._type>
model="Room"
model={modelNames.room}
skeleton={<ListSkeleton />}
render={room => (
<Link
Expand Down
115 changes: 76 additions & 39 deletions package/src/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,31 +323,42 @@ const ZenstackBaseForm = (props: ZenstackBaseFormProps) => {
const processChildren = (element: React.ReactNode): React.ReactNode => {
if (!isValidElement(element)) return element;

// Handle custom elements with data-field-name
if (element.props['data-field-name']) {
const fieldName = element.props['data-field-name'];
const field = fields[fieldName];
// First check if this element is a function component by checking if its type is a function
if (typeof element.type === 'function') {
// Check for ZenstackFormPlaceholder by display name
if ((element.type as any).displayName === ZENSTACK_FORM_PLACEHOLDER_DISPLAY_NAME) {
const fieldName = element.props.fieldName;
const field = fields[fieldName];

if (!field) {
console.warn(`Field ${fieldName} not found in model ${props.model}`);
return element;
}

if (!field) {
console.warn(`Field ${fieldName} not found in model ${props.model}`);
return element;
return (
<ZenstackFormInputInternal
key={fieldName}
field={field}
index={-1}
className={element.props.className}
{...props}
/>
);
}

return (
<ZenstackFormInputInternal
key={fieldName}
field={field}
index={-1}
{...props}
customElement={element}
/>
);
// Handle other function components
try {
const renderedElement = element.type(element.props);
return processChildren(renderedElement);
} catch (error) {
console.error('Error processing component:', error);
return element;
}
}

// Handle placeholder elements (data-placeholder-name)
if (element.props['data-placeholder-name']) {
const fieldName = element.props['data-placeholder-name'];
console.log('found placeholder', fieldName);
// Handle data attributes
if (element.props['data-field-name']) {
const fieldName = element.props['data-field-name'];
const field = fields[fieldName];

if (!field) {
Expand All @@ -360,45 +371,64 @@ const ZenstackBaseForm = (props: ZenstackBaseFormProps) => {
key={fieldName}
field={field}
index={-1}
className={element.props.className}
{...props}
customElement={element}
/>
);
}

// If element has children, clone it and process its children
// Recursively process children
if (element.props.children) {
return cloneElement(element, {
...element.props,
children: React.Children.map(element.props.children, processChildren),
children: React.Children.map(element.props.children, child => processChildren(child)),
});
}

return element;
};

// Helper to check if a field has either a custom element or placeholder
const hasCustomOrPlaceholder = (fieldName: string) => {
return React.Children.toArray(props.children).some(
child => isValidElement(child) && (
child.props['data-field-name'] === fieldName
|| child.props['data-placeholder-name'] === fieldName
// Also check nested children
|| (child.props.children && React.Children.toArray(child.props.children).some(
nestedChild => isValidElement(nestedChild) && (
nestedChild.props['data-field-name'] === fieldName
|| nestedChild.props['data-placeholder-name'] === fieldName
),
))
),
);
// Helper to recursively check for custom elements or placeholders
const hasCustomOrPlaceholder = (fieldName: string, children: React.ReactNode): boolean => {
return React.Children.toArray(children).some((child) => {
if (!isValidElement(child)) return false;

// Handle function components by rendering them
if (typeof child.type === 'function') {
// Check for ZenstackFormPlaceholder by display name
if ((child.type as any).displayName === ZENSTACK_FORM_PLACEHOLDER_DISPLAY_NAME
&& child.props.fieldName === fieldName) {
return true;
}

try {
const renderedElement = child.type(child.props);
return hasCustomOrPlaceholder(fieldName, renderedElement);
} catch (error) {
console.error('Error processing component:', error);
return false;
}
}

// Check current element's data attributes
if (child.props['data-field-name'] === fieldName) {
return true;
}

// Recursively check children
if (child.props.children) {
return hasCustomOrPlaceholder(fieldName, child.props.children);
}

return false;
});
};

return (
<>
{/* Render default form fields that don't have custom elements or placeholders */}
{Object.values(fields).map((field, index) => {
if (hasCustomOrPlaceholder(field.name)) return null;
if (hasCustomOrPlaceholder(field.name, props.children)) return null;

return (
<ZenstackFormInputInternal
Expand Down Expand Up @@ -648,3 +678,10 @@ const ZenstackFormInputInternal = (props: ZenstackFormInputProps) => {
/>
);
};

/** A placeholder component will be replaced by the actual input component in the form. */
export const ZenstackFormPlaceholder = ({ fieldName, className, ...rest }: { fieldName: string, className?: string, [key: string]: any }) => {
return <div className={className} {...rest} />;
};
const ZENSTACK_FORM_PLACEHOLDER_DISPLAY_NAME = 'ZenstackFormPlaceholder';
ZenstackFormPlaceholder.displayName = ZENSTACK_FORM_PLACEHOLDER_DISPLAY_NAME;

0 comments on commit fd6171c

Please sign in to comment.