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

@@ -17,6 +17,7 @@ import (
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"github.com/goccy/go-yaml/parser"
)
var (
@@ -55,6 +56,68 @@ type formConfigDocument struct {
Update *formRedirects `yaml:"update"`
Messages *formMessageKeys `yaml:"messages"`
RecordActions recordActionList `yaml:"recordActions"`
Preview previewDocument `yaml:"preview"`
}
// previewDocument is the config_form.yaml preview block (D-11): a mapping
// that enables the read-only preview screen, with the optional headerPartial
// naming the status hint partial. The key is never half-set: an empty (null)
// value is refused, and `preview: {}` enables the screen without a hint.
type previewDocument struct {
set bool
headerPartial string
}
// previewShapeHint is the boot error of a preview key that is not a mapping.
const previewShapeHint = "preview must be a mapping; write preview: {} to enable the preview screen without a header partial"
// topLevelKey reports whether the YAML document's root mapping has key.
func topLevelKey(raw []byte, key string) bool {
file, err := parser.ParseBytes(raw, 0)
if err != nil {
return false
}
for _, doc := range file.Docs {
if doc == nil {
continue
}
var entries []*ast.MappingValueNode
switch body := unwrapNode(doc.Body).(type) {
case *ast.MappingNode:
entries = body.Values
case *ast.MappingValueNode:
entries = []*ast.MappingValueNode{body}
}
for _, entry := range entries {
if name, err := nodeString(unwrapNode(entry.Key)); err == nil && name == key {
return true
}
}
}
return false
}
func (p *previewDocument) UnmarshalYAML(node ast.Node) error {
mapping, ok := unwrapNode(node).(*ast.MappingNode)
if !ok {
return fmt.Errorf("%s", previewShapeHint)
}
p.set = true
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return fmt.Errorf("preview: %w", err)
}
if key != "headerPartial" {
return fmt.Errorf("preview: unknown field %s", key)
}
name, err := nodeString(unwrapNode(entry.Value))
if err != nil || !identifier(name) {
return fmt.Errorf("preview: headerPartial %q: %s", nodeText(entry.Value), partialPathHint)
}
p.headerPartial = name
}
return nil
}
// recordActionList is the declarative recordActions list (D-10): the names of
@@ -106,6 +169,11 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
if err := decodeStrict(raw, &doc); err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
// The decoder does not hand an empty (null) value to previewDocument, so
// a half-set `preview:` key is refused here.
if !doc.Preview.set && topLevelKey(raw, "preview") {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("%s", previewShapeHint))
}
if doc.ModelClass != ctl.ModelName() {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
}
@@ -140,6 +208,9 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
recordActions: doc.RecordActions.items,
}
if doc.Preview.set {
schema.preview = &FormPreview{HeaderPartial: doc.Preview.headerPartial}
}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
@@ -186,10 +257,16 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
if fields == nil {
fields = []FormField{}
}
var preview *FormPreview
if s.preview != nil {
copied := *s.preview
preview = &copied
}
return &FormView{
Name: translateKey(ctx, tr, s.Name),
ModelClass: s.ModelClass,
Fields: fields,
Preview: preview,
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
Redirects: s.redirects,
Meta: FormMeta{Locale: locale},