ubiquitous-invention/apps/web/components/forms/form-field-config.tsx
Randall Stillwell 663bc77afe feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:

App
- New routes: ai, forms, planner, settings (templates/types), teams,
  doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
  builder/renderer/responses, types manager, objects creation dialog,
  card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
  health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in

Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
  (uses CT 102 shared services), removes host port mappings, adds
  Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
  metadata title flipped to ECHODO

Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
  credentials.*, *.key, *.crt, *.pem, ssh keys

Made-with: Cursor
2026-04-26 14:34:34 -05:00

407 lines
12 KiB
TypeScript

"use client";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { FormMappingPicker } from "./form-mapping-picker";
type FormField = {
id: string;
label: string;
type: string;
required: boolean;
placeholder?: string;
helpText?: string;
options?: { label: string; value: string }[];
defaultValue?: unknown;
mappedProperty: string | null;
validation?: {
min?: number;
max?: number;
pattern?: string;
maxLength?: number;
};
conditionals?: {
fieldId: string;
operator: string;
value: unknown;
action: string;
}[];
};
const OPERATORS = ["eq", "neq", "contains", "isEmpty"] as const;
const ACTIONS = ["show", "hide"] as const;
const CHOICE_TYPES = new Set([
"select",
"multi_select",
"radio",
]);
export function FormFieldConfig({
field,
allFields,
workspaceId,
onChange,
}: {
field: FormField;
allFields: FormField[];
workspaceId: string;
onChange: (patch: Partial<FormField>) => void;
}) {
const otherFields = allFields.filter((f) => f.id !== field.id);
const v = field.validation ?? {};
const conditionals = field.conditionals ?? [];
const updateOption = (
index: number,
patch: Partial<{ label: string; value: string }>,
) => {
const options = [...(field.options ?? [])];
const current = options[index] ?? { label: "", value: "" };
options[index] = { ...current, ...patch };
onChange({ options });
};
const addOption = () => {
const n = (field.options?.length ?? 0) + 1;
onChange({
options: [
...(field.options ?? []),
{ label: `Option ${n}`, value: `option_${n}` },
],
});
};
const removeOption = (index: number) => {
const options = [...(field.options ?? [])];
options.splice(index, 1);
onChange({ options: options.length ? options : undefined });
};
return (
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 shadow-sm">
<div>
<h3 className="text-sm font-semibold text-foreground">Field settings</h3>
<p className="text-xs text-muted-foreground">
{field.label} · {field.type.replaceAll("_", " ")}
</p>
</div>
<Separator />
<div className="space-y-2">
<label className="text-xs font-medium text-foreground" htmlFor="ff-label">
Label
</label>
<Input
id="ff-label"
value={field.label}
onChange={(e) => onChange({ label: e.target.value })}
/>
</div>
<div className="space-y-2">
<label
className="text-xs font-medium text-foreground"
htmlFor="ff-placeholder"
>
Placeholder
</label>
<Input
id="ff-placeholder"
value={field.placeholder ?? ""}
placeholder="Optional"
onChange={(e) =>
onChange({
placeholder: e.target.value || undefined,
})
}
/>
</div>
<div className="space-y-2">
<label
className="text-xs font-medium text-foreground"
htmlFor="ff-help"
>
Help text
</label>
<textarea
id="ff-help"
value={field.helpText ?? ""}
placeholder="Shown below the field"
rows={3}
onChange={(e) =>
onChange({
helpText: e.target.value || undefined,
})
}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => onChange({ required: e.target.checked })}
className="size-4 rounded border-input accent-primary"
/>
Required
</label>
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">Validation</span>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder="Min"
value={v.min ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
min:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
<Input
type="number"
placeholder="Max"
value={v.max ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
max:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
</div>
<Input
placeholder="Pattern (regex)"
value={v.pattern ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
pattern: e.target.value || undefined,
},
})
}
/>
<Input
type="number"
placeholder="Max length"
value={v.maxLength ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
maxLength:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
</div>
{CHOICE_TYPES.has(field.type) ? (
<>
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">Options</span>
<div className="flex flex-col gap-2">
{(field.options ?? []).map((opt, i) => (
<div key={i} className="flex gap-2">
<Input
placeholder="Label"
value={opt.label}
onChange={(e) => updateOption(i, { label: e.target.value })}
/>
<Input
placeholder="Value"
value={opt.value}
onChange={(e) => updateOption(i, { value: e.target.value })}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="shrink-0"
onClick={() => removeOption(i)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full gap-1"
onClick={addOption}
>
<Plus className="size-4" />
Add option
</Button>
</div>
</>
) : null}
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">
Map to task property
</span>
<FormMappingPicker
workspaceId={workspaceId}
value={field.mappedProperty}
onChange={(next) => onChange({ mappedProperty: next })}
/>
</div>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">
Conditional rules
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() =>
onChange({
conditionals: [
...conditionals,
{
fieldId: otherFields[0]?.id ?? "",
operator: "eq",
value: "",
action: "show",
},
],
})
}
>
<Plus className="size-3.5" />
Add rule
</Button>
</div>
{conditionals.length === 0 ? (
<p className="text-xs text-muted-foreground">
No rules. Show or hide this field based on another field&apos;s value.
</p>
) : (
<ul className="flex flex-col gap-3">
{conditionals.map((rule, index) => (
<li
key={index}
className="space-y-2 rounded-md border border-border p-2"
>
<div className="flex justify-end">
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={() => {
const next = conditionals.filter((_, i) => i !== index);
onChange({
conditionals: next.length ? next : undefined,
});
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.fieldId}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, fieldId: e.target.value };
onChange({ conditionals: next });
}}
>
<option value="">Select field</option>
{otherFields.map((f) => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</select>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.operator}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, operator: e.target.value };
onChange({ conditionals: next });
}}
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
<Input
placeholder="Value"
value={
rule.value === undefined || rule.value === null
? ""
: String(rule.value)
}
disabled={rule.operator === "isEmpty"}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, value: e.target.value };
onChange({ conditionals: next });
}}
/>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.action}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, action: e.target.value };
onChange({ conditionals: next });
}}
>
{ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</li>
))}
</ul>
)}
</div>
</div>
);
}