feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links

- docs/backend: admin controllers, forms, lists and filters, relation
  manager, users and permissions, settings, partials and widgets, admin SPA
- docs/services: storage, outbound HTTP, realtime, Web Push, search, parity
  testing and the Frontend and AJAX (not provided) page
- Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and
  its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse
  and beachcomber TestDocs* regions run on their Postgres harnesses
- concept map rows link their guide pages and the not-provided rows the
  Frontend and AJAX page; index lists Backend, Database and Services
- TestDocsRequiredPages asserts the D-08 section order
This commit is contained in:
Jakub Zych
2026-09-30 23:18:35 +02:00
parent f7dfe68707
commit 44bd1446f5
40 changed files with 2882 additions and 30 deletions

View File

@@ -0,0 +1,133 @@
package cabana_test
import (
"io/fs"
"os"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Post is the model behind the acme.blog posts controller.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Status string `gorm:"column:status"`
Published bool `gorm:"column:published"`
PublishedAt *time.Time `gorm:"column:published_at"`
Content string `gorm:"column:content"`
}
func (Post) TableName() string { return "acme_blog_posts" }
// Fillable lists the columns the admin form may write.
func (Post) Fillable() []string {
return []string{"title", "slug", "status", "published", "content"}
}
// PostsController is the admin controller for posts: the Go form of a
// WinterCMS controller with the List and Form behaviours.
type PostsController struct{}
var (
_ pact.AdminController = PostsController{}
_ pact.AdminRecordSource = PostsController{}
_ pact.AdminPermissioned = PostsController{}
_ pact.HasAdminControllers = (*BlogPlugin)(nil)
)
// ID is the controller ID; its admin API path is /acme/blog/posts.
func (PostsController) ID() string { return "acme.blog.posts" }
// ModelName must equal modelClass in the YAML.
func (PostsController) ModelName() string { return "Post" }
// ConfigDir holds config_list.yaml, config_form.yaml and config_filter.yaml.
func (PostsController) ConfigDir() string { return "controllers/posts" }
// NewRecord returns the model the generic admin handlers query.
func (PostsController) NewRecord() any { return &Post{} }
// RequiredPermissions are checked before any schema or query.
func (PostsController) RequiredPermissions() []string {
return []string{"acme.blog.access_posts"}
}
// BlogSettings is the singleton row behind the plugin's settings page.
type BlogSettings struct {
ID uint `gorm:"column:id;primaryKey"`
PostsPerPage int `gorm:"column:posts_per_page"`
CommentsEnabled bool `gorm:"column:comments_enabled"`
}
func (BlogSettings) TableName() string { return "acme_blog_settings" }
func (BlogSettings) Fillable() []string { return []string{"posts_per_page", "comments_enabled"} }
// Rules validates a settings save, as a WinterCMS settings model's $rules.
func (BlogSettings) Rules() map[string]string {
return map[string]string{"posts_per_page": "required|integer|between:1,100"}
}
// BlogPlugin is the acme.blog plugin; only its backend surface is shown.
type BlogPlugin struct{}
var (
_ pact.AdminAssets = (*BlogPlugin)(nil)
_ pact.HasPermissions = (*BlogPlugin)(nil)
_ pact.HasNavigation = (*BlogPlugin)(nil)
_ pact.HasSettings = (*BlogPlugin)(nil)
)
func (p *BlogPlugin) ID() string { return "acme.blog" }
func (p *BlogPlugin) Requires() []string { return nil }
func (p *BlogPlugin) Register(app *backpack.App) error { return nil }
func (p *BlogPlugin) Boot(app *backpack.App) error { return nil }
// AdminControllers registers the plugin's admin controllers.
func (p *BlogPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{PostsController{}}
}
// AdminFS is the plugin's embedded controllers/ and models/ tree. A real
// plugin returns an embed.FS; the example reads the same files from testdata.
func (p *BlogPlugin) AdminFS() fs.FS { return os.DirFS("testdata/docs") }
// Permissions replaces registerPermissions().
func (p *BlogPlugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
}
}
// Navigation replaces registerNavigation().
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "blog",
Label: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Permissions: []string{"acme.blog.access_posts"},
Controller: "acme.blog.posts",
SideMenu: []pact.NavigationItem{
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
},
}}
}
// Settings replaces registerSettings().
func (p *BlogPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "blog",
Label: "acme.blog::lang.settings.label",
Description: "acme.blog::lang.settings.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Model: "BlogSettings",
Permissions: []string{"acme.blog.access_settings"},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &BlogSettings{} },
}}
}

View File

@@ -0,0 +1,103 @@
package cabana_test
import (
"fmt"
"os"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/party"
)
func ExampleCompileList() {
// The plugin embeds its controllers/ and models/ trees; the example reads
// the same files from testdata.
fsys := os.DirFS("testdata/docs")
list, err := cabana.CompileList("acme.blog", PostsController{}, fsys)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(list.Title, list.RecordsPerPage, list.PerPageOptions, list.ToolbarButtons)
for _, c := range list.Columns {
fmt.Printf("column %s type=%q searchable=%v sortable=%v\n", c.Key, c.Type, c.Searchable, c.Sortable)
}
for _, f := range list.Filters {
fmt.Printf("filter %s type=%s column=%s\n", f.Name, f.Type, f.Column)
}
// Output:
// acme.blog::lang.posts.title 20 [20 50 100] [create delete]
// column title type="" searchable=true sortable=true
// column published type="switch" searchable=false sortable=true
// column published_at type="datetime" searchable=false sortable=true
// filter published type=switch column=published
// filter published_at type=daterange column=published_at
}
func ExampleCompileForm() {
fsys := os.DirFS("testdata/docs")
form, err := cabana.CompileForm("acme.blog", PostsController{}, fsys)
if err != nil {
fmt.Println(err)
return
}
for _, f := range form.Fields {
fmt.Printf("%s %s span=%q tab=%q required=%v options=%d\n", f.Name, f.Type, f.Span, f.Tab, f.Required, len(f.Options))
}
// Output:
// title text span="left" tab="" required=true options=0
// slug text span="right" tab="" required=false options=0
// status dropdown span="left" tab="" required=false options=2
// published switch span="right" tab="" required=false options=0
// content textarea span="" tab="acme.blog::lang.posts.tab_content" required=false options=0
}
func ExampleActivate() {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
// Set through SUMMER_ADMIN__JWT__SECRET in a real deployment.
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
_ = cfg.Set("backend.uri", "/admin")
routes, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&BlogPlugin{}})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(routes.Prefix)
editor := &bouncer.Principal{ID: 7, Backend: true, PermissionGrants: map[string]bool{"acme.blog.*": true}}
fmt.Println(cabana.Allows(editor, PostsController{}.RequiredPermissions()))
fmt.Println(cabana.Allows(editor, []string{"acme.shop.access_orders"}))
// Output:
// /admin
// true
// false
}
// TestDocsDeclarations checks the plugin declarations the Backend pages show.
func TestDocsDeclarations(t *testing.T) {
p := &BlogPlugin{}
if n := len(p.Permissions()); n != 2 {
t.Fatalf("Permissions() = %d entries", n)
}
if nav := p.Navigation(); len(nav) != 1 || nav[0].Controller != "acme.blog.posts" {
t.Fatalf("Navigation() = %+v", nav)
}
settings := p.Settings()
if len(settings) != 1 {
t.Fatalf("Settings() = %+v", settings)
}
if _, ok := settings[0].NewModel().(*BlogSettings); !ok {
t.Fatal("settings model is not *BlogSettings")
}
if rule := (BlogSettings{}).Rules()["posts_per_page"]; rule == "" {
t.Fatal("BlogSettings has no posts_per_page rule")
}
}

View File

@@ -0,0 +1,9 @@
scopes:
published:
label: acme.blog::lang.posts.published
type: switch
column: published
published_at:
label: acme.blog::lang.posts.published_at
type: daterange
column: published_at

View File

@@ -0,0 +1,10 @@
name: acme.blog::lang.posts.form
form: ~/plugins/acme/blog/models/post/fields.yaml
modelClass: Post
defaultRedirect: acme/blog/posts
create:
redirect: acme/blog/posts/update/:id
redirectClose: acme/blog/posts
update:
redirect: acme/blog/posts
redirectClose: acme/blog/posts

View File

@@ -0,0 +1,15 @@
list: ~/plugins/acme/blog/models/post/columns.yaml
modelClass: Post
title: acme.blog::lang.posts.title
recordUrl: acme/blog/posts/update/:id
recordsPerPage: 20
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: published_at
direction: desc
filter: config_filter.yaml
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt

View File

@@ -0,0 +1,10 @@
columns:
title:
label: acme.blog::lang.posts.title_column
searchable: true
published:
label: acme.blog::lang.posts.published
type: switch
published_at:
label: acme.blog::lang.posts.published_at
type: datetime

View File

@@ -0,0 +1,28 @@
fields:
title:
label: acme.blog::lang.posts.title_column
type: text
span: left
required: true
slug:
label: acme.blog::lang.posts.slug
type: text
span: right
context: update
comment: acme.blog::lang.posts.slug_comment
status:
label: acme.blog::lang.posts.status
type: dropdown
span: left
options:
draft: acme.blog::lang.posts.draft
published: acme.blog::lang.posts.published
published:
label: acme.blog::lang.posts.published
type: switch
span: right
content:
label: acme.blog::lang.posts.content
type: textarea
size: large
tab: acme.blog::lang.posts.tab_content

View File

@@ -0,0 +1,10 @@
fields:
posts_per_page:
label: acme.blog::lang.settings.posts_per_page
type: number
span: left
default: 10
comments_enabled:
label: acme.blog::lang.settings.comments_enabled
type: switch
span: right