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>
28 files changed,
+1720
−138
internal/fichas/app.go+10 −0internal/fichas/catalogue.go+13 −1internal/fichas/handlers.go+125 −40internal/fichas/handlers_schema.go+253 −0internal/fichas/index.go+85 −1internal/fichas/index_test.go+82 −1internal/fichas/list.go+3 −0internal/fichas/read.go+95 −22internal/fichas/render.go+21 −1internal/fichas/schema.go+441 −0internal/fichas/schema_test.go+185 −0internal/fichas/static/fichas.css+13 −0internal/fichas/static/live.js+48 −21internal/fichas/store.go+25 −0internal/fichas/templates/card_delete.html+2 −2internal/fichas/templates/card_edit.html+2 −12internal/fichas/templates/card_layout.html+34 −0internal/fichas/templates/card_new.html+5 −16internal/fichas/templates/card_show.html+4 −4internal/fichas/templates/field_edit.html+18 −0internal/fichas/templates/field_new.html+23 −0internal/fichas/templates/fields.html+27 −0internal/fichas/templates/index.html+23 −15internal/fichas/templates/partials.html+17 −0internal/fichas/templates/shell.html+2 −1internal/fichas/templates/type_new.html+12 −0internal/fichastest/browser_test.go+35 −1internal/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 +, 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") |
| + } |
| +} |