Skip to content

Commit 4c45a54

Browse files
bchapuisclaude
andcommitted
Add label, defaultValue, and unique attributes to schema fields
Extend the Field interface with optional label, defaultValue, and unique properties to support richer form rendering and database constraints. Update all consumers for consistency: schema dialog UI, form page, SQL generation, schema validation, JSON Schema output, and table introspection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 54260a8 commit 4c45a54

8 files changed

Lines changed: 166 additions & 26 deletions

File tree

apps/api/src/routes/forms.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { verifyFormToken } from "@dafthunk/runtime";
10+
import type { Field } from "@dafthunk/types";
1011
import { Hono } from "hono";
1112

1213
import type { ApiContext } from "../context";
@@ -42,7 +43,7 @@ formRoutes.get("/:signedToken", async (c) => {
4243
const parsed = JSON.parse(schema) as {
4344
title: string;
4445
description?: string;
45-
fields: Array<{ name: string; type: string; required?: boolean }>;
46+
fields: Field[];
4647
};
4748

4849
return c.json({

apps/app/src/components/schema-dialog.tsx

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Field, FieldType, SchemaEntity } from "@dafthunk/types";
2+
import Asterisk from "lucide-react/icons/asterisk";
3+
import Hash from "lucide-react/icons/hash";
24
import KeyRound from "lucide-react/icons/key-round";
35
import PlusCircle from "lucide-react/icons/plus-circle";
46
import Trash2 from "lucide-react/icons/trash-2";
@@ -22,7 +24,6 @@ import {
2224
SelectValue,
2325
} from "@/components/ui/select";
2426
import { Spinner } from "@/components/ui/spinner";
25-
import { Switch } from "@/components/ui/switch";
2627
import { Textarea } from "@/components/ui/textarea";
2728
import {
2829
Tooltip,
@@ -88,18 +89,43 @@ function FieldEditor({ fields, onChange }: FieldEditorProps) {
8889
return (
8990
<div key={index} className="flex items-center gap-2">
9091
<Input
91-
placeholder="Field name"
92+
placeholder="Name"
9293
value={field.name}
93-
onChange={(e) => updateField(index, { name: e.target.value })}
94-
className={cn("flex-1", isDuplicate && "border-destructive")}
94+
onChange={(e) =>
95+
updateField(index, { name: e.target.value })
96+
}
97+
className={cn(
98+
"flex-1 min-w-0",
99+
isDuplicate && "border-destructive"
100+
)}
101+
/>
102+
<Input
103+
placeholder="Label"
104+
value={field.label ?? ""}
105+
onChange={(e) =>
106+
updateField(index, {
107+
label: e.target.value || undefined,
108+
})
109+
}
110+
className="flex-1 min-w-0"
111+
/>
112+
<Input
113+
placeholder="Default value"
114+
value={field.defaultValue ?? ""}
115+
onChange={(e) =>
116+
updateField(index, {
117+
defaultValue: e.target.value || undefined,
118+
})
119+
}
120+
className="flex-1 min-w-0"
95121
/>
96122
<Select
97123
value={field.type}
98124
onValueChange={(val) =>
99125
updateField(index, { type: val as FieldType })
100126
}
101127
>
102-
<SelectTrigger className="w-32">
128+
<SelectTrigger className="w-28">
103129
<SelectValue />
104130
</SelectTrigger>
105131
<SelectContent>
@@ -110,18 +136,6 @@ function FieldEditor({ fields, onChange }: FieldEditorProps) {
110136
))}
111137
</SelectContent>
112138
</Select>
113-
<div className="flex items-center gap-1">
114-
<Switch
115-
checked={field.required ?? false}
116-
onCheckedChange={(checked) =>
117-
updateField(index, {
118-
required: checked ? true : undefined,
119-
})
120-
}
121-
className="scale-75"
122-
/>
123-
<span className="text-xs text-muted-foreground">Req</span>
124-
</div>
125139
<TooltipProvider>
126140
<Tooltip>
127141
<TooltipTrigger asChild>
@@ -137,7 +151,8 @@ function FieldEditor({ fields, onChange }: FieldEditorProps) {
137151
onChange(
138152
fields.map((f, i) => ({
139153
...f,
140-
primaryKey: i === index ? true : undefined,
154+
primaryKey:
155+
i === index ? true : undefined,
141156
}))
142157
);
143158
}
@@ -160,6 +175,69 @@ function FieldEditor({ fields, onChange }: FieldEditorProps) {
160175
</TooltipContent>
161176
</Tooltip>
162177
</TooltipProvider>
178+
<TooltipProvider>
179+
<Tooltip>
180+
<TooltipTrigger asChild>
181+
<Button
182+
type="button"
183+
variant={field.unique ? "secondary" : "ghost"}
184+
size="icon"
185+
className="h-8 w-8 shrink-0"
186+
disabled={field.primaryKey}
187+
onClick={() =>
188+
updateField(index, {
189+
unique: field.unique ? undefined : true,
190+
})
191+
}
192+
>
193+
<Hash
194+
className={cn(
195+
"h-4 w-4",
196+
field.unique
197+
? "text-foreground"
198+
: "text-muted-foreground"
199+
)}
200+
/>
201+
</Button>
202+
</TooltipTrigger>
203+
<TooltipContent>
204+
{field.unique
205+
? "Remove unique constraint"
206+
: "Set as unique"}
207+
</TooltipContent>
208+
</Tooltip>
209+
</TooltipProvider>
210+
<TooltipProvider>
211+
<Tooltip>
212+
<TooltipTrigger asChild>
213+
<Button
214+
type="button"
215+
variant={field.required ? "secondary" : "ghost"}
216+
size="icon"
217+
className="h-8 w-8 shrink-0"
218+
onClick={() =>
219+
updateField(index, {
220+
required: field.required ? undefined : true,
221+
})
222+
}
223+
>
224+
<Asterisk
225+
className={cn(
226+
"h-4 w-4",
227+
field.required
228+
? "text-foreground"
229+
: "text-muted-foreground"
230+
)}
231+
/>
232+
</Button>
233+
</TooltipTrigger>
234+
<TooltipContent>
235+
{field.required
236+
? "Set as optional"
237+
: "Set as required"}
238+
</TooltipContent>
239+
</Tooltip>
240+
</TooltipProvider>
163241
<Button
164242
type="button"
165243
variant="ghost"
@@ -232,7 +310,7 @@ export function SchemaDialog({
232310

233311
return (
234312
<Dialog open={open} onOpenChange={onOpenChange}>
235-
<DialogContent className="sm:max-w-lg">
313+
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
236314
<DialogHeader>
237315
<DialogTitle>{title}</DialogTitle>
238316
</DialogHeader>

apps/app/src/pages/form-page.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ interface SchemaField {
2424
name: string;
2525
type: string;
2626
required?: boolean;
27+
label?: string;
28+
defaultValue?: string;
2729
}
2830

2931
interface FormConfig {
@@ -54,6 +56,8 @@ function SchemaFieldInput({
5456
onChange: (value: unknown) => void;
5557
disabled: boolean;
5658
}) {
59+
const displayLabel = field.label || field.name;
60+
5761
switch (field.type) {
5862
case "boolean":
5963
return (
@@ -64,7 +68,7 @@ function SchemaFieldInput({
6468
onCheckedChange={onChange}
6569
disabled={disabled}
6670
/>
67-
<Label htmlFor={field.name}>{field.name}</Label>
71+
<Label htmlFor={field.name}>{displayLabel}</Label>
6872
</div>
6973
);
7074

@@ -73,7 +77,7 @@ function SchemaFieldInput({
7377
return (
7478
<div className="space-y-2">
7579
<Label htmlFor={field.name}>
76-
{field.name}
80+
{displayLabel}
7781
{field.required && <span className="text-destructive"> *</span>}
7882
</Label>
7983
<Input
@@ -100,7 +104,7 @@ function SchemaFieldInput({
100104
return (
101105
<div className="space-y-2">
102106
<Label htmlFor={field.name}>
103-
{field.name}
107+
{displayLabel}
104108
{field.required && <span className="text-destructive"> *</span>}
105109
</Label>
106110
<Input
@@ -117,7 +121,7 @@ function SchemaFieldInput({
117121
return (
118122
<div className="space-y-2">
119123
<Label htmlFor={field.name}>
120-
{field.name}
124+
{displayLabel}
121125
{field.required && <span className="text-destructive"> *</span>}
122126
</Label>
123127
<Textarea
@@ -136,7 +140,7 @@ function SchemaFieldInput({
136140
return (
137141
<div className="space-y-2">
138142
<Label htmlFor={field.name}>
139-
{field.name}
143+
{displayLabel}
140144
{field.required && <span className="text-destructive"> *</span>}
141145
</Label>
142146
<Input
@@ -183,6 +187,13 @@ export function FormPage() {
183187
if (config.submitted) {
184188
setState({ status: "already_submitted" });
185189
} else {
190+
const defaults: Record<string, unknown> = {};
191+
for (const f of config.fields) {
192+
if (f.defaultValue !== undefined) {
193+
defaults[f.name] = f.defaultValue;
194+
}
195+
}
196+
setValues(defaults);
186197
setState({ status: "ready", config });
187198
}
188199
})

packages/runtime/src/nodes/database/database-describe-table-node.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,44 @@ export class DatabaseDescribeTableNode extends ExecutableNode {
8585
// Map schema results to Field format
8686
// PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk
8787
const rows = schemaResult.results as unknown as PragmaTableInfoRow[];
88+
89+
// Collect unique columns from single-column unique indexes
90+
const uniqueColumns = new Set<string>();
91+
try {
92+
const indexList = await connection.query(
93+
`PRAGMA index_list(${table})`
94+
);
95+
if (indexList.results) {
96+
for (const idx of indexList.results as unknown as Array<{
97+
name: string;
98+
unique: number;
99+
}>) {
100+
if (!idx.unique) continue;
101+
const indexInfo = await connection.query(
102+
`PRAGMA index_info(${idx.name})`
103+
);
104+
if (
105+
indexInfo.results &&
106+
indexInfo.results.length === 1
107+
) {
108+
const col = indexInfo.results[0] as unknown as {
109+
name: string;
110+
};
111+
uniqueColumns.add(col.name);
112+
}
113+
}
114+
}
115+
} catch {
116+
// PRAGMA index_list may not be available; proceed without unique info
117+
}
118+
88119
const fields: Field[] = rows.map((col) => ({
89120
name: col.name,
90121
type: mapSqliteToType(col.type || "TEXT"),
91122
...(col.pk ? { primaryKey: true } : {}),
123+
...(!col.pk && uniqueColumns.has(col.name)
124+
? { unique: true }
125+
: {}),
92126
}));
93127

94128
const schema: Schema = {

packages/runtime/src/utils/database-table.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ export function generateCreateTableSQL(schema: Schema): string {
114114
const columns = fields.map((field) => {
115115
const sqlType = mapTypeToSqlite(field.type);
116116
const pk = field.primaryKey ? " PRIMARY KEY" : "";
117-
return `${field.name} ${sqlType}${pk}`;
117+
const uq = !field.primaryKey && field.unique ? " UNIQUE" : "";
118+
return `${field.name} ${sqlType}${pk}${uq}`;
118119
});
119120

120121
return `CREATE TABLE IF NOT EXISTS ${name} (${columns.join(", ")})`;

packages/runtime/src/utils/schema-to-json-schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ export function schemaToJsonSchema(schema: Schema): Record<string, unknown> {
2727
if (field.type === "datetime") {
2828
prop.format = "date-time";
2929
}
30+
if (field.label) {
31+
prop.description = field.label;
32+
}
33+
if (field.defaultValue !== undefined) {
34+
prop.default = field.defaultValue;
35+
}
3036
properties[field.name] = prop;
3137
if (field.required) {
3238
required.push(field.name);

packages/runtime/src/utils/schema-validation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ function validateField(
150150
const value = record[field.name];
151151

152152
if (value === null || value === undefined) {
153+
if (field.defaultValue !== undefined) {
154+
const coerced = coerceValue(field.defaultValue, field.type);
155+
if (coerced.ok) {
156+
return { value: coerced.value, error: null };
157+
}
158+
}
153159
if (field.required) {
154160
return { value: null, error: `Missing required field '${field.name}'` };
155161
}

packages/types/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export interface Field {
2626
type: FieldType;
2727
required?: boolean;
2828
primaryKey?: boolean;
29+
label?: string;
30+
defaultValue?: string;
31+
unique?: boolean;
2932
}
3033

3134
/**

0 commit comments

Comments
 (0)