feat(12.1-02): read-only preview screen with a status hint and record actions

- config_form.yaml preview block (optional headerPartial), reported in the form schema as preview
- fields with context: preview show only on the preview screen and are never written
- form messages preview and edit; recordActions without a preview block stops boot
- SPA route {id}/preview, PreviewView and PreviewField, record actions in the footer
- mapWinterUrl maps preview/:id; the update form returns to the preview
- summer-callout partial style classes for status hints
- README, docs, OpenAPI document, TS types and the embedded build updated
This commit is contained in:
Jakub Zych
2026-10-05 00:10:48 +02:00
parent 1c99de5013
commit a65c670574
50 changed files with 1441 additions and 66 deletions

View File

@@ -31,12 +31,14 @@ const rosterDir = "testdata/roster"
// rosterPerson is the fixture model: a person of one tenant who can be
// active, banned and soft-deleted.
type rosterPerson struct {
ID uint `gorm:"column:id;primaryKey"`
Tenant string `gorm:"column:tenant"`
Name string `gorm:"column:name"`
Email string `gorm:"column:email"`
Active bool `gorm:"column:active"`
Banned bool `gorm:"column:banned"`
ID uint `gorm:"column:id;primaryKey"`
Tenant string `gorm:"column:tenant"`
Name string `gorm:"column:name"`
Email string `gorm:"column:email"`
Active bool `gorm:"column:active"`
Banned bool `gorm:"column:banned"`
// JoinedIP is shown on the preview screen only (context: preview).
JoinedIP *string `gorm:"column:joined_ip"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
@@ -178,6 +180,35 @@ func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, record
return out, nil
}
// rosterStatus is the curated view model of the preview status hint: the
// callout tone and the phrase keys of its title and text. It is empty when
// no state applies, and the template then renders nothing.
type rosterStatus struct {
Tone, Title, Text string
}
// PartialData serves the preview header partial `status`: one callout by
// precedence banned, archived, not active.
func (rosterController) PartialData(_ context.Context, name string, record any) (any, error) {
if name != "status" {
return nil, fmt.Errorf("unknown partial %s", name)
}
person, ok := record.(*rosterPerson)
if !ok || person == nil {
return rosterStatus{}, nil
}
const keys = "acme.roster::lang.people."
switch {
case person.Banned:
return rosterStatus{Tone: "danger", Title: keys + "banned_title", Text: keys + "banned_text"}, nil
case person.DeletedAt.Valid:
return rosterStatus{Tone: "danger", Title: keys + "deleted_title", Text: keys + "deleted_text"}, nil
case !person.Active:
return rosterStatus{Tone: "warning", Title: keys + "inactive_title", Text: keys + "inactive_text"}, nil
}
return rosterStatus{}, nil
}
// rosterLocked is the sentinel name of a person the roster's actions refuse.
const rosterLocked = "Locked"
@@ -381,6 +412,46 @@ func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
return env, gdb
}
// rosterTree is the roster fixture tree as an in-memory file system with the
// given files replaced or added (boot-error tests).
func rosterTree(t *testing.T, replace map[string]string) fstest.MapFS {
t.Helper()
out := fstest.MapFS{}
err := filepath.WalkDir(rosterDir, func(name string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
data, err := os.ReadFile(name)
if err != nil {
return err
}
rel, err := filepath.Rel(rosterDir, name)
if err != nil {
return err
}
out[filepath.ToSlash(rel)] = &fstest.MapFile{Data: data}
return nil
})
if err != nil {
t.Fatal(err)
}
for name, body := range replace {
out[name] = &fstest.MapFile{Data: []byte(body)}
}
return out
}
// rosterBoot activates the roster plugin over fsys and returns the boot error.
func rosterBoot(t *testing.T, fsys fs.FS) error {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
return err
}
// rosterInsert stores one person and returns its id.
func rosterInsert(t *testing.T, gdb *gorm.DB, person rosterPerson) uint {
t.Helper()