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:
138
modules/cabana/phase121_form_test.go
Normal file
138
modules/cabana/phase121_form_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// rosterFormHead is the smallest config_form.yaml of the roster fixture.
|
||||
const rosterFormHead = "form: ~/plugins/acme/roster/models/person/fields.yaml\nmodelClass: Person\n"
|
||||
|
||||
// rosterFormSchema fetches the people form schema as auth sees it.
|
||||
func rosterFormSchema(t *testing.T, env *rosterEnv, auth string) (cabana.FormView, string) {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/form", "", auth)
|
||||
var body cabana.Envelope[cabana.FormView]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("form schema: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
return body.Data, rec.Body.String()
|
||||
}
|
||||
|
||||
// rosterRecord decodes a record response.
|
||||
func rosterRecord(t *testing.T, raw []byte) cabana.RecordEnvelope {
|
||||
t.Helper()
|
||||
var body cabana.RecordEnvelope
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("record body %s: %v", raw, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// rosterBootFails asserts that the roster plugin with the replaced files does
|
||||
// not boot and that the error carries every wanted part.
|
||||
func rosterBootFails(t *testing.T, replace map[string]string, want ...string) {
|
||||
t.Helper()
|
||||
err := rosterBoot(t, rosterTree(t, replace))
|
||||
if err == nil {
|
||||
t.Fatalf("the plugin booted, want an error naming %q", want)
|
||||
}
|
||||
for _, part := range want {
|
||||
if !strings.Contains(err.Error(), part) {
|
||||
t.Fatalf("error %q does not name %q", err, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewSmoke drives the preview context through the assembled router on
|
||||
// PostgreSQL (D-11; T-12.1-14, T-12.1-15): the schema's preview block and
|
||||
// messages, a preview-only field that is shown and never written, the status
|
||||
// hint through the partial route with the form scope, and the boot rules.
|
||||
func TestPreviewSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
ip := "203.0.113.7"
|
||||
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test", Active: true, JoinedIP: &ip})
|
||||
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea", Active: true, Banned: true})
|
||||
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Banned: true})
|
||||
record := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
|
||||
|
||||
t.Run("schema reports the preview, its messages and the preview-only field", func(t *testing.T) {
|
||||
view, raw := rosterFormSchema(t, env, "bearer")
|
||||
if view.Preview == nil || view.Preview.HeaderPartial != "status" {
|
||||
t.Fatalf("preview = %+v", view.Preview)
|
||||
}
|
||||
if !strings.Contains(raw, `"preview":{"headerPartial":"status"}`) {
|
||||
t.Fatalf("preview block is not in the schema: %s", raw)
|
||||
}
|
||||
if view.Messages.Preview["other"] != "Person details" || view.Messages.Edit["other"] != "Edit person" {
|
||||
t.Fatalf("messages = %+v", view.Messages)
|
||||
}
|
||||
if !strings.Contains(raw, `"name":"joined_ip","type":"text","label":"Joined from IP address","context":"preview"`) {
|
||||
t.Fatalf("preview-only field is not in the schema: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, `"redirectClose":"acme/roster/people/preview/:id"`) {
|
||||
t.Fatalf("redirects do not point at the preview: %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a preview-only field is shown and never written", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, record(ada), "", "bearer")
|
||||
if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip {
|
||||
t.Fatalf("show joined_ip = %v", got)
|
||||
}
|
||||
rec = env.expect(t, http.StatusOK, http.MethodPut, record(ada), `{"name":"Ada L","joined_ip":"198.51.100.1"}`, "bearer")
|
||||
if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip {
|
||||
t.Fatalf("update answered joined_ip = %v", got)
|
||||
}
|
||||
stored := rosterLoad(t, gdb, ada)
|
||||
if stored.Name != "Ada L" || stored.JoinedIP == nil || *stored.JoinedIP != ip {
|
||||
t.Fatalf("stored = %+v ip=%v", stored, stored.JoinedIP)
|
||||
}
|
||||
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2"}`, "bearer")
|
||||
created := rosterRecord(t, rec.Body.Bytes())
|
||||
id, _ := created.Data["id"].(float64)
|
||||
if got := rosterLoad(t, gdb, uint(id)); got.JoinedIP != nil {
|
||||
t.Fatalf("create wrote joined_ip = %q", *got.JoinedIP)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the status hint renders through the partial route in the form scope", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, banned), "", "bearer")
|
||||
body := rec.Body.String()
|
||||
for _, part := range []string{`"class":"summer-callout summer-callout--danger"`, `"role":"status"`, `"class":"summer-callout__title"`, "This person is banned", "A banned person cannot sign in until the ban is lifted."} {
|
||||
if !strings.Contains(body, part) {
|
||||
t.Fatalf("hint %s does not carry %s", body, part)
|
||||
}
|
||||
}
|
||||
// No state applies: zero nodes, so the screen renders no hint.
|
||||
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, ada), "", "bearer")
|
||||
var view cabana.Envelope[cabana.PartialView]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil || len(view.Data.Nodes) != 0 {
|
||||
t.Fatalf("hint for an active person = %s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
// Another tenant's person is outside the form scope.
|
||||
rec = env.expect(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, foreign), "", "bearer")
|
||||
actErrorCode(t, rec.Body.Bytes(), "not_found")
|
||||
})
|
||||
|
||||
t.Run("boot rules", func(t *testing.T) {
|
||||
const form = "controllers/people/config_form.yaml"
|
||||
rosterBootFails(t, map[string]string{form: rosterFormHead + "recordActions: [activate]\n"},
|
||||
"recordActions needs a preview block (record actions are offered on the preview screen)", "acme.roster.people", form)
|
||||
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n"},
|
||||
"preview must be a mapping; write preview: {} to enable the preview screen without a header partial", form)
|
||||
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview: true\n"}, "preview must be a mapping")
|
||||
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n toolbar: x\n"}, "preview: unknown field toolbar")
|
||||
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n headerPartial: $/acme/status.htm\n"}, "headerPartial", "path must be a partial name")
|
||||
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n headerPartial: missing\n"}, "partial missing", "controllers/people/_missing.htm")
|
||||
// preview: {} enables the screen without a hint.
|
||||
if err := rosterBoot(t, rosterTree(t, map[string]string{form: rosterFormHead + "preview: {}\nrecordActions: [activate]\n"})); err != nil {
|
||||
t.Fatalf("preview: {} did not boot: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user