Schema-Driven Forms
PBS Knowledge uses a schema-driven approach to generate forms dynamically from the TerminusDB schema.
Overview
How It Works
TerminusDB Schema → JSON Schema → Felte Form → UI Components
- Schema Definition: Entity types defined in TerminusDB
- Schema Conversion: Converted to JSON Schema format
- Form Generation: Felte creates form state
- Validation: AJV validates against JSON Schema
- Rendering: Custom renderers display fields
Benefits
- Consistent forms across the application
- Validation matches data model
- Easy to update when schema changes
- Reduced boilerplate code
Components
GenericEntityForm
The main form component:
<GenericEntityForm
entityType="Faculty"
entityId={facultyId}
on:save={handleSave}
on:cancel={handleCancel}
/>
Key Files
frontend/src/lib/forms/
├── GenericEntityForm.svelte # Main form component
├── fields/ # Field components
│ ├── TextField.svelte
│ ├── SelectField.svelte
│ ├── ReferenceField.svelte
│ └── ...
├── validators/ # Custom validators
├── renderers/ # Field renderers
└── loaders/ # Data loaders
Form Configuration
Fetching Schema
Schema is fetched from the backend:
const response = await fetch(`/api/schema/${entityType}`);
const schema = await response.json();
JSON Schema Format
{
"type": "object",
"properties": {
"first_name": {
"type": "string",
"title": "First Name"
},
"email": {
"type": "string",
"format": "email",
"title": "Email"
},
"appointment_type": {
"type": "string",
"enum": ["tenure_track", "research", "adjunct"],
"title": "Appointment Type"
}
},
"required": ["first_name", "last_name"]
}
Felte Integration
Form Setup
<script>
import { createForm } from 'felte';
import { validator } from '@felte/validator-ajv';
const { form, data, errors } = createForm({
initialValues: entity,
extend: validator({ schema }),
onSubmit: async values => {
await saveEntity(values);
},
});
</script>
<form use:form>
<!-- fields -->
</form>
Form State
Felte provides:
data- Current form valueserrors- Validation errorstouched- Which fields have been touchedisValid- Overall validityisSubmitting- Submission state
Field Rendering
Field Types
| JSON Schema | Field Component |
|---|---|
string | TextField |
string + format: email | EmailField |
string + enum | SelectField |
integer | NumberField |
boolean | CheckboxField |
array | ArrayField |
$ref | ReferenceField |
Custom Field Renderer
<script>
export let name;
export let schema;
export let value;
export let error;
</script>
{#if schema.type === 'string' && schema.enum}
<SelectField {name} options={schema.enum} {value} {error} />
{:else if schema.type === 'string'}
<TextField {name} {value} {error} />
{/if}
Reference Fields
Handling References
Reference fields link to other entities:
<ReferenceField name="advisor" entityType="Faculty" value={advisorId} on:change={handleChange} />
Autocomplete Search
Reference fields include search:
- User types in field
- Backend searches matching entities
- Results displayed in dropdown
- Selection updates form value
Validation
AJV Validation
Validation happens automatically:
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv();
addFormats(ajv);
const validate = ajv.compile(schema);
const valid = validate(data);
Custom Validators
Add custom validation:
// In validators/
export const customValidator = (value, schema) => {
if (schema.format === 'dartmouth-id') {
return /^d\d{5}$/.test(value);
}
return true;
};
Error Display
Errors appear below fields:
{#if $errors[name]}
<span class="error">{$errors[name]}</span>
{/if}
Array Fields
Handling Arrays
For properties like education:
<ArrayField name="education" schema={schema.properties.education} values={$data.education} />
Adding/Removing Items
<script>
function addItem() {
$data.education = [...$data.education, ''];
}
function removeItem(index) {
$data.education = $data.education.filter((_, i) => i !== index);
}
</script>
Conditional Fields
Showing/Hiding Fields
Based on other field values:
{#if $data.appointment_type === 'tenure_track'}
<TextField name="tenure_year" label="Tenure Year" />
{/if}
Dynamic Schema
Modify schema based on state:
$: effectiveSchema = {
...baseSchema,
required: $data.is_active ? [...baseSchema.required, 'email'] : baseSchema.required,
};
Enum Handling
Enum Registry
Enums are mapped in schema-enums.json:
{
"enums": {
"appointment_type": {
"values": ["tenure_track", "research", "adjunct"],
"description": "Faculty appointment type",
"widget": "select"
}
}
}
Enum Display
Convert values for display:
const displayValue = value => {
return value.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
};
Form Customization
Overriding Defaults
Customize specific fields:
<GenericEntityForm
entityType="Faculty"
fieldOverrides={{
bio: { component: RichTextEditor },
email: { readonly: true },
}}
/>
Custom Layouts
Override form layout:
<GenericEntityForm entityType="Faculty">
<div slot="layout" let:fields>
<div class="grid grid-cols-2">
<svelte:component this={fields.first_name} />
<svelte:component this={fields.last_name} />
</div>
</div>
</GenericEntityForm>
Best Practices
Performance
- Memoize schema parsing
- Debounce validation
- Lazy load large enums
User Experience
- Show validation on blur, not type
- Provide helpful error messages
- Indicate required fields clearly
Maintenance
- Keep schema source of truth
- Document custom validators
- Test form behavior with schema changes