Form-share schema + editable shares — v3.31.43

Public form now renders the resource's actual fields; admin can edit label + password after creation.

8 minmedium

The previous lesson left two rough edges on form sharing: the public form at /forms/[token] rendered a hardcoded name/email/phone/message shape regardless of the resource, and the admin had no way to edit an existing share. Both shipped fixed in v3.31.43. This lesson covers what changed, why it matters, and how the dispatcher now exposes the resource's real field schema to the public page.

The mental model — two halves of the same problem

Public form sharing in v3.31.20 always rendered the same form because the web page had no idea what the resource's real fields were. Generate a share for Category (which has name + image) and visitors still saw Name + Email + Phone + Message inputs. Name happened to line up; everything else was effectively unusable.

The fix is structural: the dispatcher service that knows which resources are reachable publicly now also knows their field shape. The public-info endpoint returns that shape; the web page renders inputs from it.

Before v3.31.43 After v3.31.43
───────────────── ──────────────
GET /api/public/forms/<token> GET /api/public/forms/<token>
→ { resource_name, → { resource_name,
has_password, has_password,
label } label,
fields: [ <-- new
{ key, label,
type, required }
] }
/forms/<token> renders /forms/<token> renders
hardcoded name/email/ one input per fields[]
phone/message entry, picking the right
HTML shape per type

How field types are inferred

The dispatcher reflects on the Go model struct and walks every field whose json tag isn't empty or -. Framework columns (id, created_at, updated_at, deleted_at, version, slug) are skipped — they're framework-managed, not user-input. For every remaining field the helper maps the reflected Go type onto an HTML input shape:

apps/api/internal/services/form_share_dispatch.go
func publicTypeFor(fieldName string, t reflect.Type) string {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
typeName := t.String()
if strings.Contains(typeName, "FileRef") || strings.Contains(typeName, "FileRefs") {
return "file"
}
if strings.Contains(typeName, "time.Time") {
return "datetime"
}
switch t.Kind() {
case reflect.Bool:
return "checkbox"
case reflect.Int, reflect.Int8, reflect.Int16, ..., reflect.Float64:
return "number"
case reflect.String:
lower := strings.ToLower(fieldName)
switch {
case lower == "email" || strings.HasSuffix(lower, "_email"):
return "email"
case lower == "phone" || lower == "tel":
return "tel"
case lower == "description" || lower == "notes" ||
lower == "message" || lower == "body":
return "textarea"
}
return "text"
}
return "text"
}

Strings get a second pass on their name so a column called email gets type="email", notes renders a <textarea>, and so on. The heuristics are intentionally narrow — a one-line mapping per common shape — because a wrong guess is the kind of thing operators notice and report fast.

Required fields come from the binding:"required" struct tag, the same tag the resource's admin handler uses for validation. Marking a field required is a single source of truth across admin form, public form, and API binding.

File fields — why they render disabled

Anything whose Go type contains FileRef or FileRefs maps to type: "file" — but the public form intentionally doesn't render a real file input. Instead it shows an inline explainer:

apps/web/app/forms/[token]/page.tsx
if (field.type === "file") {
return (
<div className="space-y-1.5">
<label className={labelClass}>{field.label}</label>
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 px-3 py-3 text-xs text-slate-500">
File uploads aren&apos;t supported on public-share forms.
{field.required
? " The operator must collect this file through a different channel."
: " You can leave this blank."}
</div>
</div>
);
}

File uploads need the auth-gated /api/uploads endpoint plus a presigned URL flow — neither is available to anonymous visitors. Rendering a file input that secretly doesn't work would be a worse failure mode than not rendering one. The submit handler also strips file keys from the payload before POST, so the dispatcher's typed unmarshal never sees them.

Token-scoped presigned uploads are on the roadmap — a future release will let public visitors POST one or two files through a short-lived signed URL the share issues per submission. Until then, the workaround is to collect the file via a different channel (email, Drive link) and have the operator attach it from the admin afterwards.

Editing existing shares

The admin form-shares page (/system/form-shares) used to have Audit, Copy, Open, and Delete actions only. Want to add password protection to an existing link? Delete + recreate, then redistribute the new token to every recipient. v3.31.43 adds an Edit button that opens a small modal with three controls:

  • Label — free-text operator-facing tag.
  • Password mode — three pills: Keep current (no change), Set password (set or rotate the password), Remove password (clear the gate; disabled when the share has no password).
  • New password — text input shown only when mode = "Set".

The backend handler at PATCH /api/admin/form-shares/:id already supported the full payload — passing password: "-" as the sentinel means "remove the existing hash." This release adds the missing UI.

Backward compatibility — pre-v3.31.43 projects

Two things changed in the dispatcher: a new PublicFields(resourceName) function with its own // grit:form-share:fields marker, and two new imports (reflect, strings) at the top of the file. Older projects scaffolded before v3.31.43 don't have any of these.

The generator handles both cases lazily:

  • If the marker is missing, grit generate resource Foo prints a one-line warning instead of failing. The new dispatch case still lands; the public form just falls back to no-fields.
  • The import-checker scans the file for reflect and strings and adds them lazily the first time they're needed, so the dispatcher compiles cleanly even when the marker patch happens by hand.

Want existing shares to render the right form right away? Either re-scaffold the project (then copy the rest of your custom code back over), or hand-edit services/form_share_dispatch.go to add the PublicFields function and the matching case for each resource. The full template is in the framework repo at internal/scaffold/api_form_share_files.go.

Quick check

You upgraded to v3.31.43 and regenerated your Category resource. The public form for Category still shows the old name/email/phone/message shape. What's the most likely cause?

Try it

In the project from the previous exercise, exercise the v3.31.43 changes end-to-end:

  1. Generate a second resource: grit generate resource Category --fields=name:string!,image:file. Migrate.
  2. In the admin, open /system/form-shares and create a share for Category. Copy the URL.
  3. Open the URL in an incognito tab. Confirm you see aName input + the dashed "File uploads aren't supported" explainer for image — not the legacy contact form.
  4. Back in the admin, click Edit on the new share. Set a password, save. Refresh the public URL — you should now see a password input above Name.
  5. Submit the form without a password → 401. Submit with the password → 201. Edit the share again, Remove password, save. Submit clean → 201.

Paste the JSON response from GET /api/public/forms/<token> into notes.md so you can see the fields[] shape directly.

What's next

The form-share story is now end-to-end: typed dispatch, real field schema on the public side, and an Edit modal on the admin side. Next chapter shifts to the opposite question — what happens on the dashboard, where each resource now gets auto-generated preset widgets. The lesson on per-resource dashboard widgets in the web course covers that.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub