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:
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user