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
}
}