Table and cards views, a read view apart from editing, and a markup gate
Built to round 1 of the mockups, in Calma.
The customer list is now two views of one list — a table and cards —
switched by a link (?view=table / ?view=cards) so it works with scripts
off and can be bookmarked. The table's last column is each card's latest
call, loaded as one query for the newest entry per card rather than a
card's whole history. The card page is a read view — details, notes, and
the call log with its add form, since logging a call is adding, not
editing — and editing moved to /cards/{id}/edit, which saves back to the
read view. It used to BE the edit form, so opening a customer put every
field in an input.
App pages use rastrillo's sidebar shell with the card types in the rail;
sign-in and errors keep the plain column, because Calma keeps app chrome
out of the place the suite meets you.
The gate that stops this recurring: TestTemplatesUseOnlyDefinedAttributes
fails on any rst-* or fichas-* attribute a template writes that no
stylesheet defines. It failed first on exactly the bug that shipped —
rst-stack and rst-muted in the index and card pages — and passes now that
they are gone. TestTemplateIconsExist does the same for icon slugs, since
icons.Icon returns "" for one it does not know.
fichas' own rules live in static/fichas.css, under a fichas- prefix. They
cannot go in theme.css, which is estilo's generated theme pinned byte for
byte by TestThemeIsEstilosForFichas.
scripts/preview serves the real app over synthetic data, signed in, on
loopback only, so a screen can be looked at before it ships. Every screen
in this commit was screenshotted through it first.
Not built, pending Paul: adding fields and types from the table view, the
card-layout editor, the written summary, and saved views.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+901
−91
internal/fichas/app.go+1 −0internal/fichas/handlers.go+84 −5internal/fichas/read.go+78 −2internal/fichas/render.go+92 −14internal/fichas/static/fichas.css+226 −0internal/fichas/templates/card_delete.html+4 −2internal/fichas/templates/card_edit.html+27 −0internal/fichas/templates/card_new.html+5 −2internal/fichas/templates/card_show.html+38 −47internal/fichas/templates/index.html+44 −19internal/fichas/templates/shell.html+44 −0internal/fichastest/markup_test.go+87 −0internal/fichastest/surface_test.go+57 −0scripts/preview/main.go+114 −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.220s ? 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.229s ? 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 a89c721..6df16dc 100644 |
| --- a/internal/fichas/app.go |
| +++ b/internal/fichas/app.go |
| @@ -82,6 +82,7 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, *app, error |
| r.Get("/cards/new", a.newCard) |
| r.Post("/cards", a.createCard) |
| r.Get("/cards/{id}", a.showCard) |
| + r.Get("/cards/{id}/edit", a.editCard) |
| r.Post("/cards/{id}", a.updateCard) |
| r.Post("/cards/{id}/entries", a.addEntry) |
| // A destructive action gets its own URL, never a modal. |
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go| index a31aacd..979e212 100644 |
| --- a/internal/fichas/handlers.go |
| +++ b/internal/fichas/handlers.go |
| @@ -109,14 +109,78 @@ func (a *app) authOr500(w http.ResponseWriter, r *http.Request, which authRoute) |
| } |
| } |
| +// NavItem is one entry in the sidebar. |
| +type NavItem struct { |
| + Label, Href string |
| + Current bool |
| +} |
| + |
| +// 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) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + data["Nav"] = nav |
| + me, _ := a.identity(r) |
| + data["Me"] = me |
| + 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 { |
| + 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}) |
| + } |
| + return out, nil |
| +} |
| + |
| +// 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. |
| +func pluralLabel(s string) string { |
| + if s == "" || strings.HasSuffix(s, "s") { |
| + return s |
| + } |
| + return s + "s" |
| +} |
| + |
| +// index is the record list, as a table or as cards — the view switch Paul |
| +// asked for. The view is a query parameter so it is a link, works with |
| +// scripts off, and can be bookmarked. |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| cards, err := ListCards(r.Context(), a.db.G, a.sealer, TypeCustomer) |
| if err != nil { |
| a.fail(w, r, err) |
| return |
| } |
| - render(w, "index", map[string]any{ |
| - "Cards": cards, |
| + fields, err := CardFields(r.Context(), a.db.G, a.sealer, TypeCustomer) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + view := r.URL.Query().Get("view") |
| + if view != "cards" { |
| + view = "table" |
| + } |
| + a.page(w, r, "index", map[string]any{ |
| + "Cards": cards, |
| + "Fields": fields, |
| + "View": view, |
| }) |
| } |
| @@ -126,7 +190,7 @@ func (a *app) newCard(w http.ResponseWriter, r *http.Request) { |
| a.fail(w, r, err) |
| return |
| } |
| - render(w, "card_new", map[string]any{ |
| + a.page(w, r, "card_new", map[string]any{ |
| "Fields": fields, |
| }) |
| } |
| @@ -165,12 +229,27 @@ func (a *app) showCard(w http.ResponseWriter, r *http.Request) { |
| a.fail(w, r, err) |
| return |
| } |
| - render(w, "card_show", map[string]any{ |
| + a.page(w, r, "card_show", map[string]any{ |
| "Card": card, |
| "EntryFields": entryFields, |
| }) |
| } |
| +// editCard is the form, on its own URL. It used to BE the card page, which |
| +// put every field in an input the moment you opened a customer. |
| +func (a *app) editCard(w http.ResponseWriter, r *http.Request) { |
| + id, ok := pathID(w, r) |
| + if !ok { |
| + return |
| + } |
| + card, err := LoadCard(r.Context(), a.db.G, a.sealer, id) |
| + if err != nil { |
| + a.notFoundOrFail(w, r, err) |
| + return |
| + } |
| + a.page(w, r, "card_edit", map[string]any{"Card": card}) |
| +} |
| + |
| func (a *app) updateCard(w http.ResponseWriter, r *http.Request) { |
| id, ok := pathID(w, r) |
| if !ok { |
| @@ -230,7 +309,7 @@ func (a *app) confirmDelete(w http.ResponseWriter, r *http.Request) { |
| a.notFoundOrFail(w, r, err) |
| return |
| } |
| - render(w, "card_delete", map[string]any{"Card": card}) |
| + a.page(w, r, "card_delete", map[string]any{"Card": card}) |
| } |
| func (a *app) deleteCard(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/fichas/read.go b/internal/fichas/read.go| index 4df66ca..c216e08 100644 |
| --- a/internal/fichas/read.go |
| +++ b/internal/fichas/read.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "fmt" |
| "sort" |
| + "time" |
| "amadan.net/oficina/fichas/internal/fichas/seal" |
| "gorm.io/gorm" |
| @@ -34,6 +35,9 @@ type CardView struct { |
| TypeLabel string |
| Fields []FieldView |
| Entries []EntryView |
| + // Latest is the most recent entry, filled by ListCards for the table's |
| + // "Last call" column and the card face. Nil when there is none. |
| + Latest *EntryView |
| } |
| // Title is what a card is called in a list. The first printed text field |
| @@ -163,13 +167,46 @@ func ListCards(ctx context.Context, db *gorm.DB, s *seal.Sealer, typeKey string) |
| byCard[v.CardID][v.FieldDefID] = v.SealedValue |
| } |
| + latest, err := latestEntries(g, s, ids) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| out := make([]CardView, 0, len(cards)) |
| for _, c := range cards { |
| fields, err := fieldViews(s, append([]FieldDef(nil), defs...), byCard[c.ID]) |
| if err != nil { |
| return nil, err |
| } |
| - out = append(out, CardView{Card: c, TypeKey: ct.Key, TypeLabel: typeLabel, Fields: fields}) |
| + out = append(out, CardView{Card: c, TypeKey: ct.Key, TypeLabel: typeLabel, Fields: fields, Latest: latest[c.ID]}) |
| + } |
| + return out, nil |
| +} |
| + |
| +// 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. |
| +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"). |
| + 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 |
| + } |
| } |
| return out, nil |
| } |
| @@ -218,8 +255,12 @@ func LoadCard(ctx context.Context, db *gorm.DB, s *seal.Sealer, cardID int64) (C |
| } |
| func loadEntries(g *gorm.DB, s *seal.Sealer, cardID int64) ([]EntryView, error) { |
| + return loadEntriesWhere(g, s, "card_id = ?", cardID) |
| +} |
| + |
| +func loadEntriesWhere(g *gorm.DB, s *seal.Sealer, where string, arg any) ([]EntryView, error) { |
| var entries []Entry |
| - if err := g.Where("card_id = ?", cardID).Order("created_at desc, id desc").Find(&entries).Error; err != nil { |
| + if err := g.Where(where, arg).Order("created_at desc, id desc").Find(&entries).Error; err != nil { |
| return nil, fmt.Errorf("fichas: loading entries: %w", err) |
| } |
| if len(entries) == 0 { |
| @@ -334,3 +375,38 @@ func memberByAddress(ctx context.Context, db *gorm.DB, s *seal.Sealer, address s |
| } |
| return m, nil |
| } |
| + |
| +// DetailItems is the read view's fact list: every field except the title |
| +// and long text, in order, with "—" for an empty one so the shape of the |
| +// card does not change as it fills in. |
| +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 { |
| + continue |
| + } |
| + v := f.Value |
| + if v == "" { |
| + v = "—" |
| + } else if f.Def.Kind == KindDate { |
| + v = displayDate(v) |
| + } |
| + out = append(out, map[string]any{"Label": f.Label, "Value": v}) |
| + } |
| + return out |
| +} |
| + |
| +// displayDate shows a stored "2026-10-02" as "2 October 2026". |
| +func displayDate(v string) string { |
| + d, err := time.Parse("2006-01-02", v) |
| + if err != nil { |
| + return v |
| + } |
| + return d.Format("2 January 2006") |
| +} |
| + |
| +// EnsureMember is memberByAddress for callers outside the package — the |
| +// preview server, which needs a real member to attribute its seed data to. |
| +func EnsureMember(ctx context.Context, db *gorm.DB, s *seal.Sealer, address string) (Member, error) { |
| + return memberByAddress(ctx, db, s, address) |
| +} |
diff --git a/internal/fichas/render.go b/internal/fichas/render.go| index c30ce8a..bebf9a9 100644 |
| --- a/internal/fichas/render.go |
| +++ b/internal/fichas/render.go |
| @@ -3,8 +3,10 @@ package fichas |
| import ( |
| "bytes" |
| "embed" |
| + "fmt" |
| "html/template" |
| "net/http" |
| + "time" |
| "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/rastrillo/ui" |
| @@ -26,24 +28,28 @@ var assets = rastrillo.NewAssets(appFS) |
| var pages = map[string]*template.Template{} |
| func init() { |
| - for _, name := range []string{"index", "errors", "card_new", "card_show", "card_delete", "signin"} { |
| + // Two shells. The app's own pages use rastrillo's sidebar shell |
| + // (templates/shell.html); sign-in and errors keep the plain column |
| + // (templates/layout.html), because Calma keeps app chrome out of the |
| + // place the suite meets you. Both define "layout". |
| + shells := map[string]string{ |
| + "index": "shell", "card_new": "shell", "card_show": "shell", |
| + "card_edit": "shell", "card_delete": "shell", |
| + "signin": "layout", "errors": "layout", |
| + } |
| + for name, shell := range shells { |
| pages[name] = template.Must( |
| - // ui's partials and their helpers first, so every page can |
| - // call one without registering anything. WithIcons points |
| - // ui's two icon seams at the app's own set |
| - // (internal/fichas/icons, scaffolded by rastrillo new) — |
| - // both of them, always: passing only icon would leave |
| - // iconAssets on the framework default and silently break |
| - // cdn and js delivery. iconAssets is whatever <head> markup |
| - // the chosen delivery needs, empty for the vendored-inline |
| - // default, so the layout calls it unconditionally and |
| - // switching delivery later needs no template edit. |
| template.Must(template.New("layout"). |
| Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| - Funcs(template.FuncMap{"asset": assets.Path, "options": selectOptions}). |
| + Funcs(template.FuncMap{ |
| + "asset": assets.Path, |
| + "options": selectOptions, |
| + "ago": ago, |
| + "dateAgo": dateAgo, |
| + "stageTone": stageTone, |
| + }). |
| ParseFS(ui.Templates(), "*.html")). |
| - // Then the app's own: the shell and this page's body. |
| - ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| + ParseFS(appFS, "templates/"+shell+".html", "templates/"+name+".html")) |
| } |
| } |
| @@ -85,3 +91,75 @@ func selectOptions(values []string, current string) []map[string]any { |
| } |
| return out |
| } |
| + |
| +// ago is a past moment in words, for a <time> whose title carries the |
| +// absolute date (AGENTS.md: dates relative, absolute in a title). |
| +func ago(when time.Time) string { return relative(time.Now(), when) } |
| + |
| +// dateAgo is ago for a stored date value ("2026-10-02"), which can be in |
| +// the future — a next action usually is. Anything that does not parse is |
| +// returned unchanged rather than hidden. |
| +func dateAgo(v string) string { |
| + d, err := time.ParseInLocation("2006-01-02", v, time.Local) |
| + if err != nil { |
| + return v |
| + } |
| + now := time.Now() |
| + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) |
| + days := int(d.Sub(today).Hours() / 24) |
| + switch { |
| + case days == 0: |
| + return "today" |
| + case days == 1: |
| + return "tomorrow" |
| + case days == -1: |
| + return "yesterday" |
| + case days > 1 && days < 14: |
| + return fmt.Sprintf("in %d days", days) |
| + case days < -1 && days > -14: |
| + return fmt.Sprintf("%d days ago", -days) |
| + default: |
| + return d.Format("2 January 2006") |
| + } |
| +} |
| + |
| +func relative(now, when time.Time) string { |
| + d := now.Sub(when) |
| + switch { |
| + case d < time.Minute: |
| + return "just now" |
| + case d < time.Hour: |
| + return plural(int(d.Minutes()), "minute") + " ago" |
| + case d < 24*time.Hour: |
| + return plural(int(d.Hours()), "hour") + " ago" |
| + case d < 14*24*time.Hour: |
| + return plural(int(d.Hours()/24), "day") + " ago" |
| + case d < 60*24*time.Hour: |
| + return plural(int(d.Hours()/24/7), "week") + " ago" |
| + default: |
| + return when.Format("2 January 2006") |
| + } |
| +} |
| + |
| +func plural(n int, unit string) string { |
| + if n == 1 { |
| + return "1 " + unit |
| + } |
| + return fmt.Sprintf("%d %ss", n, unit) |
| +} |
| + |
| +// stageTone maps the seeded Stage values to status-pill tones. The |
| +// partial's own docs put this in the app: resolving a raw value to a tone |
| +// is product logic. A value it does not know is neutral, never hidden. |
| +func stageTone(stage string) string { |
| + switch stage { |
| + case "Proposal": |
| + return "warning" |
| + case "Won": |
| + return "positive" |
| + case "Lost": |
| + return "negative" |
| + default: |
| + return "neutral" |
| + } |
| +} |
diff --git a/internal/fichas/static/fichas.css b/internal/fichas/static/fichas.css| new file mode 100644 |
| index 0000000..2fc5bb8 |
| --- /dev/null |
| +++ b/internal/fichas/static/fichas.css |
| @@ -0,0 +1,226 @@ |
| +/* fichas.css — this app's own CSS, beside estilo's theme. |
| + * |
| + * Everything here is something rastrillo's ui partials do not provide. |
| + * It is prefixed fichas-, never rst-, so the line between the framework's |
| + * vocabulary and this app's additions is visible in the markup itself. |
| + * theme.css is estilo's generated theme and is pinned byte-for-byte by |
| + * TestThemeIsEstilosForFichas, so nothing of fichas' own goes there. |
| + * TestTemplatesUseOnlyDefinedAttributes pins every rst-* and fichas-* |
| + * attribute a template writes to a rule in one of the app's stylesheets — |
| + * the check that would have caught rst-stack and rst-muted, the two |
| + * invented attributes that shipped the first index with a bullet point and |
| + * an underlined purple link. |
| + * |
| + * Only tokens.css custom properties are used, so the Calma theme and the |
| + * light/dark switch apply unchanged. |
| + * |
| + * Calma's rules (estilo AGENTS.md, "Theme"), applied here: |
| + * - Inside an app, only the icons carry colour. Links are text-coloured, |
| + * underlined on hover only; the accent is for the one primary action, |
| + * the current selection's soft tint, and nothing else. |
| + * - White cards on a cool off-white page, 12px shapes, a long low shadow, |
| + * one grey text scale. |
| + * - A list item is a semibold title over a muted line. No bullets. |
| + */ |
| + |
| +/* ---- Quiet secondary text. The thing rst-muted was pretending to be. */ |
| +[fichas-meta] { |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + margin: 0; |
| +} |
| + |
| +/* ---- The table view: a record grid. rastrillo has lists, not grids. */ |
| +[fichas-grid-wrap] { |
| + overflow-x: auto; |
| + border: 1px solid var(--rst-line); |
| + border-radius: var(--rst-radius); |
| + background: var(--rst-surface); |
| +} |
| +[fichas-grid] { |
| + inline-size: 100%; |
| + border-collapse: collapse; |
| + font-size: var(--rst-fs-sm); |
| +} |
| +[fichas-grid] th, |
| +[fichas-grid] td { |
| + padding: var(--rst-sp-2) var(--rst-sp-3); |
| + text-align: start; |
| + border-block-end: 1px solid var(--rst-line); |
| + white-space: nowrap; |
| + vertical-align: middle; |
| +} |
| +[fichas-grid] thead th { |
| + position: sticky; |
| + inset-block-start: 0; |
| + background: var(--rst-surface-2); |
| + font-weight: 600; |
| +} |
| +[fichas-grid] th small { |
| + display: block; |
| + font-weight: 400; |
| + color: var(--rst-text-faint); |
| + font-size: var(--rst-fs-xs); |
| +} |
| +[fichas-grid] tbody tr:hover { background: var(--rst-surface-2); } |
| +[fichas-grid] tbody tr:last-child td { border-block-end: 0; } |
| +[fichas-grid] td:first-child a { font-weight: 600; color: var(--rst-text); text-decoration: none; } |
| +[fichas-grid] td:first-child a:hover { text-decoration: underline; } |
| +[fichas-grid] [fichas-add-col] a { color: var(--rst-text-muted); text-decoration: none; font-weight: 500; } |
| +[fichas-grid] [fichas-add-col] a:hover { color: var(--rst-text); } |
| +[fichas-grid] [fichas-empty-cell] { color: var(--rst-text-faint); } |
| + |
| +/* ---- The cards view: a responsive grid of card faces. */ |
| +[fichas-card-grid] { |
| + display: grid; |
| + grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); |
| + gap: var(--rst-sp-4); |
| + list-style: none; |
| + padding: 0; |
| + margin: 0; |
| +} |
| +[fichas-card-grid] > li { display: flex; } |
| +[fichas-card-grid] [rst-card] { |
| + /* rst-card is a bare container by design; rst-box's padding, so a card |
| + face and a box read as the same material. */ |
| + padding: 1.1rem 1.25rem; |
| + box-shadow: var(--rst-shadow-lift); |
| + border-radius: var(--rst-radius); |
| + display: flex; |
| + flex-direction: column; |
| + gap: var(--rst-sp-2); |
| + inline-size: 100%; |
| +} |
| +[fichas-card-title] { |
| + display: flex; |
| + align-items: baseline; |
| + justify-content: space-between; |
| + gap: var(--rst-sp-2); |
| + margin: 0; |
| + font-size: var(--rst-fs-base); |
| +} |
| +[fichas-card-title] a { color: var(--rst-text); text-decoration: none; } |
| +[fichas-card-title] a:hover { text-decoration: underline; } |
| +[fichas-card-quote] { |
| + margin: 0; |
| + padding-inline-start: var(--rst-sp-2); |
| + border-inline-start: 2px solid var(--rst-line-strong); |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| +} |
| + |
| +/* ---- Toolbar row: the view switch on one side, actions on the other. */ |
| +[fichas-toolbar] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + align-items: center; |
| + justify-content: space-between; |
| + gap: var(--rst-sp-3); |
| + margin-block-end: var(--rst-sp-4); |
| +} |
| + |
| +/* ---- Show view: the record's story beside its facts. */ |
| +[fichas-split] { |
| + display: grid; |
| + grid-template-columns: minmax(0, 2fr) minmax(16rem, 1fr); |
| + gap: var(--rst-sp-5); |
| + align-items: start; |
| +} |
| +@media (max-width: 52rem) { [fichas-split] { grid-template-columns: 1fr; } } |
| +/* rst-box brings its own vertical margin; a flex gap on top of it doubled |
| + every space on the show page. Let the boxes space themselves. */ |
| +[fichas-split] > * { min-inline-size: 0; } |
| +[fichas-split] > * > [rst-box]:first-child { margin-block-start: 0; } |
| +[rst-box] form + [fichas-timeline] { margin-block-start: var(--rst-sp-5); } |
| + |
| +[fichas-timeline] { |
| + list-style: none; |
| + padding: 0; |
| + margin: 0; |
| + display: flex; |
| + flex-direction: column; |
| + gap: var(--rst-sp-3); |
| +} |
| +[fichas-timeline] > li { |
| + display: grid; |
| + grid-template-columns: 7.5rem 1fr; |
| + gap: var(--rst-sp-3); |
| +} |
| +[fichas-timeline] time { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); } |
| +[fichas-timeline] p { margin: 0; } |
| + |
| +[fichas-cite] { |
| + font-size: var(--rst-fs-xs); |
| + vertical-align: super; |
| + color: var(--rst-text-muted); |
| + text-decoration: none; |
| +} |
| +[fichas-cite]:hover { color: var(--rst-text); text-decoration: underline; } |
| + |
| +/* ---- Card layout editor: options beside a live preview. */ |
| +[fichas-layout-editor] { |
| + display: grid; |
| + grid-template-columns: minmax(0, 1fr) minmax(17rem, 22rem); |
| + gap: var(--rst-sp-5); |
| + align-items: start; |
| +} |
| +@media (max-width: 52rem) { [fichas-layout-editor] { grid-template-columns: 1fr; } } |
| + |
| +/* ---- A destructive action set apart at the foot of an edit page. */ |
| +[fichas-danger-zone] { |
| + margin-block-start: var(--rst-sp-6); |
| + padding-block-start: var(--rst-sp-4); |
| + border-block-start: 1px solid var(--rst-line); |
| +} |
| + |
| +/* ---- Calma's app bar: icon + app name + quiet context, one action. */ |
| +[fichas-appbar] { |
| + display: flex; |
| + align-items: center; |
| + justify-content: space-between; |
| + gap: var(--rst-sp-3); |
| + margin-block-end: var(--rst-sp-5); |
| +} |
| +[fichas-appbar-title] { |
| + display: flex; |
| + align-items: baseline; |
| + gap: var(--rst-sp-2); |
| + margin: 0; |
| + font-size: 1.5rem; |
| + font-weight: 700; |
| + letter-spacing: -0.01em; |
| +} |
| +[fichas-appbar-title] span { |
| + font-size: var(--rst-fs-base); |
| + font-weight: 400; |
| + color: var(--rst-text-muted); |
| + letter-spacing: 0; |
| +} |
| +[fichas-appbar-actions] { display: flex; gap: var(--rst-sp-2); } |
| + |
| +/* ---- Links in running text: text-coloured, underline on hover. */ |
| +[fichas-link] { color: var(--rst-text); text-decoration: none; } |
| +[fichas-link]:hover { text-decoration: underline; } |
| + |
| +/* ---- The record bar sits under the view switch, full width, with room. */ |
| +[fichas-toolbar] + [rst-lbar] { margin-block-end: var(--rst-sp-4); } |
| + |
| +/* ---- Sidebar icons on the text's centre line, not above its baseline. */ |
| +[rst-shell-nav] a { display: flex; align-items: center; gap: var(--rst-sp-2); } |
| +[rst-shell-nav] a .icon { flex-shrink: 0; } |
| + |
| +/* ---- A many-of-many choice as a quiet checklist. The choice-card |
| + component tints every checked option with the accent, which is right |
| + for one-of-few and far too loud for five-of-eight under Calma. */ |
| +[fichas-checklist] { |
| + border: 0; |
| + padding: 0; |
| + margin: 0 0 var(--rst-sp-4); |
| + display: flex; |
| + flex-direction: column; |
| + gap: var(--rst-sp-2); |
| +} |
| +[fichas-checklist] legend { font-weight: 600; margin-block-end: var(--rst-sp-2); } |
| + |
| +/* ---- A form's measure: fields read as a column, not a banner. */ |
| +[fichas-form] { max-inline-size: 40rem; } |
diff --git a/internal/fichas/templates/card_delete.html b/internal/fichas/templates/card_delete.html| index fd085f2..fa6d5e8 100644 |
| --- a/internal/fichas/templates/card_delete.html |
| +++ b/internal/fichas/templates/card_delete.html |
| @@ -1,6 +1,8 @@ |
| {{define "content"}} |
| {{$c := .Card}} |
| -{{template "page-header" (dict "Title" "Delete this customer?")}} |
| +<header fichas-appbar> |
| + <h1 fichas-appbar-title>Delete this customer?</h1> |
| +</header> |
| {{template "callout" (dict |
| "Tone" "warning" |
| @@ -12,5 +14,5 @@ |
| "Label" "Delete this customer" |
| "Danger" true)}} |
| -<p><a href="/cards/{{$c.Card.ID}}">Keep it</a></p> |
| +<p><a fichas-link href="/cards/{{$c.Card.ID}}">Keep it</a></p> |
| {{end}} |
diff --git a/internal/fichas/templates/card_edit.html b/internal/fichas/templates/card_edit.html| new file mode 100644 |
| index 0000000..70177ef |
| --- /dev/null |
| +++ b/internal/fichas/templates/card_edit.html |
| @@ -0,0 +1,27 @@ |
| +{{define "title"}}Edit {{.Card.Title}} · fichas{{end}} |
| +{{define "content"}} |
| +{{$c := .Card}} |
| +<header fichas-appbar> |
| + <h1 fichas-appbar-title>Edit {{$c.Title}} <span>{{$c.TypeLabel}}</span></h1> |
| +</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}} |
| + {{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> |
| + <p fichas-meta>Opens a confirmation page. The change log keeps who deleted it.</p> |
| +</div> |
| +{{end}} |
diff --git a/internal/fichas/templates/card_new.html b/internal/fichas/templates/card_new.html| index dc402c3..5879ec4 100644 |
| --- a/internal/fichas/templates/card_new.html |
| +++ b/internal/fichas/templates/card_new.html |
| @@ -1,7 +1,10 @@ |
| +{{define "title"}}Add a customer · fichas{{end}} |
| {{define "content"}} |
| -{{template "page-header" (dict "Title" "Add a customer")}} |
| +<header fichas-appbar> |
| + <h1 fichas-appbar-title>Add a customer</h1> |
| +</header> |
| -<form method="post" action="/cards"> |
| +<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 |
diff --git a/internal/fichas/templates/card_show.html b/internal/fichas/templates/card_show.html| index 0c43ed1..1b002f0 100644 |
| --- a/internal/fichas/templates/card_show.html |
| +++ b/internal/fichas/templates/card_show.html |
| @@ -1,53 +1,44 @@ |
| +{{define "title"}}{{.Card.Title}} · fichas{{end}} |
| {{define "content"}} |
| {{$c := .Card}} |
| -{{template "page-header" (dict "Title" $c.Title "Sub" $c.TypeLabel)}} |
| +<header fichas-appbar> |
| + <h1 fichas-appbar-title>{{$c.Title}} <span>{{$c.TypeLabel}} · updated <time title="{{$c.Card.UpdatedAt.Format "2 January 2006, 15:04"}}">{{ago $c.Card.UpdatedAt}}</time></span></h1> |
| + <div fichas-appbar-actions><a rst-btn="primary" href="/cards/{{$c.Card.ID}}/edit">{{icon "pencil"}} Edit</a></div> |
| +</header> |
| -<form method="post" action="/cards/{{$c.Card.ID}}"> |
| - {{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}} |
| - {{template "form-foot" (dict "Submit" "Save changes" "CancelHref" "/" "CancelLabel" "Cancel")}} |
| -</form> |
| - |
| -<h2>Call log</h2> |
| +<div fichas-split> |
| + <section> |
| + <div rst-box> |
| + <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)}} |
| + {{end}}{{end}} |
| + {{template "form-foot" (dict "Submit" "Log this call")}} |
| + </form> |
| + {{if $c.Entries}} |
| + <ol fichas-timeline> |
| + {{range $c.Entries}} |
| + <li><time datetime="{{.Entry.CreatedAt.Format "2006-01-02"}}" title="{{.Entry.CreatedAt.Format "2 January 2006, 15:04"}}">{{ago .Entry.CreatedAt}}</time><p>{{.Body}}</p></li> |
| + {{end}} |
| + </ol> |
| + {{else}} |
| + <p fichas-meta>No calls logged yet. Each one you add here builds this customer's history.</p> |
| + {{end}} |
| + </div> |
| + </section> |
| -<form method="post" action="/cards/{{$c.Card.ID}}/entries"> |
| - {{range $f := .EntryFields}} |
| - {{if eq $f.Def.Kind "date"}} |
| - {{template "field-date" (dict "Name" $f.Def.Key "Label" $f.Label)}} |
| - {{else if eq $f.Def.Key "f_body"}} |
| - {{template "field-textarea" (dict "Name" $f.Def.Key "Label" $f.Label "Rows" 3)}} |
| - {{else}} |
| - {{template "field" (dict "ID" $f.Def.Key "Name" $f.Def.Key "Label" $f.Label)}} |
| + <aside> |
| + <div rst-box> |
| + <div rst-box-head><h2>Details</h2></div> |
| + {{template "detail-list" (dict "Items" $c.DetailItems)}} |
| + </div> |
| + {{with ($c.Field "f_notes").Value}} |
| + <div rst-box> |
| + <div rst-box-head><h2>Notes</h2></div> |
| + <p>{{.}}</p> |
| + </div> |
| {{end}} |
| - {{end}} |
| - {{template "form-foot" (dict "Submit" "Log this call")}} |
| -</form> |
| - |
| -{{if $c.Entries}} |
| -<ol rst-stack> |
| - {{range $c.Entries}} |
| - <li rst-card> |
| - {{template "badge" (dict "Label" .TypeLabel)}} |
| - <p>{{.Body}}</p> |
| - <p rst-muted><time datetime="{{.Entry.CreatedAt.Format "2006-01-02"}}" title="{{.Entry.CreatedAt.Format "2 January 2006, 15:04"}}">{{.Entry.CreatedAt.Format "2 January 2006"}}</time></p> |
| - </li> |
| - {{end}} |
| -</ol> |
| -{{else}} |
| -{{template "empty-state" (dict |
| - "Title" "No calls logged" |
| - "Body" "Every call you log here feeds the summary on this card. The form above is the whole of it.")}} |
| -{{end}} |
| - |
| -<p><a rst-btn="danger" href="/cards/{{$c.Card.ID}}/delete">Delete this customer</a></p> |
| + </aside> |
| +</div> |
| {{end}} |
diff --git a/internal/fichas/templates/index.html b/internal/fichas/templates/index.html| index bd246e9..8a74420 100644 |
| --- a/internal/fichas/templates/index.html |
| +++ b/internal/fichas/templates/index.html |
| @@ -1,27 +1,52 @@ |
| +{{define "title"}}Customers · fichas{{end}} |
| {{define "content"}} |
| -{{template "page-header" (dict |
| - "Title" "Customers" |
| - "Sub" "Everything this team is working on, most recently touched first." |
| - "ActionHref" "/cards/new" |
| - "ActionLabel" "Add a customer" |
| - "ActionIcon" "plus")}} |
| - |
| -{{if .Anonymous}}{{template "callout" (dict |
| - "Tone" "warning" |
| - "Title" "This instance cannot name who is writing" |
| - "Body" "Sign-in has not landed yet, so nothing can be saved. The event log records who made every change, and it will not record a guess.")}}{{end}} |
| +<header fichas-appbar> |
| + <h1 fichas-appbar-title>Customers <span>{{len .Cards}} {{if eq (len .Cards) 1}}record{{else}}records{{end}}</span></h1> |
| + <div fichas-appbar-actions><a rst-btn="primary" href="/cards/new">{{icon "plus"}} Add a customer</a></div> |
| +</header> |
| {{if .Cards}} |
| -<ul rst-stack> |
| - {{range .Cards}} |
| - <li rst-card> |
| - <h2><a href="/cards/{{.Card.ID}}">{{.Title}}</a></h2> |
| - {{with (.Field "f_stage").Value}}{{template "status-pill" (dict "Label" . "Tone" "neutral")}}{{end}} |
| - {{with (.Field "f_email").Value}}<p rst-muted>{{.}}</p>{{end}} |
| - {{with (.Field "f_next_action").Value}}<p rst-muted>Next action <time datetime="{{.}}" title="{{.}}">{{.}}</time></p>{{end}} |
| - </li> |
| +<div fichas-toolbar> |
| + {{template "seg-tabs" (dict "Label" "View customers as" "Items" (list |
| + (dict "Label" "Table" "Href" "/?view=table" "Current" (eq .View "table")) |
| + (dict "Label" "Cards" "Href" "/?view=cards" "Current" (eq .View "cards"))))}} |
| +</div> |
| + |
| +{{if eq .View "table"}} |
| +<div fichas-grid-wrap> |
| +<table fichas-grid> |
| + <thead><tr> |
| + {{range .Fields}}{{if ne .Def.Key "f_notes"}}<th scope="col">{{.Label}}</th>{{end}}{{end}} |
| + <th scope="col">Last call</th> |
| + </tr></thead> |
| + <tbody> |
| + {{range $c := .Cards}} |
| + <tr> |
| + {{range $f := $.Fields}}{{if ne $f.Def.Key "f_notes"}}{{$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> |
| + {{else if not $v}}<td><span fichas-empty-cell>—</span></td> |
| + {{else if eq $f.Def.Key "f_stage"}}<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> |
| + {{else}}<td>{{$v}}</td>{{end}} |
| + {{end}}{{end}} |
| + <td>{{with $c.Latest}}<time datetime="{{.Entry.CreatedAt.Format "2006-01-02"}}" title="{{.Entry.CreatedAt.Format "2 January 2006, 15:04"}}">{{ago .Entry.CreatedAt}}</time>{{else}}<span fichas-empty-cell>never</span>{{end}}</td> |
| + </tr> |
| {{end}} |
| + </tbody> |
| +</table> |
| +</div> |
| +{{else}} |
| +<ul fichas-card-grid> |
| +{{range .Cards}} |
| + <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> |
| + </article></li> |
| +{{end}} |
| </ul> |
| +{{end}} |
| {{else}} |
| {{template "empty-state" (dict |
| "Title" "No customers yet" |
diff --git a/internal/fichas/templates/shell.html b/internal/fichas/templates/shell.html| new file mode 100644 |
| index 0000000..feac1fc |
| --- /dev/null |
| +++ b/internal/fichas/templates/shell.html |
| @@ -0,0 +1,44 @@ |
| +{{define "layout"}}<!doctype html> |
| +<html lang="en" dir="ltr"> |
| +<head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>{{block "title" .}}fichas{{end}}</title> |
| +<link rel="icon" type="image/png" sizes="32x32" href="{{asset "static/brand/icon-32.png"}}"> |
| +<link rel="icon" type="image/svg+xml" href="{{asset "static/brand/icon.svg"}}"> |
| +<link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}"> |
| +<link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css"> |
| +<link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/busy.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/fichas.css"}}"> |
| +<script defer src="{{asset "static/rastrillo.js"}}"></script> |
| +<script defer src="{{asset "static/busy.js"}}"></script> |
| +{{iconAssets}} |
| +<script defer src="{{asset "static/select.js"}}"></script> |
| +<script defer src="{{asset "static/datetime.js"}}"></script> |
| +</head> |
| +<body> |
| +{{/* rastrillo's sidebar shell (ui/layouts/sidebar.html), with the app's |
| + own head. Sign-in and errors keep the plain column in layout.html: |
| + Calma keeps app chrome out of the place the suite meets you. */}} |
| +<div rst-shell-sidebar> |
| +<a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a> |
| +<details rst-shell-chrome><summary>{{icon "menu"}}{{T "rastrillo.ui.shell_menu"}}</summary></details> |
| +<aside rst-shell-rail> |
| +<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> |
| +<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"> |
| +<div rst-page> |
| +{{template "content" .}} |
| +</div> |
| +</main> |
| +</div> |
| +</body> |
| +</html> |
| +{{end}} |
diff --git a/internal/fichastest/markup_test.go b/internal/fichastest/markup_test.go| new file mode 100644 |
| index 0000000..5745d82 |
| --- /dev/null |
| +++ b/internal/fichastest/markup_test.go |
| @@ -0,0 +1,87 @@ |
| +package fichastest |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/fichas/internal/fichas/icons" |
| +) |
| + |
| +var ( |
| + tagRe = regexp.MustCompile(`<[a-zA-Z][^>]*>`) |
| + attrRe = regexp.MustCompile(`\s((?:rst|fichas)-[a-z0-9-]+)`) |
| + selectorRe = regexp.MustCompile(`\[((?:rst|fichas)-[a-z0-9-]+)`) |
| + iconRe = regexp.MustCompile(`\{\{-?\s*icon\s+"([a-z0-9-]+)"`) |
| +) |
| + |
| +func read(t *testing.T, path string) string { |
| + t.Helper() |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("reading %s: %v", path, err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// TestTemplatesUseOnlyDefinedAttributes: every rst-* or fichas-* attribute a |
| +// template writes must have a rule in one of the app's stylesheets. |
| +// |
| +// The first index shipped rst-stack and rst-muted, which rastrillo does not |
| +// have. With no rule behind them the browser's defaults showed through — a |
| +// bullet point and an underlined purple link — and nothing failed. Only |
| +// attributes in THIS app's templates are checked: what rastrillo's partials |
| +// emit is the framework's business, and some of those are script hooks. |
| +func TestTemplatesUseOnlyDefinedAttributes(t *testing.T) { |
| + defined := map[string]bool{} |
| + sheets, _ := filepath.Glob("../fichas/static/*.css") |
| + brand, _ := filepath.Glob("../fichas/static/brand/*.css") |
| + for _, css := range append(sheets, brand...) { |
| + for _, m := range selectorRe.FindAllStringSubmatch(read(t, css), -1) { |
| + defined[m[1]] = true |
| + } |
| + } |
| + if !defined["rst-btn"] || !defined["fichas-grid"] { |
| + t.Fatal("the stylesheets were not found or not parsed; this test would pass vacuously") |
| + } |
| + |
| + templates, _ := filepath.Glob("../fichas/templates/*.html") |
| + if len(templates) == 0 { |
| + t.Fatal("no templates found; this test would pass vacuously") |
| + } |
| + var bad []string |
| + for _, f := range templates { |
| + for _, tag := range tagRe.FindAllString(read(t, f), -1) { |
| + for _, m := range attrRe.FindAllStringSubmatch(tag, -1) { |
| + if !defined[m[1]] { |
| + bad = append(bad, filepath.Base(f)+": "+m[1]) |
| + } |
| + } |
| + } |
| + } |
| + sort.Strings(bad) |
| + for _, b := range bad { |
| + t.Errorf("%s has no rule in any stylesheet — an invented attribute renders as browser defaults", b) |
| + } |
| +} |
| + |
| +// TestTemplateIconsExist: icons.Icon returns "" for a slug it does not know, |
| +// so a missing icon renders as nothing. Six of the first seven the mockups |
| +// used were missing, and nothing noticed until a screenshot. |
| +func TestTemplateIconsExist(t *testing.T) { |
| + templates, _ := filepath.Glob("../fichas/templates/*.html") |
| + var missing []string |
| + for _, f := range templates { |
| + for _, m := range iconRe.FindAllStringSubmatch(read(t, f), -1) { |
| + if icons.Icon(m[1]) == "" { |
| + missing = append(missing, filepath.Base(f)+": "+m[1]) |
| + } |
| + } |
| + } |
| + if len(missing) > 0 { |
| + t.Errorf("icon slugs not in internal/fichas/icons:\n %s", strings.Join(missing, "\n ")) |
| + } |
| +} |
diff --git a/internal/fichastest/surface_test.go b/internal/fichastest/surface_test.go| index b0f1d12..b31cb85 100644 |
| --- a/internal/fichastest/surface_test.go |
| +++ b/internal/fichastest/surface_test.go |
| @@ -136,3 +136,60 @@ func TestNoPageNeedsJavaScript(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The card page used to BE the edit form: opening a customer put every |
| +// field in an input. Reading and editing are separate views now. |
| +func TestTheCardPageIsReadOnly(t *testing.T) { |
| + h := newApp(t) |
| + path := createACustomer(t, h, "Cnoc Teoranta") |
| + body := get(t, h, path).Body.String() |
| + for _, field := range []string{`name="f_name"`, `name="f_email"`, `name="f_stage"`} { |
| + if strings.Contains(body, field) { |
| + t.Errorf("the read view has an input for %s", field) |
| + } |
| + } |
| + if !strings.Contains(body, `href="`+path+`/edit"`) { |
| + t.Error("the read view does not link to editing") |
| + } |
| + // Logging a call stays on the read view: it is adding, not editing. |
| + if !strings.Contains(body, `name="f_body"`) { |
| + t.Error("the read view lost the log-a-call form") |
| + } |
| +} |
| + |
| +func TestEditingHasItsOwnPage(t *testing.T) { |
| + h := newApp(t) |
| + path := createACustomer(t, h, "Cnoc Teoranta") |
| + rec := get(t, h, path+"/edit") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET %s/edit: status %d", path, rec.Code) |
| + } |
| + if !strings.Contains(rec.Body.String(), `value="Cnoc Teoranta"`) { |
| + t.Error("the edit form does not carry the current value") |
| + } |
| + // Saving lands back on the read view, not on the form. |
| + save := post(t, h, path, url.Values{"f_name": {"Cnoc Teoranta"}, "f_notes": {"renewal is due in March"}}) |
| + if loc := save.Header().Get("Location"); loc != path { |
| + t.Errorf("saving redirected to %q, want the read view %q", loc, path) |
| + } |
| +} |
| + |
| +// Table and Cards are two views of one list, switched by a link so it works |
| +// with scripts off and can be bookmarked. |
| +func TestTheListHasTableAndCardsViews(t *testing.T) { |
| + h := newApp(t) |
| + createACustomer(t, h, "Cnoc Teoranta") |
| + table := get(t, h, "/").Body.String() |
| + if !strings.Contains(table, "fichas-grid") || strings.Contains(table, "fichas-card-grid") { |
| + t.Error("the default view is not the table") |
| + } |
| + cards := get(t, h, "/?view=cards").Body.String() |
| + if !strings.Contains(cards, "fichas-card-grid") { |
| + t.Error("?view=cards does not show cards") |
| + } |
| + for _, body := range []string{table, cards} { |
| + if !strings.Contains(body, `href="/?view=table"`) || !strings.Contains(body, `href="/?view=cards"`) { |
| + t.Error("a list view is missing the Table · Cards switch") |
| + } |
| + } |
| +} |
diff --git a/scripts/preview/main.go b/scripts/preview/main.go| new file mode 100644 |
| index 0000000..85da3f3 |
| --- /dev/null |
| +++ b/scripts/preview/main.go |
| @@ -0,0 +1,114 @@ |
| +// Command preview serves the REAL app — real handlers, templates and |
| +// store — over a throwaway database of synthetic customers, already signed |
| +// in, on loopback. It exists so a screen can be looked at before it ships: |
| +// the first index went live with a bullet point and an underlined link |
| +// that no test could see. |
| +// |
| +// go run ./scripts/preview # then open http://127.0.0.1:8746/ |
| +// |
| +// Signed in through AppForTest, which says what it does in its name; this |
| +// binary binds 127.0.0.1 only and refuses to run otherwise. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "fmt" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + |
| + fichas "amadan.net/oficina/fichas/internal/fichas" |
| + "github.com/carlosframework/rastrillo/db" |
| +) |
| + |
| +const ( |
| + addr = "127.0.0.1:8746" |
| + owner = "paul@example.com" |
| +) |
| + |
| +func main() { |
| + dir, err := os.MkdirTemp("", "fichas-preview-") |
| + check(err) |
| + defer os.RemoveAll(dir) |
| + |
| + dek := make([]byte, 32) |
| + _, err = rand.Read(dek) |
| + check(err) |
| + env := map[string]string{ |
| + "FICHAS_DEK": base64.StdEncoding.EncodeToString(dek), |
| + "FICHAS_INSTANCE_KEY": "preview", |
| + "FICHAS_OWNERS": owner, |
| + "FICHAS_ORIGIN": "http://" + addr, |
| + "FICHAS_ALLOW_LOGGED_MAIL": "1", |
| + } |
| + cfg, err := fichas.LoadConfig(func(k string) string { return env[k] }) |
| + check(err) |
| + |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(filepath.Join(dir, "preview.db"), logger) |
| + check(err) |
| + mux, err := fichas.AppForTest(d, cfg, logger, owner) |
| + check(err) |
| + check(seed(d, cfg)) |
| + |
| + fmt.Println("fichas preview on http://" + addr + "/ (synthetic data, signed in as " + owner + ")") |
| + check(http.ListenAndServe(addr, mux)) |
| +} |
| + |
| +// seed writes synthetic customers through the real store, so the preview |
| +// exercises the same write path — events and all — that production does. |
| +// .example is RFC 2606's reserved TLD. |
| +func seed(d *db.DB, cfg fichas.Config) error { |
| + ctx := context.Background() |
| + m, err := fichas.EnsureMember(ctx, d.G, cfg.Sealer, owner) |
| + if err != nil { |
| + return err |
| + } |
| + me := fichas.Person(m.ID) |
| + type call struct{ body string } |
| + customers := []struct { |
| + values map[string]string |
| + calls []string |
| + }{ |
| + {map[string]string{fichas.FieldName: "Brightwater Labs", fichas.FieldDomain: "brightwater.example", |
| + fichas.FieldEmail: "ana@brightwater.example", fichas.FieldPhone: "+353 1 555 0142", |
| + fichas.FieldStage: "Proposal", fichas.FieldNextAction: "2026-10-02", |
| + fichas.FieldNotes: "Renewal is tied to their grant cycle. Ana is the budget holder; Tom signs."}, |
| + []string{"Met at the conference. Asked for a follow-up after their board meets.", |
| + "Intro call. Current tool is a shared spreadsheet; they lose track of follow-ups.", |
| + "Walked Ana through the proposal. She wants the pilot scoped to one department first."}}, |
| + {map[string]string{fichas.FieldName: "Cedar & Pike Architects", fichas.FieldDomain: "cedarpike.example", |
| + fichas.FieldEmail: "office@cedarpike.example", fichas.FieldStage: "Lead"}, nil}, |
| + {map[string]string{fichas.FieldName: "Halden Robotics", fichas.FieldDomain: "halden.example", |
| + fichas.FieldEmail: "ops@halden.example", fichas.FieldPhone: "+44 20 5550 1877", |
| + fichas.FieldStage: "Contacted", fichas.FieldNextAction: "2026-09-30"}, |
| + []string{"Left a voicemail; followed up by email."}}, |
| + {map[string]string{fichas.FieldName: "Oriel Housing Trust", fichas.FieldDomain: "oriel.example", |
| + fichas.FieldEmail: "it@oriel.example", fichas.FieldStage: "Won"}, |
| + []string{"Signed. Rollout starts next month."}}, |
| + } |
| + for _, c := range customers { |
| + card, err := fichas.CreateCard(ctx, d.G, cfg.Sealer, fichas.TypeCustomer, me, c.values) |
| + if err != nil { |
| + return err |
| + } |
| + for _, body := range c.calls { |
| + if _, err := fichas.AddEntry(ctx, d.G, cfg.Sealer, card.ID, me, fichas.TypeCallLog, "manual", |
| + map[string]string{fichas.FieldBody: body}); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func check(err error) { |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, "preview:", err) |
| + os.Exit(1) |
| + } |
| +} |