ActiveDataSource's schema endpoint enables fully dynamic form generation on any frontend.
Every resource exposes:
GET /users/schema
Response:
{
"resource": "UserResource",
"model": "User",
"fields": [
{
"name": "name",
"type": "string",
"required": true,
"placeholder": "Full name"
},
{
"name": "email",
"type": "string",
"required": true,
"validations": { "format": "email" }
},
{
"name": "status",
"type": "select",
"options": ["active", "inactive", "pending"],
"required": true,
"default": "active"
},
{
"name": "salary",
"type": "decimal",
"read_only": false,
"write_only": false,
"description": "Annual salary in USD"
}
],
"relations": [
{
"name": "organization",
"kind": "belongs_to",
"resource": "OrganizationResource",
"optional": false
}
],
"filterable": ["status", "organization_id"],
"sortable": ["name", "email", "created_at"],
"pagination": {
"default_page_size": 25,
"maximum_page_size": 100
},
"flags": {
"exportable": true,
"importable": false,
"readonly": false
}
}class UserResource < ActiveDataSource::Resource
model User
field :name do
type :string
required true
placeholder "Enter full name"
description "User's display name"
validates presence: true, length: { minimum: 2 }
end
field :email do
type :string
required true
validates format: { with: URI::MailTo::EMAIL_REGEXP }
end
field :status do
type :select
options User.statuses.keys
required true
default "active"
end
field :role do
type :radio
options %w[admin manager member]
end
field :permissions do
type :multi_select
options %w[read write delete admin]
end
field :avatar do
type :image
end
field :bio do
type :text
placeholder "Tell us about yourself"
hidden false
end
field :salary do
type :decimal
read_only false
end
end| Type | UI Component |
|---|---|
string |
Text input |
text |
Textarea |
integer |
Number input (step=1) |
float |
Number input |
decimal |
Number input |
boolean |
Checkbox/toggle |
date |
Date picker |
datetime |
Datetime picker |
time |
Time picker |
select |
Dropdown (single) |
multi_select |
Multi-select dropdown |
radio |
Radio button group |
checkbox |
Checkbox group |
file |
File upload |
image |
Image upload with preview |
json |
JSON editor |
array |
Tag/chip input |
import { DataSource } from "@active-data-source/client";
const users = new DataSource("/users");
const schema = await users.schema();
// schema.fields contains everything needed to render a form
for (const field of schema.fields) {
console.log(field.name, field.type, field.options);
}// app/javascript/controllers/dynamic_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { schemaUrl: String }
async connect() {
const response = await fetch(this.schemaUrlValue, {
headers: { Accept: "application/json" }
});
const schema = await response.json();
this.renderForm(schema.fields);
}
renderForm(fields) {
const form = document.createElement("form");
fields
.filter(f => !f.hidden && !f.read_only)
.forEach(field => {
form.appendChild(this.buildField(field));
});
this.element.appendChild(form);
}
buildField(field) {
const wrapper = document.createElement("div");
wrapper.className = "field";
const label = document.createElement("label");
label.textContent = field.name.replace(/_/g, " ");
wrapper.appendChild(label);
if (field.type === "select") {
const select = document.createElement("select");
select.name = field.name;
(field.options || []).forEach(opt => {
const option = document.createElement("option");
option.value = opt;
option.textContent = opt;
select.appendChild(option);
});
wrapper.appendChild(select);
} else {
const input = document.createElement("input");
input.name = field.name;
input.type = field.type === "integer" || field.type === "decimal" ? "number" : "text";
input.placeholder = field.placeholder || "";
input.required = field.required || false;
wrapper.appendChild(input);
}
return wrapper;
}
}<div data-controller="dynamic-form"
data-dynamic-form-schema-url-value="/users/schema">
</div><script setup>
import { ref, onMounted } from "vue";
import { DataSource } from "@active-data-source/client";
const fields = ref([]);
const users = new DataSource("/users");
onMounted(async () => {
const schema = await users.schema();
fields.value = schema.fields.filter(f => !f.hidden && !f.read_only);
});
</script>
<template>
<form @submit.prevent="submit">
<div v-for="field in fields" :key="field.name" class="form-group">
<label>{{ field.name }}</label>
<select v-if="field.type === 'select'" :name="field.name" :required="field.required">
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<input v-else :type="inputType(field)" :name="field.name"
:placeholder="field.placeholder" :required="field.required" />
</div>
<button type="submit">Save</button>
</form>
</template>