How to detect "initial" vs "new item" state in AutoCrud's FormLayoutRenderer?

Hello everyone,

I’m using the AutoCrud component with a custom formProps.layoutRenderer to create a conditional form.

My Problem:
The behavior when I create a custom renderer is that the fields in the form are enabled all the time.

My Goal:
I want the fields inside my custom form renderer to be disabled (like the default behavior) when the component first loads (the “initial” state) and only become enabled when the user either clicks the “New” button or selects an existing item from the grid.

I tried looking at the available fields in the form object, but there is no difference between the initial state and the “new” state on that form object that I can key off of to disable the fields.

This video shows the default behavior with the “Sources” page when I don’t override the renderer, as well as the bad behavior on the “Destination” page with the overridden renderer.

Any ideas for what to do here to get the correct behavior?

It’s a bit hard to guess what prevents it from being initially (or conditionally) disabled without looking at how you utilized the disabled property of the AutoForm. How is it set in the “Destination” view that has the custom form layout renderer?

You can’t access the disabled property on the form:
export type AutoCrudFormProps<TModel extends AbstractModel> = Omit<Partial<AutoFormProps<TModel>>, 'disabled' | 'item' | 'model' | 'onDeleteSuccess' | 'onSubmitSuccess' | 'service'> & Readonly<{ headerRenderer?: AutoCrudFormHeaderRenderer<Value<TModel>>; }>;

Here is my source.tsx:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';
import { DestinationService, SourceAuthConfigService, SourceService } from "Frontend/generated/endpoints";
import SourceAuthConfigModel from "Frontend/generated/ai/goacquire/shared/entity/SourceAuthConfigModel";
import SourceAuthConfig from "Frontend/generated/ai/goacquire/shared/entity/SourceAuthConfig";
import Source from "Frontend/generated/ai/goacquire/shared/entity/Source";
import { AutoCrud } from "@vaadin/hilla-react-crud";
import {
    Avatar,
    GridColumn,
    TextField,
    Checkbox,
    PasswordField,
    TextArea,
    ComboBox,
    ComboBoxFilterChangedEvent,
    MultiSelectComboBox
} from "@vaadin/react-components";
import Matcher from "Frontend/generated/com/vaadin/hilla/crud/filter/PropertyStringFilter/Matcher";
import { useEffect } from 'react';
import { useSignal } from "@vaadin/hilla-react-signals";
import Destination from "Frontend/generated/ai/goacquire/shared/entity/Destination";
import { getDestinationIcon } from "Frontend/components/destination-icon-mapping";

export const config: ViewConfig = {
    menu: { order: 3, icon: 'vaadin:connect' },
    title: 'Source',
    loginRequired: true,
    rolesAllowed: ["ADMIN"]
};

export default function SourceView() {
    const allSources = useSignal<Source[]>([]);
    const filteredSources = useSignal<Source[]>([]);
    const allDestinations = useSignal<Destination[]>([]);

    useEffect(() => {
        const pageable = {
            pageNumber: 0,
            pageSize: 1000,
            offset: 0,
            sort: { sorted: false, orders: [] },
            paged: true,
            unpaged: false
        };

        SourceService.list(pageable, undefined).then(tmpSources => {
            if (tmpSources) {
                allSources.value = tmpSources.filter((s): s is Source => !!s && s.active);
                filteredSources.value = allSources.value;
            }
        });

        DestinationService.list(pageable, undefined).then(tmpDestinations => {
            if (tmpDestinations) {
                allDestinations.value = tmpDestinations.filter((d): d is Destination => !!d && d.active);
            }
        });
    }, []);


    function sourceFilterChanged(event: ComboBoxFilterChangedEvent) {
        const filter = event.detail.value;
        if (!filter || filter.length === 0 || filter.trim().length === 0) {
            filteredSources.value = allSources.value;
            return;
        }
        filteredSources.value = allSources.value.filter(({ name, internalFormat }) =>
            name?.toLowerCase().includes(filter.toLowerCase()) || internalFormat?.name?.toLowerCase().includes(filter.toLowerCase())
        );
    }

    return (
        <AutoCrud
            service={SourceAuthConfigService}
            model={SourceAuthConfigModel}
            style={{ height: '100%' }}
            gridProps={{
                visibleColumns: [
                    'logoUrl',
                    'source',
                    'internalFormat',
                    'description',
                    'url',
                    'username',
                    'active'
                ],
                customColumns: [
                    <GridColumn
                        key="logoUrl"
                        header="Logo"
                        autoWidth
                        renderer={({ item }: { item: SourceAuthConfig }) => (
                            <Avatar
                                img={item.source?.logoUrl}
                                name={item.source?.name}
                            />
                        )}
                    />,
                    <GridColumn
                        key="source"
                        header="Source Name"
                        autoWidth
                        renderer={({ item }: { item: SourceAuthConfig }) => (
                            <span>{item.source?.name}</span>
                        )}
                    />,
                    <GridColumn
                        key="internalFormat"
                        header="Format"
                        autoWidth
                        renderer={({ item }: { item: SourceAuthConfig }) => (
                            <span>{item.source?.internalFormat?.name}</span>
                        )}
                    />,
                    <GridColumn
                        key="url"
                        header="URL"
                        autoWidth
                        renderer={({ item }: { item: SourceAuthConfig }) => (
                            <span>{item.source?.url}</span>
                        )}
                    />,
                ],
                columnOptions: {
                    source: {
                        headerFilterRenderer: ({ setFilter }) => (
                            <TextField
                                theme='small'
                                placeholder='Filter source...'
                                onValueChanged={({ detail: { value } }) =>
                                    setFilter({
                                        propertyId: "source.name",
                                        filterValue: value,
                                        matcher: Matcher.CONTAINS,
                                        "@type": "propertyString",
                                    })
                                }
                            />
                        ),
                    },
                    internalFormat: {
                        headerFilterRenderer: ({ setFilter }) => (
                            <TextField
                                theme='small'
                                placeholder='Filter format...'
                                onValueChanged={({ detail: { value } }) =>
                                    setFilter({
                                        propertyId: "source.internalFormat.name",
                                        filterValue: value,
                                        matcher: Matcher.CONTAINS,
                                        "@type": "propertyString",
                                    })
                                }
                            />
                        ),
                    },
                    description: {
                        headerFilterRenderer: ({ setFilter }) => (
                            <TextField
                                theme="small"
                                placeholder="Filter description..."
                                onValueChanged={({ detail: { value } }) =>
                                    setFilter({
                                        propertyId: "description",
                                        filterValue: value,
                                        matcher: Matcher.CONTAINS,
                                        "@type": "propertyString",
                                    })
                                }
                            />
                        ),
                    },
                    url: {
                        headerFilterRenderer: ({ setFilter }) => (
                            <TextField
                                theme='small'
                                placeholder='Filter URL...'
                                onValueChanged={({ detail: { value } }) =>
                                    setFilter({
                                        propertyId: "source.url",
                                        filterValue: value,
                                        matcher: Matcher.CONTAINS,
                                        "@type": "propertyString",
                                    })
                                }
                            />
                        ),
                    },
                    username: {
                        headerFilterRenderer: ({ setFilter }) => (
                            <TextField
                                theme="small"
                                placeholder="Filter username..."
                                onValueChanged={({ detail: { value } }) =>
                                    setFilter({
                                        propertyId: "username",
                                        filterValue: value,
                                        matcher: Matcher.CONTAINS,
                                        "@type": "propertyString",
                                    })
                                }
                            />
                        ),
                    }
                }
            }}
            formProps={{
                visibleFields: [
                    'source',
                    'description',
                    'username',
                    'password',
                    'totpSecret',
                    'destinations',
                    'additionalConfig',
                    'active'
                ],
                fieldOptions: {
                    source: {
                        renderer: ({ field }) => (
                            <ComboBox
                                {...field}
                                label="Source"
                                placeholder="Select a source..."
                                items={allSources.value}
                                itemIdPath="id"
                                itemValuePath="name"
                                itemLabelPath="name"
                                required
                                // Custom filter function to search both name and internal format
                                filteredItems={filteredSources.value}
                                onFilterChanged={sourceFilterChanged}
                                // Enhanced renderer to show logo, name, and internal format
                                renderer={({ item: source }: { item: Source }) => (
                                    <div className="flex items-center gap-s">
                                        <Avatar
                                            img={source.logoUrl}
                                            name={source.name}
                                        />
                                        <div className="flex flex-col">
                                            <span className="font-medium">{source.name}</span>
                                            <span className="text-s text-secondary">
                                                Format: {source.internalFormat?.name}
                                            </span>
                                        </div>
                                    </div>
                                )}
                            />
                        ),
                    },
                    destinations: {
                        renderer: ({ field }) => (
                            <MultiSelectComboBox
                                {...field}
                                items={allDestinations.value}
                                placeholder="Select destinations..."
                                itemIdPath="id"
                                itemValuePath="name"
                                itemLabelPath="name"
                                helperText="Select where scraped files should be sent"
                                renderer={({ item: destination }: { item: Destination }) => (
                                    <div className="flex items-center gap-s">
                                        <Avatar
                                            img={getDestinationIcon(destination?.type)}
                                            name={destination?.name}
                                        />
                                        <div className="flex flex-col">
                                            <span className="font-medium">{destination?.name}</span>
                                            <span className="text-s text-secondary">{destination?.type}</span>
                                        </div>
                                    </div>
                                )}
                            />
                        )
                    },
                    description: {
                        renderer: ({ field }) => (
                            <TextField
                                {...field}
                                label="Description"
                                placeholder="e.g., US Account, CA Account, Production, Testing"
                                required
                                helperText="A descriptive name to identify this configuration"
                            />
                        ),
                    },
                    username: {
                        renderer: ({ field }) => (
                            <TextField
                                {...field}
                                label="Username"
                                required
                            />
                        ),
                    },
                    password: {
                        renderer: ({ field }) => (
                            <PasswordField
                                {...field}
                                label="Password"
                                helperText="Password will be encrypted and stored securely"
                            />
                        ),
                    },
                    totpSecret: {
                        renderer: ({ field }) => (
                            <PasswordField
                                {...field}
                                label="TOTP Secret"
                                helperText="Time-based One-Time Password secret for 2FA"
                            />
                        ),
                    },
                    additionalConfig: {
                        renderer: ({ field }) => (
                            <TextArea
                                {...field}
                                label="Additional Configuration (JSON)"
                                placeholder='{"key": "value"}'
                                style={{ minHeight: '150px' }}
                                helperText="Process-specific configuration in JSON format"
                            />
                        ),
                    },
                    active: {
                        renderer: ({ field }) => (
                            <Checkbox
                                {...field}
                                label="Active"
                            />
                        ),
                    },
                },
            }}
        />
    );
}

And here is my destination.tsx:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';
import { DestinationService } from "Frontend/generated/endpoints";
import DestinationModel from "Frontend/generated/ai/goacquire/shared/entity/DestinationModel";
import Destination from "Frontend/generated/ai/goacquire/shared/entity/Destination";
import { AutoCrud } from "@vaadin/hilla-react-crud";
import { ComboBox, TextField, TextArea, Checkbox, PasswordField, NumberField, Avatar, GridColumn } from "@vaadin/react-components";
import { useCallback } from 'react';
import { useSignal } from "@vaadin/hilla-react-signals";
import DestinationType from "Frontend/generated/ai/goacquire/shared/entity/DestinationType";
import { AutoFormLayoutRendererProps } from '@vaadin/hilla-react-crud';
import { getDestinationIcon } from 'Frontend/components/destination-icon-mapping';

export const config: ViewConfig = {
    menu: { order: 2, icon: 'vaadin:exchange' },
    title: 'Destination',
    loginRequired: true,
    rolesAllowed: ["ADMIN"]
};

// Custom layout renderer for the form
function FormLayoutRenderer({ form }: AutoFormLayoutRendererProps<DestinationModel>) {
    const { field, model, value } = form;
    // When no item is being edited, the form value is null. In this case, disable the fields.
    const isDisabled = !value;

    // Logging to verify the disabled state in the browser's developer console.
    console.log(`Form renderer: isDisabled is ${isDisabled}`, { form });

    const selectedType = useSignal<DestinationType | null>(value?.type || null);

    // Handle type change
    const handleTypeChange = (e: CustomEvent) => {
        const newType = e.detail.value as DestinationType;
        selectedType.value = newType;
    };

    return (
        <div className="flex flex-col gap-s p-m">
            {/* Base Fields */}
            <TextField label="Name" {...field(model.name)} disabled={isDisabled} />
            <TextArea label="Description" {...field(model.description)} disabled={isDisabled} />
            <ComboBox
                label="Type"
                items={Object.values(DestinationType)}
                {...field(model.type)}
                onValueChanged={handleTypeChange}
                disabled={isDisabled}
            />
            <Checkbox label="Active" {...field(model.active)} disabled={isDisabled} />

            {/* Email Fields */}
            {selectedType.value === 'EMAIL' && (
                <div className="flex flex-col gap-s">
                    <TextField label="Email Address" {...field(model.emailAddress)} disabled={isDisabled} />
                </div>
            )}

            {/* SFTP Fields */}
            {selectedType.value === 'SFTP' && (
                <div className="flex flex-col gap-s">
                    <TextField label="SFTP Host" {...field(model.sftpHost)} disabled={isDisabled} />
                    <NumberField
                        label="SFTP Port"
                        {...field(model.sftpPort)}
                        min={1}
                        max={65535}
                        stepButtonsVisible
                        disabled={isDisabled}
                    />
                    <TextField label="SFTP Username" {...field(model.sftpUsername)} disabled={isDisabled} />
                    <PasswordField label="SFTP Password" {...field(model.sftpPassword)} disabled={isDisabled} />
                    <TextArea label="SFTP Private Key" {...field(model.sftpPrivateKey)} disabled={isDisabled} />
                    <TextField label="Base Directory" {...field(model.sftpBaseDirectory)} disabled={isDisabled} />
                </div>
            )}

            {/* S3 Fields */}
            {selectedType.value === 'S3' && (
                <div className="flex flex-col gap-s">
                    <TextField label="S3 Region" {...field(model.s3Region)} disabled={isDisabled} />
                    <TextField label="S3 Bucket" {...field(model.s3Bucket)} disabled={isDisabled} />
                    <PasswordField label="Access Key" {...field(model.s3AccessKey)} disabled={isDisabled} />
                    <PasswordField label="Secret Key" {...field(model.s3SecretKey)} disabled={isDisabled} />
                    <TextField label="Base Path" {...field(model.s3BasePath)} disabled={isDisabled} />
                </div>
            )}

            {/* Cloud Storage Fields */}
            {(selectedType.value === 'GOOGLE_DRIVE' || selectedType.value === 'ONEDRIVE' || selectedType.value === 'DROPBOX') && (
                <div className="flex flex-col gap-s">
                    <PasswordField label="OAuth Refresh Token" {...field(model.oauthRefreshToken)} disabled={isDisabled} />
                    <TextField label="Base Folder" {...field(model.cloudBaseFolder)} disabled={isDisabled} />
                </div>
            )}

            {/* Azure Fields */}
            {selectedType.value === 'AZURE_BLOB' && (
                <div className="flex flex-col gap-s">
                    <TextField label="Container" {...field(model.azureContainer)} disabled={isDisabled} />
                    <PasswordField label="Connection String" {...field(model.azureConnectionString)} disabled={isDisabled} />
                    <TextField label="Base Path" {...field(model.azureBasePath)} disabled={isDisabled} />
                </div>
            )}
        </div>
    );
}

export default function DestinationView() {
    return (
        <AutoCrud
            service={DestinationService}
            model={DestinationModel}
            style={{ height: '100%' }}
            gridProps={{
                visibleColumns: ['type', 'name', 'description', 'active'],
                customColumns: [
                    <GridColumn
                        key="type"
                        header="Type"
                        autoWidth
                        renderer={({ item }: { item: Destination }) => (
                            <div className="flex items-center gap-s">
                                {item.type && (
                                    <>
                                        <Avatar
                                            img={getDestinationIcon(item.type)}
                                            name={item.type}
                                        />
                                        <span>{item.type}</span>
                                    </>
                                )}
                            </div>
                        )}
                    />,
                ],
            }}
            formProps={{
                layoutRenderer: FormLayoutRenderer
            }}
        />
    );
}

Here is the output of the form object into the logs for when the page is first loaded and no object is selected:

{
  "form": {
    "defaultValue": {
      "active": false
    },
    "dirty": false,
    "errors": [],
    "invalid": false,
    "model": {},
    "name": "",
    "ownErrors": [],
    "required": false,
    "validators": [],
    "value": {
      "active": false
    },
    "visited": false,
    "submitting": false
  }
}

vs when I hit the new button:

{
  "form": {
    "defaultValue": {
      "active": false
    },
    "dirty": false,
    "errors": [],
    "invalid": false,
    "model": {},
    "name": "",
    "ownErrors": [],
    "required": false,
    "validators": [],
    "value": {
      "active": false
    },
    "visited": false,
    "submitting": false
  }
}

vs when I click a row in the grid:

{
  "form": {
    "defaultValue": {
      "id": 1,
      "name": "Adam Email",
      "description": "My email",
      "type": "EMAIL",
      "active": true,
      "encryptionKeyId": "cdd4e02b-0d1e-4921-949f-b282015cdb2c",
      "emailAddress": "adam@goacquire.ai"
    },
    "dirty": false,
    "errors": [],
    "invalid": false,
    "model": {},
    "name": "",
    "ownErrors": [],
    "required": false,
    "validators": [],
    "value": {
      "id": 1,
      "name": "Adam Email",
      "description": "My email",
      "type": "EMAIL",
      "active": true,
      "encryptionKeyId": "cdd4e02b-0d1e-4921-949f-b282015cdb2c",
      "emailAddress": "adam@goacquire.ai"
    },
    "visited": false,
    "submitting": false
  }
}

The disabled logic in the AutoCrud (which is passed down to the AutoForm) is like this:

and then, AutoForm is in charge of creating the fields and passing them to a possibly existing LayoutRenderer, here:

So, the logic of LayoutRenderer should actually only place the received children (fields) in the correct place (according to your custom design), but not to recreate them, as they’ve already bean created for you, containing the proper disabled logic passed down to them (see createAutoFormField implementation). That’s probably why the definition of AutoCrudFormProps omits the disabled.