Skip to main content

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
  1. Schema Definition: Entity types defined in TerminusDB
  2. Schema Conversion: Converted to JSON Schema format
  3. Form Generation: Felte creates form state
  4. Validation: AJV validates against JSON Schema
  5. 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 values
  • errors - Validation errors
  • touched - Which fields have been touched
  • isValid - Overall validity
  • isSubmitting - Submission state

Field Rendering

Field Types

JSON SchemaField Component
stringTextField
string + format: emailEmailField
string + enumSelectField
integerNumberField
booleanCheckboxField
arrayArrayField
$refReferenceField

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

Reference fields include search:

  1. User types in field
  2. Backend searches matching entities
  3. Results displayed in dropdown
  4. 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