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:
133
modules/cabana/example_controller_test.go
Normal file
133
modules/cabana/example_controller_test.go
Normal 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{} },
|
||||
}}
|
||||
}
|
||||
103
modules/cabana/example_test.go
Normal file
103
modules/cabana/example_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
9
modules/cabana/testdata/docs/controllers/posts/config_filter.yaml
vendored
Normal file
9
modules/cabana/testdata/docs/controllers/posts/config_filter.yaml
vendored
Normal 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
|
||||
10
modules/cabana/testdata/docs/controllers/posts/config_form.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/controllers/posts/config_form.yaml
vendored
Normal 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
|
||||
15
modules/cabana/testdata/docs/controllers/posts/config_list.yaml
vendored
Normal file
15
modules/cabana/testdata/docs/controllers/posts/config_list.yaml
vendored
Normal 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
|
||||
10
modules/cabana/testdata/docs/models/post/columns.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/models/post/columns.yaml
vendored
Normal 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
|
||||
28
modules/cabana/testdata/docs/models/post/fields.yaml
vendored
Normal file
28
modules/cabana/testdata/docs/models/post/fields.yaml
vendored
Normal 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
|
||||
10
modules/cabana/testdata/docs/models/settings/fields.yaml
vendored
Normal file
10
modules/cabana/testdata/docs/models/settings/fields.yaml
vendored
Normal 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
|
||||
Reference in New Issue
Block a user