oficina / fichas Public

Type designer, card layouts, and the review's fixes to search

Paul, 2026-09-29, approving the mockups: set up types and fields from the
database view, and arrange what a card shows.

The designer. A fields page per type, adding a field (Text, Long text,
Number, Date, Choice), renaming one or editing a Choice's options, a new
type, and a card-layout editor with a preview on the most recent card.
Types and fields are rows with opaque generated keys and sealed labels,
never DDL (§4.3), and every change writes an event, so the in-memory index
rebuilds from the same change feed as a card edit.

What it refuses, and says why beside the field: plaintext (reserved for
structural columns), a findable Choice (a short list of options would give
the values away), findable Long text (it would find nothing), and "link to
another card", which needs a picker first. Kind and findability are fixed
once a field exists — both describe how the values are stored, and changing
either is a re-encryption of every one; Seed refuses the same for shipped
fields. Values are now checked by kind before anything is written: a bad
number, date or option comes back as a 422 with a message beside the field
and everything typed kept, where it used to be a 500. Numbers are stored
bare, so "1,200" sorts as a number.

Card routes are no longer Customer-only: /t/{type} lists any type, the
sidebar lists every card type, and the new/edit/show pages render fields
by kind through one shared partial. The partial file is not named with a
leading underscore — go:embed skips such files silently, and the first
version was never in the binary.

Review of 94e6c6e (Fable 5.1): ACCEPT WITH CHANGES. Adopted in full:

1. After a live search, the view tabs and filter menus kept the search the
   page LOADED with, so picking a filter threw the typed one away. live.js
   now swaps every [data-live-region] by id — toolbar, filters, results —
   and never the search box. TestLiveSearch asserts the Stage and Cards
   links carry the typed search.
2. An expired session made the search box go silently dead: fetch followed
   the redirect to sign-in and found no region. A redirect, error or
   non-list page now navigates. TestAnExpiredSessionIsNotADeadEnd.
   Both assertions were run against the old live.js and failed.
3. The cold-load number was measured on cards with no calls, so the slow
   path never ran: latestEntries was four queries per card. It is one
   batched load now, a burst of more than 200 changed cards rebuilds
   instead of patching, and the benchmark seeds two calls per card. Real
   numbers at 5,000 customers with 10,000 calls: cold load 225ms once per
   wake, a search with a filter and a sort 0.37ms. TestLatestEntriesIsBatched
   fails if it stops being batched. (The seal Open benchmark reads 100ns on
   this machine and 288ns on the reviewer's; same order either way.)
4. Seed wrote no events, so the index's rebuild-on-schema-change path could
   never run. It logs them now, as the app acting as itself.
5. Search terms are deduplicated and capped at 16.
6. aria-live moved from the whole results region to the count line.

Also fixed: "1 customers".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9179761a1205758a03c3302b3a1ce792a204b863 parent 94e6c6e
28 files changed, +1720 −138
  • internal/fichas/app.go +10 −0
  • internal/fichas/catalogue.go +13 −1
  • internal/fichas/handlers.go +125 −40
  • internal/fichas/handlers_schema.go +253 −0
  • internal/fichas/index.go +85 −1
  • internal/fichas/index_test.go +82 −1
  • internal/fichas/list.go +3 −0
  • internal/fichas/read.go +95 −22
  • internal/fichas/render.go +21 −1
  • internal/fichas/schema.go +441 −0
  • internal/fichas/schema_test.go +185 −0
  • internal/fichas/static/fichas.css +13 −0
  • internal/fichas/static/live.js +48 −21
  • internal/fichas/store.go +25 −0
  • internal/fichas/templates/card_delete.html +2 −2
  • internal/fichas/templates/card_edit.html +2 −12
  • internal/fichas/templates/card_layout.html +34 −0
  • internal/fichas/templates/card_new.html +5 −16
  • internal/fichas/templates/card_show.html +4 −4
  • internal/fichas/templates/field_edit.html +18 −0
  • internal/fichas/templates/field_new.html +23 −0
  • internal/fichas/templates/fields.html +27 −0
  • internal/fichas/templates/index.html +23 −15
  • internal/fichas/templates/partials.html +17 −0
  • internal/fichas/templates/shell.html +2 −1
  • internal/fichas/templates/type_new.html +12 −0
  • internal/fichastest/browser_test.go +35 −1
  • internal/fichastest/designer_test.go +117 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/fichas/cmd/fichas	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas	0.669s
?   	amadan.net/oficina/fichas/internal/fichas/icons	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas/seal	(cached)
ok  	amadan.net/oficina/fichas/internal/fichastest	0.328s
?   	amadan.net/oficina/fichas/scripts/mockups	[no test files]
?   	amadan.net/oficina/fichas/scripts/preview	[no test files]
?   	amadan.net/oficina/fichas/scripts/writeestilo	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/fichas/app.go b/internal/fichas/app.go
index 40f24eb..ef07972 100644
--- a/internal/fichas/app.go
+++ b/internal/fichas/app.go
@@ -79,6 +79,16 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, *app, error
r.Group(func(r chi.Router) {
r.Use(a.requireSession)
r.Get("/", a.index)
+ r.Get("/t/{type}", a.index)
+ r.Get("/t/{type}/fields", a.fieldsPage)
+ r.Get("/t/{type}/fields/new", a.newField)
+ r.Post("/t/{type}/fields", a.createField)
+ r.Get("/t/{type}/layout", a.layoutPage)
+ r.Post("/t/{type}/layout", a.saveLayout)
+ r.Get("/fields/{key}/edit", a.editField)
+ r.Post("/fields/{key}", a.updateField)
+ r.Get("/types/new", a.newType)
+ r.Post("/types", a.createType)
r.Get("/cards/new", a.newCard)
r.Post("/cards", a.createCard)
r.Get("/cards/{id}", a.showCard)
diff --git a/internal/fichas/catalogue.go b/internal/fichas/catalogue.go
index fda15d1..22acfad 100644
--- a/internal/fichas/catalogue.go
+++ b/internal/fichas/catalogue.go
@@ -200,6 +200,14 @@ func seedType_(tx *gorm.DB, s *seal.Sealer, t seedType) (CardType, error) {
if err := tx.Create(&ct).Error; err != nil {
return ct, fmt.Errorf("seed: creating %s: %w", t.Key, err)
}
+ // Logged like any schema change. The first version wrote none, which
+ // made the index's "a type or field changed: rebuild" path unreachable
+ // and its claim to follow a complete change feed untrue for the
+ // catalogue — safe only because Seed happens to run before the index
+ // loads.
+ if err := logEvent(tx, s, EventCreated, ObjectType, ct.ID, 0, 0, seedActor, nil, nil); err != nil {
+ return ct, err
+ }
return ct, nil
}
@@ -258,9 +266,13 @@ func seedField_(tx *gorm.DB, s *seal.Sealer, cardTypeID int64, ordinal int, f se
if err := tx.Create(&fd).Error; err != nil {
return fmt.Errorf("seed: creating %s: %w", f.Key, err)
}
- return nil
+ return logEvent(tx, s, EventCreated, ObjectField, fd.ID, 0, 0, seedActor, nil, nil)
}
+// seedActor is the app writing its own catalogue — ruling 9's app-as-itself,
+// with its reason, never attributed to whoever's boot triggered it.
+var seedActor = AppBecause(0, "catalogue seed")
+
// openOptions unseals a KindEnum's permitted values.
func openOptions(s *seal.Sealer, sealed []byte) ([]string, error) {
raw, err := s.Open(seal.PurposeLabel, sealed)
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go
index ae6257b..cc4f2f2 100644
--- a/internal/fichas/handlers.go
+++ b/internal/fichas/handlers.go
@@ -121,7 +121,13 @@ type NavItem struct {
// page renders a shell page with what every shell page needs: the card
// types in the sidebar, and who is signed in.
func (a *app) page(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
- nav, err := a.nav(r)
+ a.pageStatus(w, r, http.StatusOK, name, data)
+}
+
+// pageStatus is page with a status — 422 for a form sent back with errors.
+func (a *app) pageStatus(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) {
+ current, _ := data["TypeKey"].(string)
+ nav, err := a.nav(r, current)
if err != nil {
a.fail(w, r, err)
return
@@ -129,29 +135,53 @@ func (a *app) page(w http.ResponseWriter, r *http.Request, name string, data map
data["Nav"] = nav
me, _ := a.identity(r)
data["Me"] = me
+ if status != http.StatusOK {
+ w.WriteHeader(status)
+ }
render(w, name, data)
}
-// nav lists the card types. Scoped to cards: an entry type (Call log) is
-// something you add to a card, not somewhere you go.
-func (a *app) nav(r *http.Request) ([]NavItem, error) {
- var types []CardType
- if err := a.db.G.WithContext(r.Context()).Where("scope = ?", ScopeCard).Order("id").Find(&types).Error; err != nil {
+// nav lists the card types, from the index rather than the database: the
+// labels are sealed and the index has them open already. Scoped to cards —
+// an entry type (Call log) is something you add to a card, not somewhere
+// you go.
+func (a *app) nav(r *http.Request, current string) ([]NavItem, error) {
+ if err := a.ix.Sync(r.Context()); err != nil {
return nil, err
}
- out := make([]NavItem, 0, len(types))
- for _, ct := range types {
- label, err := openLabel(a.sealer, ct.SealedLabel)
- if err != nil {
- return nil, err
- }
- // Slice 1 has one card type and it lives at /. More types get
- // their own paths when the type designer lands.
- out = append(out, NavItem{Label: pluralLabel(label), Href: "/", Current: ct.Key == TypeCustomer})
+ var out []NavItem
+ for _, t := range a.ix.Types() {
+ out = append(out, NavItem{Label: t.Plural, Href: typeHref(t.Key), Current: t.Key == current})
}
return out, nil
}
+// typeHref is a card type's list page. Keys are opaque, so a URL never
+// carries a type's name.
+func typeHref(key string) string { return "/t/" + key }
+
+// currentType is the type a request is about: the {type} in the path, a
+// ?type= on a form, or the first type.
+func (a *app) currentType(r *http.Request) (TypeInfo, error) {
+ if err := a.ix.Sync(r.Context()); err != nil {
+ return TypeInfo{}, err
+ }
+ want := chi.URLParam(r, "type")
+ if want == "" {
+ want = r.FormValue("type")
+ }
+ types := a.ix.Types()
+ for _, t := range types {
+ if t.Key == want || (want == "" && t.Key == TypeCustomer) {
+ return t, nil
+ }
+ }
+ if want == "" && len(types) > 0 {
+ return types[0], nil
+ }
+ return TypeInfo{}, gorm.ErrRecordNotFound
+}
+
// pluralLabel names a collection: a type is "Customer", its list is
// "Customers". Deliberately naive; a type designer gets its own field for
// this rather than growing English rules here.
@@ -162,37 +192,41 @@ func pluralLabel(s string) string {
return s + "s"
}
-// index is the record list, as a table or as cards — the view switch Paul
-// asked for — searched, filtered, sorted and paged from the in-memory index.
-// All of it lives in the URL (list.go), so every state is a link and the
-// page works with scripts off; live.js only makes the search box update the
-// results as you type.
+// index is a card type's list, as a table or as cards, searched, filtered,
+// sorted and paged from the in-memory index. All of it lives in the URL
+// (list.go), so every state is a link and the page works with scripts off;
+// live.js only makes the search box update the results as you type.
func (a *app) index(w http.ResponseWriter, r *http.Request) {
- if err := a.ix.Sync(r.Context()); err != nil {
- a.fail(w, r, err)
+ ti, err := a.currentType(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
return
}
- fields, err := a.ix.Fields(TypeCustomer)
+ fields, err := a.ix.Fields(ti.Key)
if err != nil {
a.fail(w, r, err)
return
}
- l := parseList(r, "/", fields)
- res, err := a.ix.Query(TypeCustomer, l.query())
+ base := typeHref(ti.Key)
+ l := parseList(r, base, fields)
+ res, err := a.ix.Query(ti.Key, l.query())
if err != nil {
a.fail(w, r, err)
return
}
filtered := l.Q != "" || len(l.Filters) > 0
- clear := l.with(func(s *listState) { s.Q = "" })
+ reset := listState{base: base, View: l.View, Filters: map[string]string{}}
a.page(w, r, "index", map[string]any{
+ "TypeKey": ti.Key,
"Result": res,
"Cards": res.Cards,
"Fields": fields,
+ "Face": a.ix.Face(ti.Key),
"View": l.View,
"Q": l.Q,
+ "Base": base,
"Hidden": l.hidden(),
- "ClearHref": clear.href(),
+ "ClearHref": l.with(func(s *listState) { s.Q = "" }).href(),
"Filters": l.filterMenus(fields),
"Columns": l.columns(fields),
"Pages": l.pages(res),
@@ -200,19 +234,22 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
"Filtered": filtered,
"TableHref": l.with(func(s *listState) { s.View = "table" }).href(),
"CardsHref": l.with(func(s *listState) { s.View = "cards" }).href(),
- "ResetHref": "/" + map[bool]string{true: "?view=cards", false: ""}[l.View == "cards"],
+ "ResetHref": reset.href(),
})
}
func (a *app) newCard(w http.ResponseWriter, r *http.Request) {
- fields, err := CardFields(r.Context(), a.db.G, a.sealer, TypeCustomer)
+ ti, err := a.currentType(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ fields, err := CardFields(r.Context(), a.db.G, a.sealer, ti.Key)
if err != nil {
a.fail(w, r, err)
return
}
- a.page(w, r, "card_new", map[string]any{
- "Fields": fields,
- })
+ a.page(w, r, "card_new", map[string]any{"TypeKey": ti.Key, "Type": ti, "Fields": fields})
}
func (a *app) createCard(w http.ResponseWriter, r *http.Request) {
@@ -221,12 +258,25 @@ func (a *app) createCard(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, err)
return
}
- fields, err := CardFields(r.Context(), a.db.G, a.sealer, TypeCustomer)
+ ti, err := a.currentType(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ fields, err := CardFields(r.Context(), a.db.G, a.sealer, ti.Key)
if err != nil {
a.fail(w, r, err)
return
}
- card, err := CreateCard(r.Context(), a.db.G, a.sealer, TypeCustomer, actor, formValues(r, fields))
+ values := formValues(r, fields)
+ card, err := CreateCard(r.Context(), a.db.G, a.sealer, ti.Key, actor, values)
+ if fe, ok := IsFormError(err); ok {
+ // Sent back with what was typed and a message beside each field
+ // that needs it, never a 500 and never an empty form.
+ a.pageStatus(w, r, http.StatusUnprocessableEntity, "card_new", map[string]any{
+ "TypeKey": ti.Key, "Type": ti, "Fields": withValues(fields, values), "Errors": fe})
+ return
+ }
if err != nil {
a.fail(w, r, err)
return
@@ -234,6 +284,19 @@ func (a *app) createCard(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/cards/"+strconv.FormatInt(card.ID, 10), http.StatusSeeOther)
}
+// withValues fills blank field views with what a form submitted, so a form
+// sent back with errors keeps everything the person typed.
+func withValues(fields []FieldView, values map[string]string) []FieldView {
+ out := make([]FieldView, len(fields))
+ copy(out, fields)
+ for i := range out {
+ if v, ok := values[out[i].Def.Key]; ok {
+ out[i].Value = v
+ }
+ }
+ return out
+}
+
func (a *app) showCard(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
@@ -250,6 +313,7 @@ func (a *app) showCard(w http.ResponseWriter, r *http.Request) {
return
}
a.page(w, r, "card_show", map[string]any{
+ "TypeKey": card.TypeKey,
"Card": card,
"EntryFields": entryFields,
})
@@ -267,7 +331,7 @@ func (a *app) editCard(w http.ResponseWriter, r *http.Request) {
a.notFoundOrFail(w, r, err)
return
}
- a.page(w, r, "card_edit", map[string]any{"Card": card})
+ a.page(w, r, "card_edit", map[string]any{"TypeKey": card.TypeKey, "Card": card})
}
func (a *app) updateCard(w http.ResponseWriter, r *http.Request) {
@@ -285,9 +349,15 @@ func (a *app) updateCard(w http.ResponseWriter, r *http.Request) {
a.notFoundOrFail(w, r, err)
return
}
- defs := make([]FieldView, len(card.Fields))
- copy(defs, card.Fields)
- if err := UpdateCard(r.Context(), a.db.G, a.sealer, id, actor, formValues(r, defs)); err != nil {
+ values := formValues(r, card.Fields)
+ err = UpdateCard(r.Context(), a.db.G, a.sealer, id, actor, values)
+ if fe, ok := IsFormError(err); ok {
+ card.Fields = withValues(card.Fields, values)
+ a.pageStatus(w, r, http.StatusUnprocessableEntity, "card_edit", map[string]any{
+ "TypeKey": card.TypeKey, "Card": card, "Errors": fe})
+ return
+ }
+ if err != nil {
a.fail(w, r, err)
return
}
@@ -309,7 +379,19 @@ func (a *app) addEntry(w http.ResponseWriter, r *http.Request) {
a.fail(w, r, err)
return
}
- if _, err := AddEntry(r.Context(), a.db.G, a.sealer, id, actor, TypeCallLog, "manual", formValues(r, fields)); err != nil {
+ values := formValues(r, fields)
+ _, err = AddEntry(r.Context(), a.db.G, a.sealer, id, actor, TypeCallLog, "manual", values)
+ if fe, ok := IsFormError(err); ok {
+ card, lerr := LoadCard(r.Context(), a.db.G, a.sealer, id)
+ if lerr != nil {
+ a.notFoundOrFail(w, r, lerr)
+ return
+ }
+ a.pageStatus(w, r, http.StatusUnprocessableEntity, "card_show", map[string]any{
+ "TypeKey": card.TypeKey, "Card": card, "EntryFields": withValues(fields, values), "Errors": fe})
+ return
+ }
+ if err != nil {
a.notFoundOrFail(w, r, err)
return
}
@@ -329,7 +411,7 @@ func (a *app) confirmDelete(w http.ResponseWriter, r *http.Request) {
a.notFoundOrFail(w, r, err)
return
}
- a.page(w, r, "card_delete", map[string]any{"Card": card})
+ a.page(w, r, "card_delete", map[string]any{"TypeKey": card.TypeKey, "Card": card})
}
func (a *app) deleteCard(w http.ResponseWriter, r *http.Request) {
@@ -415,3 +497,6 @@ func signinError(code string) string {
return "Something went wrong on our side, not yours. Trying again won't help until it is fixed."
}
}
+
+// errNotFound is a 404 for things that are not gorm rows.
+var errNotFound = gorm.ErrRecordNotFound
diff --git a/internal/fichas/handlers_schema.go b/internal/fichas/handlers_schema.go
new file mode 100644
index 0000000..75cb90e
--- /dev/null
+++ b/internal/fichas/handlers_schema.go
@@ -0,0 +1,253 @@
+package fichas
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// The type designer's pages. Every one is a form that POSTs and works with
+// scripts off; a FormError comes back to the same form with what was typed
+// and a message beside the field that needs it (422), never a 500.
+
+// kindWords names a kind for a person.
+var kindWords = map[string]string{
+ KindText: "Text", KindLongText: "Long text", KindNumber: "Number",
+ KindDate: "Date", KindEnum: "Choice", KindRef: "Link",
+}
+
+// FieldRow is one line of a type's fields page.
+type FieldRow struct {
+ Key, Label, Kind string
+ Findable, Title bool
+ Options []string
+}
+
+func (a *app) fieldsPage(w http.ResponseWriter, r *http.Request) {
+ ti, err := a.currentType(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ fields, err := a.ix.Fields(ti.Key)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ var rows []FieldRow
+ for i, f := range fields {
+ kind := kindWords[f.Def.Kind]
+ if isLong(f.Def) {
+ kind = kindWords[KindLongText]
+ }
+ rows = append(rows, FieldRow{Key: f.Def.Key, Label: f.Label, Kind: kind,
+ Findable: f.Def.Tier == TierPrint, Title: f.Def.Key == FieldName || (i == 0 && !hasName(fields)),
+ Options: f.Options})
+ }
+ a.page(w, r, "fields", map[string]any{"TypeKey": ti.Key, "Type": ti, "Rows": rows})
+}
+
+func hasName(fields []FieldView) bool {
+ for _, f := range fields {
+ if f.Def.Key == FieldName {
+ return true
+ }
+ }
+ return false
+}
+
+func (a *app) newField(w http.ResponseWriter, r *http.Request) {
+ ti, err := a.currentType(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ a.page(w, r, "field_new", map[string]any{"TypeKey": ti.Key, "Type": ti,
+ "Form": map[string]string{"kind": KindText, "tier": string(TierSealed)}})
+}
+
+func (a *app) createField(w http.ResponseWriter, r *http.Request) {
+ actor, err := a.actor(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ ti, err := a.currentType(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ _ = r.ParseForm()
+ spec := FieldSpec{
+ Label: r.PostForm.Get("label"),
+ Kind: r.PostForm.Get("kind"),
+ Tier: Tier(r.PostForm.Get("tier")),
+ Options: strings.Split(r.PostForm.Get("options"), "\n"),
+ }
+ _, err = AddField(r.Context(), a.db.G, a.sealer, ti.Key, actor, spec)
+ if fe, ok := IsFormError(err); ok {
+ a.pageStatus(w, r, http.StatusUnprocessableEntity, "field_new", map[string]any{
+ "TypeKey": ti.Key, "Type": ti, "Errors": fe,
+ "Form": map[string]string{"label": spec.Label, "kind": spec.Kind, "tier": string(spec.Tier), "options": r.PostForm.Get("options")}})
+ return
+ }
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, typeHref(ti.Key)+"/fields", http.StatusSeeOther)
+}
+
+// fieldForEdit finds a field and the type it belongs to, from the index.
+func (a *app) fieldForEdit(r *http.Request) (TypeInfo, FieldView, error) {
+ if err := a.ix.Sync(r.Context()); err != nil {
+ return TypeInfo{}, FieldView{}, err
+ }
+ key := chi.URLParam(r, "key")
+ for _, t := range a.ix.Types() {
+ fields, err := a.ix.Fields(t.Key)
+ if err != nil {
+ return TypeInfo{}, FieldView{}, err
+ }
+ for _, f := range fields {
+ if f.Def.Key == key {
+ return t, f, nil
+ }
+ }
+ }
+ return TypeInfo{}, FieldView{}, errNotFound
+}
+
+func (a *app) editField(w http.ResponseWriter, r *http.Request) {
+ ti, f, err := a.fieldForEdit(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ a.page(w, r, "field_edit", map[string]any{"TypeKey": ti.Key, "Type": ti, "Field": f,
+ "KindWords": kindWords[f.Def.Kind], "Label": f.Label, "Options": strings.Join(f.Options, "\n")})
+}
+
+func (a *app) updateField(w http.ResponseWriter, r *http.Request) {
+ actor, err := a.actor(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ ti, f, err := a.fieldForEdit(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ _ = r.ParseForm()
+ label, options := r.PostForm.Get("label"), r.PostForm.Get("options")
+ err = UpdateField(r.Context(), a.db.G, a.sealer, f.Def.Key, actor, label, strings.Split(options, "\n"))
+ if fe, ok := IsFormError(err); ok {
+ a.pageStatus(w, r, http.StatusUnprocessableEntity, "field_edit", map[string]any{"TypeKey": ti.Key, "Type": ti,
+ "Field": f, "KindWords": kindWords[f.Def.Kind], "Label": label, "Options": options, "Errors": fe})
+ return
+ }
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, typeHref(ti.Key)+"/fields", http.StatusSeeOther)
+}
+
+func (a *app) newType(w http.ResponseWriter, r *http.Request) {
+ a.page(w, r, "type_new", map[string]any{"Form": map[string]string{}})
+}
+
+func (a *app) createType(w http.ResponseWriter, r *http.Request) {
+ actor, err := a.actor(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ _ = r.ParseForm()
+ singular, plural := r.PostForm.Get("label"), r.PostForm.Get("plural")
+ ct, err := CreateType(r.Context(), a.db.G, a.sealer, actor, singular, plural)
+ if fe, ok := IsFormError(err); ok {
+ a.pageStatus(w, r, http.StatusUnprocessableEntity, "type_new", map[string]any{
+ "Errors": fe, "Form": map[string]string{"label": singular, "plural": plural}})
+ return
+ }
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ // Straight to its fields: a new type has only a name, and adding what
+ // it needs is the next thing anyone does.
+ http.Redirect(w, r, typeHref(ct.Key)+"/fields", http.StatusSeeOther)
+}
+
+// FaceOption is one switch on the card-layout page.
+type FaceOption struct {
+ Key, Label string
+ On, Locked bool
+}
+
+func (a *app) layoutPage(w http.ResponseWriter, r *http.Request) {
+ ti, err := a.currentType(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ a.renderLayout(w, r, ti, a.ix.Face(ti.Key), nil)
+}
+
+func (a *app) renderLayout(w http.ResponseWriter, r *http.Request, ti TypeInfo, face []string, fe FormError) {
+ fields, err := a.ix.Fields(ti.Key)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ on := map[string]bool{}
+ for _, k := range face {
+ on[k] = true
+ }
+ var opts []FaceOption
+ for i, f := range fields {
+ title := f.Def.Key == FieldName || (i == 0 && !hasName(fields))
+ opts = append(opts, FaceOption{Key: f.Def.Key, Label: f.Label, On: title || on[f.Def.Key], Locked: title})
+ }
+ opts = append(opts, FaceOption{Key: FaceLatestCall, Label: "Latest call", On: on[FaceLatestCall]})
+
+ // A preview card: the most recently touched one, drawn with this face.
+ var preview *CardView
+ if res, err := a.ix.Query(ti.Key, Query{PerPage: 1}); err == nil && len(res.Cards) == 1 {
+ preview = &res.Cards[0]
+ }
+ status := http.StatusOK
+ if fe != nil {
+ status = http.StatusUnprocessableEntity
+ }
+ a.pageStatus(w, r, status, "card_layout", map[string]any{"TypeKey": ti.Key, "Type": ti,
+ "Options": opts, "Face": face, "Preview": preview, "Errors": fe})
+}
+
+func (a *app) saveLayout(w http.ResponseWriter, r *http.Request) {
+ actor, err := a.actor(r)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ ti, err := a.currentType(r)
+ if err != nil {
+ a.notFoundOrFail(w, r, err)
+ return
+ }
+ _ = r.ParseForm()
+ keys := r.PostForm["face"]
+ err = SetFace(r.Context(), a.db.G, a.sealer, ti.Key, actor, keys)
+ if fe, ok := IsFormError(err); ok {
+ a.renderLayout(w, r, ti, keys, fe)
+ return
+ }
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, typeHref(ti.Key)+"?view=cards", http.StatusSeeOther)
+}
diff --git a/internal/fichas/index.go b/internal/fichas/index.go
index fbe9590..f485571 100644
--- a/internal/fichas/index.go
+++ b/internal/fichas/index.go
@@ -2,6 +2,7 @@ package fichas
import (
"context"
+ "encoding/json"
"fmt"
"sort"
"strconv"
@@ -123,6 +124,16 @@ func (ix *Index) Sync(ctx context.Context) error {
return nil
}
}
+ // Past a burst of this size, one batched full load is cheaper than
+ // reloading card by card (each reload is several queries); an import of
+ // calls from Calls would arrive as exactly such a burst.
+ if len(cards) > reloadBurst {
+ if err := ix.loadAll(g); err != nil {
+ return err
+ }
+ ix.seen = latest
+ return nil
+ }
for id := range cards {
if err := ix.reloadCard(g, id); err != nil {
return err
@@ -132,6 +143,9 @@ func (ix *Index) Sync(ctx context.Context) error {
return nil
}
+// reloadBurst is where Sync stops patching and rebuilds.
+const reloadBurst = 200
+
func (ix *Index) loadAll(g *gorm.DB) error {
var types []CardType
if err := g.Where("scope = ?", ScopeCard).Order("id").Find(&types).Error; err != nil {
@@ -319,7 +333,7 @@ func (ix *Index) Query(typeKey string, q Query) (Result, error) {
return Result{}, gorm.ErrRecordNotFound
}
- terms := strings.Fields(strings.ToLower(q.Text))
+ terms := searchTerms(q.Text)
choice := map[string]bool{}
for _, d := range ti.defs {
if d.Kind == KindEnum {
@@ -479,3 +493,73 @@ func (ix *Index) Fields(typeKey string) ([]FieldView, error) {
}
return nil, gorm.ErrRecordNotFound
}
+
+// TypeInfo is a card type's names, for the sidebar and page titles.
+type TypeInfo struct {
+ Key, Label, Plural string
+}
+
+// Types lists the card types in creation order. Call Sync first.
+func (ix *Index) Types() []TypeInfo {
+ ix.mu.RLock()
+ defer ix.mu.RUnlock()
+ out := make([]TypeInfo, 0, len(ix.types))
+ for _, t := range ix.types {
+ out = append(out, TypeInfo{Key: t.ct.Key, Label: t.label, Plural: t.plural})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].Key == TypeCustomer || (out[j].Key != TypeCustomer && out[i].Key < out[j].Key)
+ })
+ return out
+}
+
+// Face is a type's card face: the field keys the Cards view shows, in order.
+// Call Sync first.
+func (ix *Index) Face(typeKey string) []string {
+ ix.mu.RLock()
+ defer ix.mu.RUnlock()
+ for _, t := range ix.types {
+ if t.ct.Key == typeKey {
+ return faceFor(t.ct, t.defs)
+ }
+ }
+ return nil
+}
+
+// faceFor is a type's saved face, or its default: for the seeded Customer,
+// the face the mockups settled on; for any other type, the title and the
+// latest call.
+func faceFor(ct CardType, defs []FieldDef) []string {
+ if ct.FaceKeys != "" {
+ var keys []string
+ if err := json.Unmarshal([]byte(ct.FaceKeys), &keys); err == nil {
+ return keys
+ }
+ }
+ if ct.Key == TypeCustomer {
+ return []string{FieldStage, FieldEmail, FaceLatestCall, FieldNextAction}
+ }
+ return []string{FaceLatestCall}
+}
+
+// maxTerms bounds a search. Matching is cards × terms, so an unbounded query
+// — 20,000 words took 357ms over 2,000 cards — holds the read lock against
+// every Sync. Nobody types seventeen words into a search box.
+const maxTerms = 16
+
+// searchTerms lowercases, splits, drops duplicates, and caps.
+func searchTerms(q string) []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, t := range strings.Fields(strings.ToLower(q)) {
+ if seen[t] {
+ continue
+ }
+ seen[t] = true
+ out = append(out, t)
+ if len(out) == maxTerms {
+ break
+ }
+ }
+ return out
+}
diff --git a/internal/fichas/index_test.go b/internal/fichas/index_test.go
index 4a8d816..9d1475f 100644
--- a/internal/fichas/index_test.go
+++ b/internal/fichas/index_test.go
@@ -2,6 +2,9 @@ package fichas
import (
"fmt"
+ "strings"
+
+ "gorm.io/gorm"
"testing"
"time"
)
@@ -155,13 +158,20 @@ func BenchmarkSearch(b *testing.B) {
b.StopTimer()
g, s := seeded(b)
stages := []string{"Lead", "Contacted", "Proposal", "Won", "Lost"}
+ // Every card gets two calls. The first version of this benchmark seeded
+ // none, so the cold load it reported never ran the latest-call path —
+ // which was the slow one.
for i := 0; i < 5000; i++ {
- CreateCard(ctx(), g, s, TypeCustomer, Person(1), map[string]string{
+ c, _ := CreateCard(ctx(), g, s, TypeCustomer, Person(1), map[string]string{
FieldName: fmt.Sprintf("Company %04d", i),
FieldEmail: fmt.Sprintf("hello%04d@example.com", i),
FieldStage: stages[i%5],
FieldNotes: "Renewal is tied to their grant cycle; the budget holder signs off in March.",
})
+ for k := 0; k < 2; k++ {
+ AddEntry(ctx(), g, s, c.ID, Person(1), TypeCallLog, "manual",
+ map[string]string{FieldBody: fmt.Sprintf("Call %d about the pilot scope.", k)})
+ }
}
ix := NewIndex(g, s)
start := time.Now()
@@ -180,3 +190,74 @@ func BenchmarkSearch(b *testing.B) {
}
}
}
+
+func TestSearchTermsAreBounded(t *testing.T) {
+ long := strings.Repeat("a b c ", 10000)
+ if n := len(searchTerms(long)); n != 3 {
+ t.Fatalf("duplicate terms were not collapsed: %d", n)
+ }
+ var many []string
+ for i := 0; i < 100; i++ {
+ many = append(many, fmt.Sprintf("t%d", i))
+ }
+ if n := len(searchTerms(strings.Join(many, " "))); n != maxTerms {
+ t.Fatalf("%d terms kept, want %d", n, maxTerms)
+ }
+}
+
+// The latest-call load is batched: its query count must not grow with the
+// number of cards.
+func TestLatestEntriesIsBatched(t *testing.T) {
+ g, s := seeded(t)
+ var ids []int64
+ for i := 0; i < 40; i++ {
+ c, _ := CreateCard(ctx(), g, s, TypeCustomer, Person(1), map[string]string{FieldName: fmt.Sprintf("C%d", i)})
+ AddEntry(ctx(), g, s, c.ID, Person(1), TypeCallLog, "manual", map[string]string{FieldBody: "hello"})
+ ids = append(ids, c.ID)
+ }
+ var queries int
+ cb := "count_queries"
+ g.Callback().Query().After("gorm:query").Register(cb, func(*gorm.DB) { queries++ })
+ defer g.Callback().Query().Remove(cb)
+ got, err := latestEntries(g, s, ids)
+ if err != nil || len(got) != 40 {
+ t.Fatalf("latestEntries: %v, %d found", err, len(got))
+ }
+ if queries > 6 {
+ t.Fatalf("latestEntries ran %d queries for 40 cards; it is not batched", queries)
+ }
+}
+
+// Seeding writes events, so a field the catalogue adds after an index has
+// loaded reaches it — the rebuild branch the first version could never take.
+func TestSeedingIsInTheChangeFeed(t *testing.T) {
+ g, s := seeded(t)
+ var n int64
+ g.Model(&Event{}).Where("object_kind IN ?", []string{ObjectType, ObjectField}).Count(&n)
+ want := int64(len(catalogue))
+ for _, c := range catalogue {
+ want += int64(len(c.Fields))
+ }
+ if n != want {
+ t.Fatalf("seeding logged %d schema events, want %d", n, want)
+ }
+
+ ix := NewIndex(g, s)
+ mustQuery(t, ix, Query{})
+ restore := catalogue
+ catalogue = append([]seedType(nil), catalogue...)
+ catalogue[0].Fields = append(append([]seedField(nil), catalogue[0].Fields...),
+ seedField{Key: "f_website", Label: "Website", Kind: KindText, Tier: TierPrint})
+ t.Cleanup(func() { catalogue = restore })
+ if err := Seed(g, s); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+ ix.Sync(ctx())
+ fields, _ := ix.Fields(TypeCustomer)
+ for _, f := range fields {
+ if f.Def.Key == "f_website" {
+ return
+ }
+ }
+ t.Fatal("a field seeded after the index loaded never reached it")
+}
diff --git a/internal/fichas/list.go b/internal/fichas/list.go
index 82c0ddd..e42aa13 100644
--- a/internal/fichas/list.go
+++ b/internal/fichas/list.go
@@ -223,6 +223,9 @@ func (l listState) pages(res Result) []map[string]any {
// countLine says what the list is showing, in words.
func countLine(res Result, filtered bool) string {
noun := res.Plural
+ if res.All == 1 && !filtered {
+ noun = res.Label // "1 customer", not "1 customers"
+ }
switch {
case res.All == 0:
return ""
diff --git a/internal/fichas/read.go b/internal/fichas/read.go
index c216e08..0266835 100644
--- a/internal/fichas/read.go
+++ b/internal/fichas/read.go
@@ -44,10 +44,8 @@ type CardView struct {
// with a value, because that is the name-shaped one; falls back to the id
// so a card with nothing in it is still reachable.
func (c CardView) Title() string {
- for _, f := range c.Fields {
- if f.Def.Key == FieldName && f.Value != "" {
- return f.Value
- }
+ if v := c.Field(c.titleKey()).Value; v != "" {
+ return v
}
for _, f := range c.Fields {
if f.Def.Kind == KindText && f.Value != "" {
@@ -185,28 +183,28 @@ func ListCards(ctx context.Context, db *gorm.DB, s *seal.Sealer, typeKey string)
// latestEntries returns each card's most recent entry, unsealed.
//
-// One query for the newest entry id per card, then the ordinary loader
-// for just those rows — so a list page unseals one entry per card, not a
-// card's whole history.
+// Batched: one query for the newest entry id per card, then ONE load for all
+// of those entries together. The first version loaded them one card at a
+// time — four queries each — and its cold-load timing was taken on cards
+// with no calls, so the path never ran: 238ms and 8,004 queries at 2,000
+// cards that each had a call, under the index's write lock. A review found it.
func latestEntries(g *gorm.DB, s *seal.Sealer, cardIDs []int64) (map[int64]*EntryView, error) {
- var newest []struct {
- CardID int64
- ID int64
- }
- if err := g.Model(&Entry{}).Select("card_id, MAX(id) AS id").
+ var newest []int64
+ if err := g.Model(&Entry{}).Select("MAX(id)").
Where("card_id IN ?", cardIDs).Group("card_id").Scan(&newest).Error; err != nil {
return nil, fmt.Errorf("fichas: finding latest entries: %w", err)
}
out := map[int64]*EntryView{}
- for _, n := range newest {
- views, err := loadEntriesWhere(g, s, "id = ?", n.ID)
- if err != nil {
- return nil, err
- }
- if len(views) == 1 {
- v := views[0]
- out[n.CardID] = &v
- }
+ if len(newest) == 0 {
+ return out, nil
+ }
+ views, err := loadEntriesWhere(g, s, "id IN ?", newest)
+ if err != nil {
+ return nil, err
+ }
+ for i := range views {
+ v := views[i]
+ out[v.Entry.CardID] = &v
}
return out, nil
}
@@ -382,7 +380,7 @@ func memberByAddress(ctx context.Context, db *gorm.DB, s *seal.Sealer, address s
func (c CardView) DetailItems() []map[string]any {
var out []map[string]any
for _, f := range c.Fields {
- if f.Def.Key == FieldName || f.Def.Key == FieldNotes {
+ if c.IsTitle(f.Def.Key) || isLong(f.Def) {
continue
}
v := f.Value
@@ -410,3 +408,78 @@ func displayDate(v string) string {
func EnsureMember(ctx context.Context, db *gorm.DB, s *seal.Sealer, address string) (Member, error) {
return memberByAddress(ctx, db, s, address)
}
+
+// titleKey is the field a card is named by: Name where the type has it,
+// otherwise the type's first field — which CreateType makes its name.
+func (c CardView) titleKey() string {
+ for _, f := range c.Fields {
+ if f.Def.Key == FieldName {
+ return FieldName
+ }
+ }
+ if len(c.Fields) > 0 {
+ return c.Fields[0].Def.Key
+ }
+ return ""
+}
+
+// IsTitle reports whether a field is the card's title.
+func (c CardView) IsTitle(key string) bool { return key == c.titleKey() }
+
+// FaceLine is one line on a card face.
+type FaceLine struct {
+ Label, Value string
+ Kind string // a field kind, or "call" for the latest call
+ Date string // for a date field: the stored value, for <time datetime>
+}
+
+// CardFace is what the Cards view draws for one card.
+type CardFace struct {
+ Pill string // the first choice field on the face, beside the title
+ Lines []FaceLine
+}
+
+// Face lays a card out by the type's face keys.
+func (c CardView) Face(keys []string) CardFace {
+ var face CardFace
+ for _, k := range keys {
+ if k == FaceLatestCall {
+ if c.Latest != nil && c.Latest.Body() != "" {
+ face.Lines = append(face.Lines, FaceLine{Kind: "call", Value: c.Latest.Body()})
+ }
+ continue
+ }
+ if k == c.titleKey() {
+ continue
+ }
+ f := c.Field(k)
+ if f.Value == "" {
+ continue
+ }
+ if f.Def.Kind == KindEnum && face.Pill == "" {
+ face.Pill = f.Value
+ continue
+ }
+ line := FaceLine{Label: f.Label, Value: f.Value, Kind: f.Def.Kind}
+ if isLong(f.Def) {
+ line.Kind = KindLongText
+ }
+ if f.Def.Kind == KindDate {
+ line.Date = f.Value
+ }
+ face.Lines = append(face.Lines, line)
+ }
+ return face
+}
+
+// LongFields are the card's paragraph fields that have a value, for the read
+// view, which gives each its own box.
+func (c CardView) LongFields() []FieldView {
+ var out []FieldView
+ for _, f := range c.Fields {
+ if isLong(f.Def) && f.Value != "" {
+ out = append(out, f)
+ }
+ }
+ return out
+}
diff --git a/internal/fichas/render.go b/internal/fichas/render.go
index 6b492b2..684b29d 100644
--- a/internal/fichas/render.go
+++ b/internal/fichas/render.go
@@ -35,6 +35,8 @@ func init() {
shells := map[string]string{
"index": "shell", "card_new": "shell", "card_show": "shell",
"card_edit": "shell", "card_delete": "shell",
+ "fields": "shell", "field_new": "shell", "field_edit": "shell",
+ "type_new": "shell", "card_layout": "shell",
"signin": "layout", "errors": "layout",
}
for name, shell := range shells {
@@ -50,9 +52,11 @@ func init() {
"isLong": isLong,
"lower": lower,
"commas": commas,
+ "errFor": errFor,
+ "yesNo": yesNo,
}).
ParseFS(ui.Templates(), "*.html")).
- ParseFS(appFS, "templates/"+shell+".html", "templates/"+name+".html"))
+ ParseFS(appFS, "templates/"+shell+".html", "templates/partials.html", "templates/"+name+".html"))
}
}
@@ -166,3 +170,19 @@ func stageTone(stage string) string {
return "neutral"
}
}
+
+// errFor is the error for one form field, or "" — nil-safe, because most
+// pages have no errors and pass none.
+func errFor(errs any, key string) string {
+ if fe, ok := errs.(FormError); ok {
+ return fe[key]
+ }
+ return ""
+}
+
+func yesNo(b bool) string {
+ if b {
+ return "Yes"
+ }
+ return "No — private"
+}
diff --git a/internal/fichas/schema.go b/internal/fichas/schema.go
new file mode 100644
index 0000000..3788629
--- /dev/null
+++ b/internal/fichas/schema.go
@@ -0,0 +1,441 @@
+package fichas
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "amadan.net/oficina/fichas/internal/fichas/seal"
+ "gorm.io/gorm"
+)
+
+// The type designer: adding fields, renaming them, creating card types and
+// arranging a card's face — "in database view I could set up tables and
+// fields" (Paul, 2026-09-29).
+//
+// §4.3 holds throughout. A type or field is ROWS, never DDL: its key is an
+// opaque value generated here and never the user's words, and its label is
+// sealed. A user-added field may be sealed or printed and never plaintext.
+//
+// Every change here writes an Event like a card change does, because "every
+// change to every object" includes the schema, and because the index
+// (index.go) rebuilds from the event log: a field added without an event is
+// a field the Table view would never show.
+
+// Event object kinds for the schema.
+const (
+ ObjectType = "type"
+ ObjectField = "field"
+)
+
+// Limits, stated so a form can say them.
+const (
+ maxLabel = 80
+ maxOptions = 50
+ maxOption = 60
+ maxText = 2000
+ maxLong = 20000
+)
+
+// FieldSpec is what the add-field form submits.
+type FieldSpec struct {
+ Label string
+ Kind string
+ Tier Tier
+ Options []string // KindEnum only
+}
+
+// FormError is a validation failure a person can fix, keyed by the form
+// field it belongs beside. Handlers render it; nothing is written.
+type FormError map[string]string
+
+func (e FormError) Error() string {
+ parts := make([]string, 0, len(e))
+ for k, v := range e {
+ parts = append(parts, k+": "+v)
+ }
+ return "fichas: " + strings.Join(parts, "; ")
+}
+
+// userKinds are the kinds the designer offers. KindRef ("link to another
+// card") needs a picker and waits; offering it without one would store ids
+// typed by hand.
+var userKinds = map[string]bool{KindText: true, KindLongText: true, KindNumber: true, KindDate: true, KindEnum: true}
+
+func (spec FieldSpec) validate() FormError {
+ e := FormError{}
+ label := strings.TrimSpace(spec.Label)
+ switch {
+ case label == "":
+ e["label"] = "Give the field a name."
+ case utf8.RuneCountInString(label) > maxLabel:
+ e["label"] = fmt.Sprintf("Keep the name under %d characters.", maxLabel)
+ }
+ if !userKinds[spec.Kind] {
+ e["kind"] = "Choose what kind of value it holds."
+ }
+ switch spec.Tier {
+ case TierSealed:
+ case TierPrint:
+ // §4.2: a print publishes equality classes. For a short list of
+ // options that IS the values; for paragraphs it is meaningless.
+ switch spec.Kind {
+ case KindEnum:
+ e["tier"] = "A choice can't be findable by exact match: with only a few options, the matches would give the values away."
+ case KindLongText:
+ e["tier"] = "Long text can't be findable by exact match — two notes are almost never identical, so it would find nothing."
+ }
+ default:
+ // TierPlain is not offered, and not accepted: it is reserved for
+ // structural columns the app ships (§4.3).
+ e["tier"] = "Choose whether fichas can check it for duplicates."
+ }
+ if spec.Kind == KindEnum {
+ opts, msg := cleanOptions(spec.Options)
+ if msg != "" {
+ e["options"] = msg
+ } else if len(opts) == 0 {
+ e["options"] = "Add at least one option, one per line."
+ }
+ }
+ if len(e) == 0 {
+ return nil
+ }
+ return e
+}
+
+// cleanOptions trims, drops blanks and duplicates, and enforces the limits.
+func cleanOptions(in []string) ([]string, string) {
+ seen := map[string]bool{}
+ var out []string
+ for _, o := range in {
+ o = strings.TrimSpace(o)
+ if o == "" || seen[o] {
+ continue
+ }
+ if utf8.RuneCountInString(o) > maxOption {
+ return nil, fmt.Sprintf("Keep each option under %d characters.", maxOption)
+ }
+ seen[o] = true
+ out = append(out, o)
+ }
+ if len(out) > maxOptions {
+ return nil, fmt.Sprintf("Keep it to %d options or fewer.", maxOptions)
+ }
+ return out, ""
+}
+
+// newKey is an opaque key: a prefix and 12 random hex digits. Opaque on
+// purpose — a key reaches the durable file in the clear, a label must not.
+func newKey(prefix string) (string, error) {
+ b := make([]byte, 6)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("fichas: generating a key: %w", err)
+ }
+ return prefix + hex.EncodeToString(b), nil
+}
+
+func sealOptions(s *seal.Sealer, opts []string) ([]byte, error) {
+ if len(opts) == 0 {
+ return nil, nil
+ }
+ raw, err := json.Marshal(opts)
+ if err != nil {
+ return nil, err
+ }
+ return s.Seal(seal.PurposeLabel, raw)
+}
+
+// AddField adds a field to a card type. Existing cards simply have no value
+// for it; nothing is migrated.
+func AddField(ctx context.Context, db *gorm.DB, s *seal.Sealer, typeKey string, actor Actor, spec FieldSpec) (FieldDef, error) {
+ if e := spec.validate(); e != nil {
+ return FieldDef{}, e
+ }
+ var fd FieldDef
+ err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ ct, err := typeByKey(tx, typeKey, ScopeCard)
+ if err != nil {
+ return err
+ }
+ key, err := newKey("f_")
+ if err != nil {
+ return err
+ }
+ label, err := s.Seal(seal.PurposeLabel, []byte(strings.TrimSpace(spec.Label)))
+ if err != nil {
+ return err
+ }
+ opts, _ := cleanOptions(spec.Options)
+ sealedOpts, err := sealOptions(s, opts)
+ if err != nil {
+ return err
+ }
+ var ordinal int
+ if err := tx.Model(&FieldDef{}).Where("card_type_id = ?", ct.ID).
+ Select("COALESCE(MAX(ordinal), -1) + 1").Scan(&ordinal).Error; err != nil {
+ return err
+ }
+ fd = FieldDef{CardTypeID: ct.ID, Key: key, SealedLabel: label, SealedOptions: sealedOpts,
+ Kind: spec.Kind, Tier: spec.Tier, Ordinal: ordinal}
+ if err := tx.Create(&fd).Error; err != nil {
+ return fmt.Errorf("fichas: creating the field: %w", err)
+ }
+ return logEvent(tx, s, EventCreated, ObjectField, fd.ID, 0, 0, actor, nil,
+ []change{{Field: "label", After: strings.TrimSpace(spec.Label)}, {Field: "kind", After: spec.Kind}, {Field: "tier", After: string(spec.Tier)}})
+ })
+ if err != nil {
+ return FieldDef{}, err
+ }
+ return fd, nil
+}
+
+// UpdateField renames a field and, for a choice, replaces its options.
+//
+// Kind and tier are NOT editable, and that is the design rather than a gap:
+// both describe how the values already in the database were written, and
+// changing either is a re-encryption of every one of them (Seed refuses the
+// same change for shipped fields). Removing an option does not touch cards
+// that have it: they keep showing their value.
+func UpdateField(ctx context.Context, db *gorm.DB, s *seal.Sealer, key string, actor Actor, label string, options []string) error {
+ label = strings.TrimSpace(label)
+ e := FormError{}
+ if label == "" {
+ e["label"] = "Give the field a name."
+ } else if utf8.RuneCountInString(label) > maxLabel {
+ e["label"] = fmt.Sprintf("Keep the name under %d characters.", maxLabel)
+ }
+ return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ var fd FieldDef
+ if err := tx.Where("key = ?", key).First(&fd).Error; err != nil {
+ return err
+ }
+ var opts []string
+ if fd.Kind == KindEnum {
+ var msg string
+ opts, msg = cleanOptions(options)
+ if msg != "" {
+ e["options"] = msg
+ } else if len(opts) == 0 {
+ e["options"] = "Keep at least one option."
+ }
+ }
+ if len(e) > 0 {
+ return e
+ }
+ before, err := openLabel(s, fd.SealedLabel)
+ if err != nil {
+ return err
+ }
+ sealed, err := s.Seal(seal.PurposeLabel, []byte(label))
+ if err != nil {
+ return err
+ }
+ set := map[string]any{"sealed_label": sealed, "updated_at": time.Now()}
+ changes := []change{}
+ if before != label {
+ changes = append(changes, change{Field: "label", Before: before, After: label})
+ }
+ if fd.Kind == KindEnum {
+ var prev []string
+ if len(fd.SealedOptions) > 0 {
+ if prev, err = openOptions(s, fd.SealedOptions); err != nil {
+ return err
+ }
+ }
+ if strings.Join(prev, "\n") != strings.Join(opts, "\n") {
+ so, err := sealOptions(s, opts)
+ if err != nil {
+ return err
+ }
+ set["sealed_options"] = so
+ changes = append(changes, change{Field: "options", Before: strings.Join(prev, "\n"), After: strings.Join(opts, "\n")})
+ }
+ }
+ if len(changes) == 0 {
+ return nil // nothing moved; nothing is logged
+ }
+ if err := tx.Model(&FieldDef{}).Where("id = ?", fd.ID).Updates(set).Error; err != nil {
+ return err
+ }
+ return logEvent(tx, s, EventUpdated, ObjectField, fd.ID, 0, 0, actor, nil, changes)
+ })
+}
+
+// CreateType adds a card type with one field, its title: a type without a
+// name field has cards nobody can tell apart.
+func CreateType(ctx context.Context, db *gorm.DB, s *seal.Sealer, actor Actor, singular, plural string) (CardType, error) {
+ singular, plural = strings.TrimSpace(singular), strings.TrimSpace(plural)
+ e := FormError{}
+ if singular == "" {
+ e["label"] = "Name one of them — Supplier, Candidate, Property."
+ } else if utf8.RuneCountInString(singular) > maxLabel {
+ e["label"] = fmt.Sprintf("Keep the name under %d characters.", maxLabel)
+ }
+ if utf8.RuneCountInString(plural) > maxLabel {
+ e["plural"] = fmt.Sprintf("Keep the name under %d characters.", maxLabel)
+ }
+ if len(e) > 0 {
+ return CardType{}, e
+ }
+ var ct CardType
+ err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ key, err := newKey("t_")
+ if err != nil {
+ return err
+ }
+ label, err := s.Seal(seal.PurposeLabel, []byte(singular))
+ if err != nil {
+ return err
+ }
+ var sp []byte
+ if plural != "" {
+ if sp, err = s.Seal(seal.PurposeLabel, []byte(plural)); err != nil {
+ return err
+ }
+ }
+ ct = CardType{Key: key, Scope: ScopeCard, SealedLabel: label, SealedPlural: sp}
+ if err := tx.Create(&ct).Error; err != nil {
+ return fmt.Errorf("fichas: creating the type: %w", err)
+ }
+ fk, err := newKey("f_")
+ if err != nil {
+ return err
+ }
+ nl, err := s.Seal(seal.PurposeLabel, []byte("Name"))
+ if err != nil {
+ return err
+ }
+ // Printed: a name is high-cardinality, and "is this the same one"
+ // is the question worth answering without the key — as Customer's.
+ if err := tx.Create(&FieldDef{CardTypeID: ct.ID, Key: fk, SealedLabel: nl, Kind: KindText, Tier: TierPrint}).Error; err != nil {
+ return err
+ }
+ return logEvent(tx, s, EventCreated, ObjectType, ct.ID, 0, 0, actor, nil,
+ []change{{Field: "label", After: singular}, {Field: "plural", After: plural}})
+ })
+ if err != nil {
+ return CardType{}, err
+ }
+ return ct, nil
+}
+
+// SetFace saves which fields a type's cards show in the Cards view.
+func SetFace(ctx context.Context, db *gorm.DB, s *seal.Sealer, typeKey string, actor Actor, keys []string) error {
+ return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ ct, err := typeByKey(tx, typeKey, ScopeCard)
+ if err != nil {
+ return err
+ }
+ var defs []FieldDef
+ if err := tx.Where("card_type_id = ?", ct.ID).Find(&defs).Error; err != nil {
+ return err
+ }
+ known := map[string]bool{FaceLatestCall: true}
+ for _, d := range defs {
+ known[d.Key] = true
+ }
+ clean := []string{}
+ seen := map[string]bool{}
+ for _, k := range keys {
+ if !known[k] {
+ return FormError{"face": "That field isn't on this card type."}
+ }
+ if !seen[k] {
+ seen[k] = true
+ clean = append(clean, k)
+ }
+ }
+ raw, _ := json.Marshal(clean)
+ if string(raw) == ct.FaceKeys {
+ return nil
+ }
+ if err := tx.Model(&CardType{}).Where("id = ?", ct.ID).
+ Updates(map[string]any{"face_keys": string(raw), "updated_at": time.Now()}).Error; err != nil {
+ return err
+ }
+ return logEvent(tx, s, EventUpdated, ObjectType, ct.ID, 0, 0, actor, nil,
+ []change{{Field: "face", Before: ct.FaceKeys, After: string(raw)}})
+ })
+}
+
+// validateValues checks card values against their field kinds before any is
+// written, and says what is wrong in words a person can act on. Before this
+// the store took anything, so a mistyped number or date was a 500.
+func validateValues(defs map[string]FieldDef, values map[string]string) FormError {
+ e := FormError{}
+ for key, v := range values {
+ d, ok := defs[key]
+ if !ok || v == "" {
+ continue
+ }
+ switch d.Kind {
+ case KindNumber:
+ if _, err := strconv.ParseFloat(strings.ReplaceAll(v, ",", ""), 64); err != nil {
+ e[key] = "Enter a number, like 1200 or 3.5."
+ }
+ case KindDate:
+ if _, err := time.Parse("2006-01-02", v); err != nil {
+ e[key] = "Enter a date."
+ }
+ case KindEnum:
+ // The options are sealed and checked by the caller, which has
+ // them open; see optionsError.
+ }
+ limit := maxText
+ if isLong(d) {
+ limit = maxLong
+ }
+ if utf8.RuneCountInString(v) > limit {
+ e[key] = fmt.Sprintf("That's too long — keep it under %d characters.", limit)
+ }
+ }
+ if len(e) == 0 {
+ return nil
+ }
+ return e
+}
+
+// optionsError checks a choice value against the field's options.
+func optionsError(s *seal.Sealer, defs map[string]FieldDef, values map[string]string, e FormError) (FormError, error) {
+ for key, v := range values {
+ d, ok := defs[key]
+ if !ok || d.Kind != KindEnum || v == "" || len(d.SealedOptions) == 0 {
+ continue
+ }
+ opts, err := openOptions(s, d.SealedOptions)
+ if err != nil {
+ return nil, err
+ }
+ found := false
+ for _, o := range opts {
+ if o == v {
+ found = true
+ }
+ }
+ if !found {
+ if e == nil {
+ e = FormError{}
+ }
+ e[key] = "Choose one of the options."
+ }
+ }
+ return e, nil
+}
+
+// IsFormError reports whether err is a validation failure to show beside
+// the fields rather than a failure of the app.
+func IsFormError(err error) (FormError, bool) {
+ var fe FormError
+ if errors.As(err, &fe) {
+ return fe, true
+ }
+ return nil, false
+}
diff --git a/internal/fichas/schema_test.go b/internal/fichas/schema_test.go
new file mode 100644
index 0000000..f064900
--- /dev/null
+++ b/internal/fichas/schema_test.go
@@ -0,0 +1,185 @@
+package fichas
+
+import (
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/fichas/internal/fichas/seal"
+)
+
+func TestAddFieldRefusesWhatWouldLeakOrMislead(t *testing.T) {
+ g, s := seeded(t)
+ for name, spec := range map[string]FieldSpec{
+ "no name": {Kind: KindText, Tier: TierSealed},
+ "no kind": {Label: "x", Tier: TierSealed},
+ "a link, not built": {Label: "x", Kind: KindRef, Tier: TierSealed},
+ "plaintext": {Label: "x", Kind: KindText, Tier: TierPlain},
+ "a findable choice": {Label: "x", Kind: KindEnum, Tier: TierPrint, Options: []string{"a", "b"}},
+ "findable long text": {Label: "x", Kind: KindLongText, Tier: TierPrint},
+ "a choice, no options": {Label: "x", Kind: KindEnum, Tier: TierSealed, Options: []string{" ", ""}},
+ } {
+ _, err := AddField(ctx(), g, s, TypeCustomer, Person(1), spec)
+ if _, ok := IsFormError(err); !ok {
+ t.Errorf("%s: got %v, want a FormError", name, err)
+ }
+ }
+ var n int64
+ g.Model(&FieldDef{}).Where("key NOT LIKE ?", "f\\_%").Count(&n)
+ var total int64
+ g.Model(&FieldDef{}).Count(&total)
+ if total != int64(len(catalogue[0].Fields)+len(catalogue[1].Fields)) {
+ t.Errorf("a refused field was written anyway (%d field defs)", total)
+ }
+}
+
+func TestAddedFieldsAreOpaqueSealedAndLogged(t *testing.T) {
+ g, s := seeded(t)
+ fd, err := AddField(ctx(), g, s, TypeCustomer, Person(1), FieldSpec{
+ Label: "Contract value", Kind: KindNumber, Tier: TierSealed})
+ if err != nil {
+ t.Fatalf("AddField: %v", err)
+ }
+ if !strings.HasPrefix(fd.Key, "f_") || strings.Contains(strings.ToLower(fd.Key), "contract") {
+ t.Fatalf("the key %q is not opaque: it reaches the durable file in the clear", fd.Key)
+ }
+ label, _ := s.Open(seal.PurposeLabel, fd.SealedLabel)
+ if string(label) != "Contract value" {
+ t.Fatalf("label is %q", label)
+ }
+ if fd.Ordinal != len(catalogue[0].Fields) {
+ t.Fatalf("a new field is at ordinal %d, want it last (%d)", fd.Ordinal, len(catalogue[0].Fields))
+ }
+ if ev := events(t, g, ObjectField, fd.ID); len(ev) != 1 || ev[0].Kind != EventCreated {
+ t.Fatalf("adding a field was not logged: %+v", ev)
+ }
+}
+
+// A field added in the designer must reach the Table view, which reads the
+// index — and the index only learns from the event log.
+func TestTheIndexSeesANewField(t *testing.T) {
+ g, s := seeded(t)
+ ix := NewIndex(g, s)
+ CreateCard(ctx(), g, s, TypeCustomer, Person(1), map[string]string{FieldName: "Cnoc Teoranta"})
+ mustQuery(t, ix, Query{})
+
+ fd, _ := AddField(ctx(), g, s, TypeCustomer, Person(1), FieldSpec{Label: "Region", Kind: KindEnum,
+ Tier: TierSealed, Options: []string{"North", "South"}})
+ fields, _ := func() ([]FieldView, error) { ix.Sync(ctx()); return ix.Fields(TypeCustomer) }()
+ found := false
+ for _, f := range fields {
+ if f.Def.Key == fd.Key && f.Label == "Region" && len(f.Options) == 2 {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("the index did not pick up a field added after it loaded")
+ }
+}
+
+func TestUpdateFieldRenamesAndLogsOnlyRealChanges(t *testing.T) {
+ g, s := seeded(t)
+ fd, _ := AddField(ctx(), g, s, TypeCustomer, Person(1), FieldSpec{Label: "Region", Kind: KindEnum,
+ Tier: TierSealed, Options: []string{"North", "South"}})
+
+ if err := UpdateField(ctx(), g, s, fd.Key, Person(1), "Region", []string{"North", "South"}); err != nil {
+ t.Fatalf("no-op update: %v", err)
+ }
+ if n := len(events(t, g, ObjectField, fd.ID)); n != 1 {
+ t.Fatalf("a no-op update was logged (%d events)", n)
+ }
+ if err := UpdateField(ctx(), g, s, fd.Key, Person(2), "Territory", []string{"North", "South", "West"}); err != nil {
+ t.Fatalf("UpdateField: %v", err)
+ }
+ ev := events(t, g, ObjectField, fd.ID)
+ if len(ev) != 2 || ev[1].ActorID == nil || *ev[1].ActorID != 2 {
+ t.Fatalf("the rename was not logged with its actor: %+v", ev)
+ }
+ var after FieldDef
+ g.Where("key = ?", fd.Key).First(&after)
+ if l, _ := s.Open(seal.PurposeLabel, after.SealedLabel); string(l) != "Territory" {
+ t.Fatalf("label is %q after a rename", l)
+ }
+ if after.Kind != KindEnum || after.Tier != TierSealed {
+ t.Fatal("a rename changed kind or tier")
+ }
+ if _, ok := IsFormError(UpdateField(ctx(), g, s, fd.Key, Person(1), "Territory", nil)); !ok {
+ t.Fatal("a choice was left with no options")
+ }
+}
+
+func TestCreateTypeGivesItANameField(t *testing.T) {
+ g, s := seeded(t)
+ ct, err := CreateType(ctx(), g, s, Person(1), "Supplier", "")
+ if err != nil {
+ t.Fatalf("CreateType: %v", err)
+ }
+ if ct.Scope != ScopeCard || !strings.HasPrefix(ct.Key, "t_") || strings.Contains(ct.Key, "upplier") {
+ t.Fatalf("type is %+v", ct)
+ }
+ var defs []FieldDef
+ g.Where("card_type_id = ?", ct.ID).Find(&defs)
+ if len(defs) != 1 || defs[0].Kind != KindText || defs[0].Tier != TierPrint {
+ t.Fatalf("a new type's fields are %+v", defs)
+ }
+ card, err := CreateCard(ctx(), g, s, ct.Key, Person(1), map[string]string{defs[0].Key: "Harbour Timber"})
+ if err != nil {
+ t.Fatalf("a card of the new type: %v", err)
+ }
+ ix := NewIndex(g, s)
+ ix.Sync(ctx())
+ r, err := ix.Query(ct.Key, Query{})
+ if err != nil || r.Total != 1 || r.Cards[0].Title() != "Harbour Timber" || r.Plural != "Suppliers" {
+ t.Fatalf("the new type's list: %v total=%d plural=%q", err, r.Total, r.Plural)
+ }
+ _ = card
+ if _, ok := IsFormError(func() error { _, e := CreateType(ctx(), g, s, Person(1), " ", ""); return e }()); !ok {
+ t.Fatal("a type with no name was created")
+ }
+}
+
+func TestSetFaceKeepsOnlyThisTypesFields(t *testing.T) {
+ g, s := seeded(t)
+ if err := SetFace(ctx(), g, s, TypeCustomer, Person(1), []string{FieldStage, FaceLatestCall, FieldStage}); err != nil {
+ t.Fatalf("SetFace: %v", err)
+ }
+ var ct CardType
+ g.Where("key = ?", TypeCustomer).First(&ct)
+ if ct.FaceKeys != `["`+FieldStage+`","`+FaceLatestCall+`"]` {
+ t.Fatalf("face is %s", ct.FaceKeys)
+ }
+ if _, ok := IsFormError(SetFace(ctx(), g, s, TypeCustomer, Person(1), []string{FieldBody})); !ok {
+ t.Fatal("a Call log field was accepted on the Customer card face")
+ }
+}
+
+// Values are checked by kind, and a bad one writes nothing — before this a
+// mistyped number or date was a 500.
+func TestValuesAreCheckedByKind(t *testing.T) {
+ g, s := seeded(t)
+ num, _ := AddField(ctx(), g, s, TypeCustomer, Person(1), FieldSpec{Label: "Seats", Kind: KindNumber, Tier: TierSealed})
+ for name, v := range map[string]map[string]string{
+ "a word in a number": {num.Key: "twelve"},
+ "a bad date": {FieldNextAction: "next tuesday"},
+ "an unknown stage": {FieldStage: "Maybe"},
+ } {
+ _, err := CreateCard(ctx(), g, s, TypeCustomer, Person(1), v)
+ fe, ok := IsFormError(err)
+ if !ok {
+ t.Errorf("%s: got %v, want a FormError", name, err)
+ continue
+ }
+ for k := range v {
+ if fe[k] == "" {
+ t.Errorf("%s: no message for %s", name, k)
+ }
+ }
+ }
+ var cards int64
+ g.Model(&Card{}).Count(&cards)
+ if cards != 0 {
+ t.Fatalf("%d card(s) written despite invalid values", cards)
+ }
+ if _, err := CreateCard(ctx(), g, s, TypeCustomer, Person(1), map[string]string{num.Key: "1,200", FieldStage: "Won"}); err != nil {
+ t.Fatalf("valid values refused: %v", err)
+ }
+}
diff --git a/internal/fichas/static/fichas.css b/internal/fichas/static/fichas.css
index 255cf5e..906162f 100644
--- a/internal/fichas/static/fichas.css
+++ b/internal/fichas/static/fichas.css
@@ -229,3 +229,16 @@
[fichas-grid] th a { color: inherit; text-decoration: none; }
[fichas-grid] th a:hover { text-decoration: underline; }
[fichas-grid] th[aria-sort] a { color: var(--rst-text); }
+
+/* ---- Long text keeps its line breaks; on a card face it is cut short. */
+[fichas-prose] { white-space: pre-line; }
+[fichas-clamp] {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* ---- A live region is a wrapper for swapping, not for layout: the filter
+ menus inside the list bar stay flex items of the bar. */
+#list-filters { display: contents; }
diff --git a/internal/fichas/static/live.js b/internal/fichas/static/live.js
index 6cf1c29..474b83b 100644
--- a/internal/fichas/static/live.js
+++ b/internal/fichas/static/live.js
@@ -1,20 +1,30 @@
-/* live.js — the search box updates the results as you type.
+/* live.js — the search box updates the list as you type.
*
* Progressive enhancement over an ordinary GET form: with scripts off the
* form submits and the server renders the same page. With them on, typing
- * fetches that same URL and swaps only the results region, so the search
- * box keeps focus and the cursor stays where it was.
+ * fetches that same URL and swaps every [data-live-region] — the results,
+ * the view tabs and the filter menus — by id. The search box itself is
+ * never replaced, so it keeps focus and the cursor stays put.
*
- * Why this is fast enough to feel instant: the server answers from an
- * in-memory index (index.go) — a search over 5,000 cards is a quarter of a
+ * All three are swapped, not just the results, because every link in the
+ * tabs and filter menus carries the current search. Swapping only the
+ * results (the first version) left those links carrying the search the
+ * page was LOADED with, so picking a filter after typing quietly threw the
+ * typed search away.
+ *
+ * Fast enough to feel instant because the server answers from an in-memory
+ * index (index.go) — a search over 5,000 cards is a fraction of a
* millisecond — so the cost is one round trip and a render. Keystrokes are
- * debounced so a word typed quickly is one request, and a request that a
- * newer keystroke has overtaken is aborted, so a slow answer can never
- * overwrite a fresher one.
+ * debounced, and a request a newer keystroke overtakes is aborted, so a slow
+ * answer can never overwrite a fresher one.
*
* The URL follows along (history.replaceState), so a live search is still a
- * link you can bookmark or share, and Back is not littered with one entry
- * per keystroke.
+ * link, and Back is not littered with an entry per keystroke.
+ *
+ * When the answer is not a list page — a redirect to sign-in because the
+ * session expired, or an error — the browser simply navigates to the URL,
+ * so the person sees what the server has to say. The first version
+ * swallowed that silently and the box went dead.
*/
(function () {
"use strict";
@@ -23,8 +33,7 @@
function init() {
var bar = document.querySelector("[data-live-search]");
- var results = document.querySelector("[data-live-results]");
- if (!bar || !results || !window.fetch || !window.DOMParser) return;
+ if (!bar || !window.fetch || !window.DOMParser) return;
var form = bar.querySelector("form");
var input = form && form.querySelector('input[name="q"]');
if (!input) return;
@@ -43,26 +52,43 @@
var url = urlFor();
if (inflight) inflight.abort();
inflight = new AbortController();
- results.setAttribute("aria-busy", "true");
+ var results = document.querySelector("[data-live-results]");
+ if (results) results.setAttribute("aria-busy", "true");
fetch(url, { signal: inflight.signal, headers: { Accept: "text/html" }, credentials: "same-origin" })
.then(function (r) {
- if (!r.ok) throw new Error("status " + r.status);
+ if (!r.ok || r.redirected) {
+ location.assign(url);
+ return null;
+ }
return r.text();
})
.then(function (text) {
+ if (text === null) return;
var doc = new DOMParser().parseFromString(text, "text/html");
- var fresh = doc.querySelector("[data-live-results]");
- if (!fresh) return;
- results.innerHTML = fresh.innerHTML;
+ var regions = document.querySelectorAll("[data-live-region][id]");
+ var swapped = 0;
+ regions.forEach(function (el) {
+ var fresh = doc.getElementById(el.id);
+ if (fresh && fresh.hasAttribute("data-live-region")) {
+ el.innerHTML = fresh.innerHTML;
+ swapped++;
+ }
+ });
+ if (swapped === 0) {
+ location.assign(url); // not a list page: let the browser show it
+ return;
+ }
history.replaceState(null, "", url);
})
.catch(function (err) {
- // An abort is a newer keystroke winning; anything else leaves
- // the page as it was and the form still submits normally.
+ // An abort is a newer keystroke winning. Anything else: navigate,
+ // and let the server's page say what went wrong.
if (err && err.name === "AbortError") return;
+ location.assign(url);
})
.finally(function () {
- results.removeAttribute("aria-busy");
+ var r = document.querySelector("[data-live-results]");
+ if (r) r.removeAttribute("aria-busy");
});
}
@@ -71,7 +97,8 @@
timer = setTimeout(refresh, DEBOUNCE_MS);
});
- // Enter does the same thing immediately rather than reloading the page.
+ // Enter runs the same refresh at once instead of reloading the page;
+ // refresh itself falls back to navigating when a swap is not possible.
form.addEventListener("submit", function (e) {
e.preventDefault();
clearTimeout(timer);
diff --git a/internal/fichas/store.go b/internal/fichas/store.go
index a8207c3..475b881 100644
--- a/internal/fichas/store.go
+++ b/internal/fichas/store.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"sort"
+ "strings"
"time"
"amadan.net/oficina/fichas/internal/fichas/seal"
@@ -166,6 +167,30 @@ func logEvent(tx *gorm.DB, s *seal.Sealer, kind, objectKind string, objectID, ch
// define is an error rather than something quietly dropped — a silently
// ignored field is a value a person believes they saved.
func writeValues(tx *gorm.DB, s *seal.Sealer, cardID int64, defs map[string]FieldDef, values map[string]string) ([]int64, []change, error) {
+ for key := range values {
+ if _, ok := defs[key]; !ok {
+ return nil, nil, fmt.Errorf("fichas: %s is not a field of this card type", key)
+ }
+ }
+ // Numbers are stored bare: "1,200" is accepted from a person and kept
+ // as "1200", or it would sort as text and never compare as a number.
+ normalised := make(map[string]string, len(values))
+ for k, v := range values {
+ if defs[k].Kind == KindNumber {
+ v = strings.ReplaceAll(strings.TrimSpace(v), ",", "")
+ }
+ normalised[k] = v
+ }
+ values = normalised
+ fe := validateValues(defs, values)
+ fe, err := optionsError(s, defs, values, fe)
+ if err != nil {
+ return nil, nil, err
+ }
+ if fe != nil {
+ return nil, nil, fe
+ }
+
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
diff --git a/internal/fichas/templates/card_delete.html b/internal/fichas/templates/card_delete.html
index fa6d5e8..755f8f7 100644
--- a/internal/fichas/templates/card_delete.html
+++ b/internal/fichas/templates/card_delete.html
@@ -1,7 +1,7 @@
{{define "content"}}
{{$c := .Card}}
<header fichas-appbar>
- <h1 fichas-appbar-title>Delete this customer?</h1>
+ <h1 fichas-appbar-title>Delete this {{lower $c.TypeLabel}}?</h1>
</header>
{{template "callout" (dict
@@ -11,7 +11,7 @@
{{template "confirm-form" (dict
"Action" (printf "/cards/%d/delete" $c.Card.ID)
- "Label" "Delete this customer"
+ "Label" (printf "Delete this %s" (lower $c.TypeLabel))
"Danger" true)}}
<p><a fichas-link href="/cards/{{$c.Card.ID}}">Keep it</a></p>
diff --git a/internal/fichas/templates/card_edit.html b/internal/fichas/templates/card_edit.html
index 70177ef..b908ebf 100644
--- a/internal/fichas/templates/card_edit.html
+++ b/internal/fichas/templates/card_edit.html
@@ -6,22 +6,12 @@
</header>
<form method="post" action="/cards/{{$c.Card.ID}}" fichas-form>
- {{range $f := $c.Fields}}
- {{if eq $f.Def.Kind "enum"}}
- {{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Options" (options $f.Options $f.Value))}}
- {{else if eq $f.Def.Kind "date"}}
- {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
- {{else if eq $f.Def.Key "f_notes"}}
- {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Rows" 4)}}
- {{else}}
- {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value)}}
- {{end}}
- {{end}}
+ {{range $f := $c.Fields}}{{template "fichas-input" (dict "F" $f "Error" (errFor $.Errors $f.Def.Key))}}{{end}}
{{template "form-foot" (dict "Submit" "Save changes" "CancelHref" (printf "/cards/%d" $c.Card.ID) "CancelLabel" "Cancel")}}
</form>
<div fichas-danger-zone>
- <a rst-btn="danger" href="/cards/{{$c.Card.ID}}/delete">Delete this customer…</a>
+ <a rst-btn="danger" href="/cards/{{$c.Card.ID}}/delete">Delete this {{lower $c.TypeLabel}}…</a>
<p fichas-meta>Opens a confirmation page. The change log keeps who deleted it.</p>
</div>
{{end}}
diff --git a/internal/fichas/templates/card_layout.html b/internal/fichas/templates/card_layout.html
new file mode 100644
index 0000000..954f7e8
--- /dev/null
+++ b/internal/fichas/templates/card_layout.html
@@ -0,0 +1,34 @@
+{{define "title"}}Arrange the {{.Type.Label}} card · fichas{{end}}
+{{define "content"}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>Arrange the {{.Type.Label}} card <span>what each card shows in Cards view</span></h1>
+</header>
+<form method="post" action="/t/{{.TypeKey}}/layout" fichas-layout-editor>
+ <div>
+ <fieldset fichas-checklist>
+ <legend>Show on the card</legend>
+ {{range .Options}}
+ {{if .Locked}}{{template "field-check" (dict "Name" "_title" "Value" .Key "Label" (printf "%s — always the title" .Label) "Checked" true "Disabled" true)}}
+ {{else}}{{template "field-check" (dict "Name" "face" "Value" .Key "Label" .Label "Checked" .On)}}{{end}}
+ {{end}}
+ </fieldset>
+ {{with errFor .Errors "face"}}<p rst-field-error role="alert">{{.}}</p>{{end}}
+ <p fichas-meta>The first Choice you show sits beside the title as a status.</p>
+ {{template "form-foot" (dict "Submit" "Save card layout" "CancelHref" (printf "/t/%s?view=cards" .TypeKey) "CancelLabel" "Cancel")}}
+ </div>
+ <aside>
+ <p fichas-meta>Current layout, on your most recent card</p>
+ {{with .Preview}}{{$face := .Face $.Face}}
+ <ul fichas-card-grid><li><article rst-card>
+ <h2 fichas-card-title><span>{{.Title}}</span>{{with $face.Pill}}{{template "status-pill" (dict "Label" . "Tone" (stageTone .))}}{{end}}</h2>
+ {{range $face.Lines}}
+ {{if eq .Kind "call"}}<p fichas-card-quote>{{.Value}}</p>
+ {{else if eq .Kind "date"}}<p fichas-meta>{{.Label}} <time datetime="{{.Date}}" title="{{.Date}}">{{dateAgo .Date}}</time></p>
+ {{else if eq .Kind "longtext"}}<p fichas-meta fichas-clamp>{{.Value}}</p>
+ {{else}}<p fichas-meta>{{.Value}}</p>{{end}}
+ {{end}}
+ </article></li></ul>
+ {{else}}<p fichas-meta>Add a card to see a preview.</p>{{end}}
+ </aside>
+</form>
+{{end}}
diff --git a/internal/fichas/templates/card_new.html b/internal/fichas/templates/card_new.html
index 5879ec4..f2ca2ce 100644
--- a/internal/fichas/templates/card_new.html
+++ b/internal/fichas/templates/card_new.html
@@ -1,23 +1,12 @@
-{{define "title"}}Add a customer · fichas{{end}}
+{{define "title"}}Add a {{lower .Type.Label}} · fichas{{end}}
{{define "content"}}
<header fichas-appbar>
- <h1 fichas-appbar-title>Add a customer</h1>
+ <h1 fichas-appbar-title>Add a {{lower .Type.Label}}</h1>
</header>
<form method="post" action="/cards" fichas-form>
- {{range $i, $f := .Fields}}
- {{if eq $f.Def.Kind "enum"}}
- {{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label
- "Options" (options $f.Options ""))}}
- {{else if eq $f.Def.Kind "date"}}
- {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label)}}
- {{else if eq $f.Def.Key "f_notes"}}
- {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Rows" 4)}}
- {{else}}
- {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label
- "Autofocus" (eq $i 0))}}
- {{end}}
- {{end}}
- {{template "form-foot" (dict "Submit" "Save this customer" "CancelHref" "/" "CancelLabel" "Cancel")}}
+ <input type="hidden" name="type" value="{{.TypeKey}}">
+ {{range $i, $f := .Fields}}{{template "fichas-input" (dict "F" $f "Error" (errFor $.Errors $f.Def.Key) "Autofocus" (eq $i 0))}}{{end}}
+ {{template "form-foot" (dict "Submit" (printf "Save this %s" (lower .Type.Label)) "CancelHref" (printf "/t/%s" .TypeKey) "CancelLabel" "Cancel")}}
</form>
{{end}}
diff --git a/internal/fichas/templates/card_show.html b/internal/fichas/templates/card_show.html
index 1b002f0..f5845b6 100644
--- a/internal/fichas/templates/card_show.html
+++ b/internal/fichas/templates/card_show.html
@@ -12,7 +12,7 @@
<div rst-box-head><h2>Call log</h2></div>
<form method="post" action="/cards/{{$c.Card.ID}}/entries">
{{range $f := .EntryFields}}{{if eq $f.Def.Key "f_body"}}
- {{template "field-textarea" (dict "Name" $f.Def.Key "Label" "Log a call" "Rows" 2)}}
+ {{template "field-textarea" (dict "Name" $f.Def.Key "Label" "Log a call" "Rows" 2 "Value" $f.Value "Error" (errFor $.Errors $f.Def.Key) "Hint" "⌘↩ to save")}}
{{end}}{{end}}
{{template "form-foot" (dict "Submit" "Log this call")}}
</form>
@@ -33,10 +33,10 @@
<div rst-box-head><h2>Details</h2></div>
{{template "detail-list" (dict "Items" $c.DetailItems)}}
</div>
- {{with ($c.Field "f_notes").Value}}
+ {{range $c.LongFields}}
<div rst-box>
- <div rst-box-head><h2>Notes</h2></div>
- <p>{{.}}</p>
+ <div rst-box-head><h2>{{.Label}}</h2></div>
+ <p fichas-prose>{{.Value}}</p>
</div>
{{end}}
</aside>
diff --git a/internal/fichas/templates/field_edit.html b/internal/fichas/templates/field_edit.html
new file mode 100644
index 0000000..07b2343
--- /dev/null
+++ b/internal/fichas/templates/field_edit.html
@@ -0,0 +1,18 @@
+{{define "title"}}Edit {{.Field.Label}} · fichas{{end}}
+{{define "content"}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>Edit {{.Field.Label}} <span>{{.Type.Plural}}</span></h1>
+</header>
+<form method="post" action="/fields/{{.Field.Def.Key}}" fichas-form>
+ {{template "field" (dict "ID" "label" "Name" "label" "Label" "Field name" "Value" .Label "Required" true "Error" (errFor .Errors "label"))}}
+ {{if eq .Field.Def.Kind "enum"}}
+ {{template "field-textarea" (dict "Name" "options" "Label" "Options" "Value" .Options "Rows" 6 "Hint" "one per line" "Error" (errFor .Errors "options"))}}
+ <p fichas-meta>Removing an option doesn't change cards that already have it — they keep showing their value.</p>
+ {{end}}
+ {{template "detail-list" (dict "Items" (list
+ (dict "Label" "Kind" "Value" .KindWords)
+ (dict "Label" "Findable by exact match" "Value" (yesNo (eq (printf "%s" .Field.Def.Tier) "print")))))}}
+ <p fichas-meta>Kind and findability are fixed once a field exists: both describe how its values are stored.</p>
+ {{template "form-foot" (dict "Submit" "Save" "CancelHref" (printf "/t/%s/fields" .TypeKey) "CancelLabel" "Cancel")}}
+</form>
+{{end}}
diff --git a/internal/fichas/templates/field_new.html b/internal/fichas/templates/field_new.html
new file mode 100644
index 0000000..53f21dd
--- /dev/null
+++ b/internal/fichas/templates/field_new.html
@@ -0,0 +1,23 @@
+{{define "title"}}Add a field · fichas{{end}}
+{{define "content"}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>Add a field <span>to {{.Type.Plural}}</span></h1>
+</header>
+<form method="post" action="/t/{{.TypeKey}}/fields" fichas-form>
+ {{template "field" (dict "ID" "label" "Name" "label" "Label" "Field name" "Value" (index .Form "label") "Placeholder" "e.g. Contract value" "Required" true "Autofocus" true "Error" (errFor .Errors "label"))}}
+ {{template "choice-field" (dict "Legend" "What kind of value" "Name" "kind" "Options" (list
+ (dict "Value" "text" "Title" "Text" "Desc" "A name, an address, a short note." "Checked" (eq (index .Form "kind") "text"))
+ (dict "Value" "longtext" "Title" "Long text" "Desc" "Paragraphs." "Checked" (eq (index .Form "kind") "longtext"))
+ (dict "Value" "number" "Title" "Number" "Checked" (eq (index .Form "kind") "number"))
+ (dict "Value" "date" "Title" "Date" "Checked" (eq (index .Form "kind") "date"))
+ (dict "Value" "enum" "Title" "Choice" "Desc" "One of a list you set — like Stage." "Checked" (eq (index .Form "kind") "enum"))))}}
+ {{with errFor .Errors "kind"}}<p rst-field-error role="alert">{{.}}</p>{{end}}
+ {{template "field-textarea" (dict "Name" "options" "Label" "Options, for a Choice" "Value" (index .Form "options") "Rows" 4 "Hint" "one per line" "Error" (errFor .Errors "options"))}}
+ {{template "choice-field" (dict "Legend" "Can fichas check it for duplicates?" "Name" "tier" "Options" (list
+ (dict "Value" "sealed" "Title" "No — keep it private" "Desc" "Encrypted, readable only when a card is open. It can still be searched, sorted and grouped inside fichas. Right for almost everything." "Checked" (eq (index .Form "tier") "sealed"))
+ (dict "Value" "print" "Title" "Yes — findable by exact match" "Desc" "fichas can spot two cards with the same value without opening them. For emails, phone numbers and domains — never for a short list of options, where the matches would give the values away." "Checked" (eq (index .Form "tier") "print"))))}}
+ {{with errFor .Errors "tier"}}<p rst-field-error role="alert">{{.}}</p>{{end}}
+ {{template "callout" (dict "Tone" "neutral" "Title" "Kind and this choice can't be changed later" "Body" "Both describe how the values are stored, and changing either would mean re-encrypting every one. The name and a Choice's options can always be edited. Adding a field never touches existing cards.")}}
+ {{template "form-foot" (dict "Submit" "Add field" "CancelHref" (printf "/t/%s/fields" .TypeKey) "CancelLabel" "Cancel")}}
+</form>
+{{end}}
diff --git a/internal/fichas/templates/fields.html b/internal/fichas/templates/fields.html
new file mode 100644
index 0000000..a952ad8
--- /dev/null
+++ b/internal/fichas/templates/fields.html
@@ -0,0 +1,27 @@
+{{define "title"}}{{.Type.Plural}} fields · fichas{{end}}
+{{define "content"}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>Fields <span>on every {{lower .Type.Label}}</span></h1>
+ <div fichas-appbar-actions>
+ <a rst-btn href="/t/{{.TypeKey}}">Back to {{lower .Type.Plural}}</a>
+ <a rst-btn="primary" href="/t/{{.TypeKey}}/fields/new">{{icon "plus"}} Add a field</a>
+ </div>
+</header>
+
+<div fichas-grid-wrap>
+<table fichas-grid>
+ <thead><tr><th scope="col">Field</th><th scope="col">Kind</th><th scope="col">Findable by exact match</th><th scope="col"><span class="rst-sr-only">Actions</span></th></tr></thead>
+ <tbody>
+ {{range .Rows}}
+ <tr>
+ <td>{{.Label}}{{if .Title}} <span fichas-meta>· the card's title</span>{{end}}</td>
+ <td>{{.Kind}}{{with .Options}} <span fichas-meta>· {{len .}} options</span>{{end}}</td>
+ <td>{{if .Findable}}Yes{{else}}<span fichas-empty-cell>No — private</span>{{end}}</td>
+ <td><a fichas-link href="/fields/{{.Key}}/edit">Edit</a></td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+</div>
+<p fichas-meta>Every value is encrypted. "Findable" fields also keep a fingerprint, so fichas can spot two cards with the same email or phone without opening them.</p>
+{{end}}
diff --git a/internal/fichas/templates/index.html b/internal/fichas/templates/index.html
index 3a13b75..390c47c 100644
--- a/internal/fichas/templates/index.html
+++ b/internal/fichas/templates/index.html
@@ -2,22 +2,24 @@
{{define "content"}}
<header fichas-appbar>
<h1 fichas-appbar-title>{{.Result.Plural}}{{if .Result.All}} <span>{{commas .Result.All}} {{if eq .Result.All 1}}record{{else}}records{{end}}</span>{{end}}</h1>
- <div fichas-appbar-actions><a rst-btn="primary" href="/cards/new">{{icon "plus"}} Add a {{lower .Result.Label}}</a></div>
+ <div fichas-appbar-actions><a rst-btn="primary" href="/cards/new?type={{.TypeKey}}">{{icon "plus"}} Add a {{lower .Result.Label}}</a></div>
</header>
{{if .Result.All}}
-<div fichas-toolbar>
+<div fichas-toolbar id="list-toolbar" data-live-region>
{{template "seg-tabs" (dict "Label" (printf "View %s as" .Result.Plural) "Items" (list
(dict "Label" "Table" "Href" .TableHref "Current" (eq .View "table"))
(dict "Label" "Cards" "Href" .CardsHref "Current" (eq .View "cards"))))}}
+ {{if eq .View "cards"}}<a rst-btn href="{{.Base}}/layout">{{icon "layout-grid"}} Arrange cards</a>
+ {{else}}<a rst-btn href="{{.Base}}/fields">{{icon "pencil"}} Fields</a>{{end}}
</div>
<div rst-lbar data-live-search>
- {{template "list-bar-search" (dict "Action" "/" "Query" .Q "Placeholder" (printf "Search %s" (lower .Result.Plural)) "Hidden" .Hidden "ClearHref" .ClearHref)}}
- {{range .Filters}}{{template "dropdown" .}}{{end}}
+ {{template "list-bar-search" (dict "Action" .Base "Query" .Q "Placeholder" (printf "Search %s" (lower .Result.Plural)) "Hidden" .Hidden "ClearHref" .ClearHref)}}
+ <div id="list-filters" data-live-region>{{range .Filters}}{{template "dropdown" .}}{{end}}</div>
</div>
-<div id="results" data-live-results aria-live="polite">
+<div id="results" data-live-results data-live-region>
{{if .Cards}}
{{if eq .View "table"}}
<div fichas-grid-wrap>
@@ -28,8 +30,8 @@
<tbody>
{{range $c := .Cards}}
<tr>
- {{range $f := $.Fields}}{{if not (isLong $f.Def)}}{{$v := ($c.Field $f.Def.Key).Value}}
- {{if eq $f.Def.Key "f_name"}}<td><a href="/cards/{{$c.Card.ID}}">{{$c.Title}}</a></td>
+ {{range $f := $c.Fields}}{{if not (isLong $f.Def)}}{{$v := $f.Value}}
+ {{if $c.IsTitle $f.Def.Key}}<td><a href="/cards/{{$c.Card.ID}}">{{$c.Title}}</a></td>
{{else if not $v}}<td><span fichas-empty-cell>—</span></td>
{{else if eq $f.Def.Kind "enum"}}<td>{{template "status-pill" (dict "Label" $v "Tone" (stageTone $v))}}</td>
{{else if eq $f.Def.Kind "date"}}<td><time datetime="{{$v}}" title="{{$v}}">{{dateAgo $v}}</time></td>
@@ -43,17 +45,22 @@
</div>
{{else}}
<ul fichas-card-grid>
- {{range .Cards}}
+ {{range $c := .Cards}}{{$face := $c.Face $.Face}}
<li><article rst-card>
- <h2 fichas-card-title><a href="/cards/{{.Card.ID}}">{{.Title}}</a>{{with (.Field "f_stage").Value}}{{template "status-pill" (dict "Label" . "Tone" (stageTone .))}}{{end}}</h2>
- {{with (.Field "f_email").Value}}<p fichas-meta>{{.}}</p>{{end}}
- {{with .Latest}}{{with .Body}}<p fichas-card-quote>{{.}}</p>{{end}}{{end}}
- <p fichas-meta>{{with (.Field "f_next_action").Value}}Next action <time datetime="{{.}}" title="{{.}}">{{dateAgo .}}</time>{{else}}{{with .Latest}}Last call <time title="{{.Entry.CreatedAt.Format "2 January 2006"}}">{{ago .Entry.CreatedAt}}</time>{{else}}No calls yet{{end}}{{end}}</p>
+ <h2 fichas-card-title><a href="/cards/{{$c.Card.ID}}">{{$c.Title}}</a>{{with $face.Pill}}{{template "status-pill" (dict "Label" . "Tone" (stageTone .))}}{{end}}</h2>
+ {{range $face.Lines}}
+ {{if eq .Kind "call"}}<p fichas-card-quote>{{.Value}}</p>
+ {{else if eq .Kind "date"}}<p fichas-meta>{{.Label}} <time datetime="{{.Date}}" title="{{.Date}}">{{dateAgo .Date}}</time></p>
+ {{else if eq .Kind "longtext"}}<p fichas-meta fichas-clamp>{{.Value}}</p>
+ {{else}}<p fichas-meta>{{.Value}}</p>{{end}}
+ {{end}}
</article></li>
{{end}}
</ul>
{{end}}
- <p rst-count-line>{{.Count}}</p>
+ {{/* aria-live on the count, not the region: every swap would otherwise
+ re-announce a whole table to a screen reader. */}}
+ <p rst-count-line aria-live="polite">{{.Count}}</p>
{{with .Pages}}{{template "pagination" (dict "Items" . "Label" "Pages of results")}}{{end}}
{{else}}
{{template "empty-state" (dict
@@ -66,8 +73,9 @@
{{else}}
{{template "empty-state" (dict
"Title" (printf "No %s yet" (lower .Result.Plural))
- "Body" "A customer card holds what you know about one prospect, and every call you log against them. Start with the one you spoke to most recently."
- "ActionHref" "/cards/new"
+ "Body" (printf "Each %s is a card: what you know about one of them, and everything you log against it." (lower .Result.Label))
+ "ActionHref" (printf "/cards/new?type=%s" .TypeKey)
"ActionLabel" (printf "Add a %s" (lower .Result.Label)))}}
+<p><a fichas-link href="{{.Base}}/fields">Set up its fields first</a></p>
{{end}}
{{end}}
diff --git a/internal/fichas/templates/partials.html b/internal/fichas/templates/partials.html
new file mode 100644
index 0000000..7f74b26
--- /dev/null
+++ b/internal/fichas/templates/partials.html
@@ -0,0 +1,17 @@
+{{/* Shared app partials, parsed into every shell page. Not named with a
+ leading underscore: go:embed silently skips such files, and the first
+ version of this file was never in the binary. */}}
+{{/* fichas-input — one card field as a form control, chosen by kind, with
+ its error beside it. Keys: F (a FieldView), Error (string), Autofocus. */}}
+{{define "fichas-input"}}
+{{- $f := .F -}}
+{{- if eq $f.Def.Kind "enum" -}}
+ {{template "field-select" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Options" (options $f.Options $f.Value) "Error" .Error)}}
+{{- else if eq $f.Def.Kind "date" -}}
+ {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Error" .Error)}}
+{{- else if isLong $f.Def -}}
+ {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Rows" 4 "Error" .Error)}}
+{{- else -}}
+ {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label "Value" $f.Value "Error" .Error "Autofocus" .Autofocus)}}
+{{- end -}}
+{{end}}
diff --git a/internal/fichas/templates/shell.html b/internal/fichas/templates/shell.html
index f27585c..e6aa12d 100644
--- a/internal/fichas/templates/shell.html
+++ b/internal/fichas/templates/shell.html
@@ -32,7 +32,8 @@
<a rst-shell-brand href="/"><span estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="">fichas</span></a>
<nav rst-shell-nav>
{{range .Nav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{icon "users"}} {{.Label}}</a>
-{{end}}</nav>
+{{end}}<a href="/types/new">{{icon "plus"}} New type</a>
+</nav>
<div rst-shell-rail-foot><form method="post" action="/signout"><span fichas-meta>{{.Me}}</span> <button rst-btn="ghost" type="submit">Sign out</button></form></div>
</aside>
<main rst-shell-main id="main">
diff --git a/internal/fichas/templates/type_new.html b/internal/fichas/templates/type_new.html
new file mode 100644
index 0000000..618ab6b
--- /dev/null
+++ b/internal/fichas/templates/type_new.html
@@ -0,0 +1,12 @@
+{{define "title"}}New type · fichas{{end}}
+{{define "content"}}
+<header fichas-appbar>
+ <h1 fichas-appbar-title>New type <span>a new kind of card</span></h1>
+</header>
+<form method="post" action="/types" fichas-form>
+ {{template "field" (dict "ID" "label" "Name" "label" "Label" "One of them is called" "Value" (index .Form "label") "Placeholder" "Supplier" "Required" true "Autofocus" true "Error" (errFor .Errors "label"))}}
+ {{template "field" (dict "ID" "plural" "Name" "plural" "Label" "A list of them is called" "Value" (index .Form "plural") "Placeholder" "Suppliers" "Hint" "optional" "Error" (errFor .Errors "plural"))}}
+ <p fichas-meta>It starts with one field, Name. You'll add the rest next.</p>
+ {{template "form-foot" (dict "Submit" "Create type" "CancelHref" "/" "CancelLabel" "Cancel")}}
+</form>
+{{end}}
diff --git a/internal/fichastest/browser_test.go b/internal/fichastest/browser_test.go
index a18490a..d87d020 100644
--- a/internal/fichastest/browser_test.go
+++ b/internal/fichastest/browser_test.go
@@ -91,7 +91,7 @@ func TestLiveSearch(t *testing.T) {
})
var marker bool
- var results, location string
+ var results, location, stageHref, cardsHref string
rig.Run(
chromedp.Navigate(rig.Origin+"/"),
chromedp.WaitVisible(`input[name="q"]`),
@@ -101,7 +101,17 @@ func TestLiveSearch(t *testing.T) {
chromedp.Text(`[data-live-results]`, &results),
chromedp.Evaluate(`window.__noReload === true`, &marker),
chromedp.Evaluate(`location.search`, &location),
+ chromedp.Evaluate(`(document.querySelector('#list-filters a[href*="f_stage="]')||{}).getAttribute ? document.querySelector('#list-filters a[href*="f_stage="]').getAttribute('href') : ''`, &stageHref),
+ chromedp.Evaluate(`(function(){var a=[...document.querySelectorAll('#list-toolbar a')].find(function(x){return x.textContent.trim()==='Cards'});return a?a.getAttribute('href'):''})()`, &cardsHref),
)
+ // The tabs and filter menus must carry the TYPED search, not the one the
+ // page loaded with — or choosing a filter throws the search away.
+ if !strings.Contains(stageHref, "q=halden") {
+ t.Fatalf("after typing, the Stage filter links to %q, dropping the search", stageHref)
+ }
+ if !strings.Contains(cardsHref, "q=halden") {
+ t.Fatalf("after typing, the Cards tab links to %q, dropping the search", cardsHref)
+ }
if !marker {
t.Fatal("typing reloaded the page; live.js did not take over the search")
}
@@ -152,3 +162,27 @@ func itoa(n int64) string {
}
return string(b)
}
+
+// TestAnExpiredSessionIsNotADeadEnd: when the list answer is a redirect to
+// sign-in, typing must land on sign-in rather than silently doing nothing.
+func TestAnExpiredSessionIsNotADeadEnd(t *testing.T) {
+ rig := browserApp(t, func(ctx context.Context, d *db.DB, cfg fichas.Config) {
+ m, _ := fichas.EnsureMember(ctx, d.G, cfg.Sealer, TestOwner)
+ fichas.CreateCard(ctx, d.G, cfg.Sealer, fichas.TypeCustomer, fichas.Person(m.ID),
+ map[string]string{fichas.FieldName: "Brightwater Labs"})
+ })
+ var path string
+ rig.Run(
+ chromedp.Navigate(rig.Origin+"/"),
+ chromedp.WaitVisible(`input[name="q"]`),
+ // Point the form at a URL that redirects, as an expired session's
+ // list request does, and type.
+ chromedp.Evaluate(`document.querySelector('[data-live-search] form').setAttribute('action', '/cards/new?type=nope')`, nil),
+ chromedp.SendKeys(`input[name="q"]`, "x"),
+ chromedp.Sleep(900*time.Millisecond),
+ chromedp.Evaluate(`location.pathname`, &path),
+ )
+ if path == "/" {
+ t.Fatal("a non-list answer left the page where it was; the search box is a dead end")
+ }
+}
diff --git a/internal/fichastest/designer_test.go b/internal/fichastest/designer_test.go
new file mode 100644
index 0000000..945f4d9
--- /dev/null
+++ b/internal/fichastest/designer_test.go
@@ -0,0 +1,117 @@
+package fichastest
+
+import (
+ "html"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestAddingAFieldPutsItEverywhere(t *testing.T) {
+ h := newApp(t)
+ createACustomer(t, h, "Cnoc Teoranta")
+
+ rec := post(t, h, "/t/t_customer/fields", url.Values{"label": {"Contract value"}, "kind": {"number"}, "tier": {"sealed"}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("adding a field: %d\n%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(get(t, h, "/t/t_customer/fields").Body.String(), "Contract value") {
+ t.Error("the field is not on the fields page")
+ }
+ if !strings.Contains(get(t, h, "/").Body.String(), ">Contract value<") {
+ t.Error("the field is not a column in the table")
+ }
+ if !strings.Contains(get(t, h, "/cards/new?type=t_customer").Body.String(), "Contract value") {
+ t.Error("the field is not on the new-customer form")
+ }
+}
+
+// A refusal comes back to the same form, with the message and what was typed.
+func TestARefusedFieldKeepsTheForm(t *testing.T) {
+ h := newApp(t)
+ rec := post(t, h, "/t/t_customer/fields", url.Values{
+ "label": {"Region"}, "kind": {"enum"}, "tier": {"print"}, "options": {"North\nSouth"}})
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("a findable choice: status %d, want 422", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "give the values away") {
+ t.Error("the refusal does not say why")
+ }
+ if !strings.Contains(body, `value="Region"`) || !strings.Contains(body, "North") {
+ t.Error("the form lost what was typed")
+ }
+}
+
+func TestANewTypeIsSomewhereToGo(t *testing.T) {
+ h := newApp(t)
+ rec := post(t, h, "/types", url.Values{"label": {"Supplier"}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("creating a type: %d\n%s", rec.Code, rec.Body.String())
+ }
+ fields := rec.Header().Get("Location")
+ m := regexp.MustCompile(`^/t/(t_[0-9a-f]+)/fields$`).FindStringSubmatch(fields)
+ if m == nil {
+ t.Fatalf("created a type and went to %q", fields)
+ }
+ key := m[1]
+ if !strings.Contains(get(t, h, "/").Body.String(), "Suppliers") {
+ t.Error("the new type is not in the sidebar")
+ }
+ list := get(t, h, "/t/"+key)
+ if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "No suppliers yet") {
+ t.Fatalf("the new type's list: %d", list.Code)
+ }
+ // Its one field is Name, with a generated key; find it on the form.
+ form := get(t, h, "/cards/new?type="+key).Body.String()
+ fk := regexp.MustCompile(`name="(f_[0-9a-f]+)"`).FindStringSubmatch(form)
+ if fk == nil {
+ t.Fatalf("no name field on the new-supplier form:\n%s", form)
+ }
+ rec = post(t, h, "/cards", url.Values{"type": {key}, fk[1]: {"Harbour Timber"}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("adding a supplier: %d\n%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(get(t, h, "/t/"+key).Body.String(), "Harbour Timber") {
+ t.Error("the supplier is not on the suppliers list")
+ }
+}
+
+func TestTheCardLayoutChangesTheCards(t *testing.T) {
+ h := newApp(t)
+ // Not ana@example.com: that is the signed-in address, printed in the
+ // sidebar on every page, so its absence could never be asserted.
+ post(t, h, "/cards", url.Values{"f_name": {"Cnoc Teoranta"}, "f_email": {"orders@cnoc.example"}, "f_phone": {"+353 1 555 0100"}})
+ // Unescaped: html/template writes "+" as &#43;, so a raw match on a
+ // phone number can never succeed — which is how this test first failed.
+ before := html.UnescapeString(get(t, h, "/?view=cards").Body.String())
+ if strings.Contains(before, "+353 1 555 0100") || !strings.Contains(before, "orders@cnoc.example") {
+ t.Fatal("the default Customer face is not what this test assumes; it would prove nothing")
+ }
+ rec := post(t, h, "/t/t_customer/layout", url.Values{"face": {"f_phone"}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("saving the layout: %d", rec.Code)
+ }
+ after := html.UnescapeString(get(t, h, "/?view=cards").Body.String())
+ if !strings.Contains(after, "+353 1 555 0100") || strings.Contains(after, "orders@cnoc.example") {
+ t.Error("the saved layout did not change what the cards show")
+ }
+}
+
+// A bad value comes back beside the field, not as a 500 — and keeps the rest.
+func TestABadValueIsExplainedNotA500(t *testing.T) {
+ h := newApp(t)
+ rec := post(t, h, "/cards", url.Values{"f_name": {"Cnoc Teoranta"}, "f_next_action": {"next tuesday"}})
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("a bad date: status %d, want 422", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Enter a date.") {
+ t.Error("the bad date has no message")
+ }
+ if !strings.Contains(body, `value="Cnoc Teoranta"`) {
+ t.Error("the form lost the name that was typed")
+ }
+}