Compare commits

..

10 Commits

Author SHA1 Message Date
Jakub Zych
40a2a6584c docs(quick-261007-qgx): record heading from translatable fields 2026-10-07 19:07:34 +02:00
Jakub Zych
b6b194e5eb fix(boardwalk): rebuild embedded admin shell 2026-10-07 19:07:25 +02:00
Jakub Zych
a714bb4629 fix(admin): name records by translatable (mltext) fields
The record heading, breadcrumb crumb and document title took the first
plain text field only, so records named by an mltext field fell back to
the generic update message. A shared recordDisplayName helper now takes
the first text or ML field and reads its default locale, then the first
non-empty one.
2026-10-07 19:05:47 +02:00
Jakub Zych
7554241e9d docs(planning): log to-many list column fix 2026-10-07 18:43:18 +02:00
Jakub Zych
fa25b4e000 fix(cabana): show to-many relation list columns joined with commas 2026-10-07 18:43:17 +02:00
Jakub Zych
b4cc6f88e4 docs: capture todo - Translate Messages as a Nuxt i18n JSON editor 2026-10-07 18:32:01 +02:00
Jakub Zych
52eef9b43b docs(quick-261007-p4y): toggle sizing and settings controller links 2026-10-07 18:28:54 +02:00
Jakub Zych
0b09acdb0c feat(admin): open controller-link settings entries from the settings index
A settings entry with a controller opens that controller's route from
its card (settingsPath); singleton cards still open /settings/<code>.
On a linked controller with no navigation entry of its own the rail
marks Settings current, the breadcrumbs read Settings > entry label
(plus the record title on record and create routes) and the page title
uses the entry label. The settings docs describe the admin behaviour;
the embedded admin shell is rebuilt.
2026-10-07 18:24:54 +02:00
Jakub Zych
4a9b0896b1 feat(cabana): settings entries that link to an admin controller
pact.SettingsItem gains an additive Controller field, the equivalent of a
WinterCMS registerSettings 'url' => Backend::url(...) entry. A link entry
declares no Model, Form or NewModel and needs no AdminFS; start-up fails
when it combines Controller with a singleton form or a model, or names an
unregistered controller. Settings codes stay one namespace.

GET /settings lists a link entry with its controller only when the
principal passes the item's permissions and may open the controller.
Registry.Setting never returns a link entry, so the singleton settings
endpoints answer 404 for its code. SettingsEntry carries controller,
empty for singletons; the OpenAPI document, generated SPA types and
settings fixture follow, and the pact and cabana READMEs and the
settings docs describe the link.
2026-10-07 18:22:01 +02:00
Jakub Zych
b040ee3a74 fix(admin): size the switch toggle in pixels at the 14px root
The admin sets the html font size to 14px, so the rem utilities w-11 and
size-5 shrank the track to 38.5px and the knob to 17.5px against the
fixed 26px height and 18px travel. Use w-[44px] and size-[20px] so the
knob sits in a symmetric 3px inset at both ends. Rebuild the embedded
admin shell.
2026-10-07 18:15:41 +02:00
46 changed files with 1190 additions and 63 deletions

View File

@@ -5,10 +5,10 @@ current_phase: 15
current_phase_name: Journal plugin
status: executing
stopped_at: Completed 15-04-PLAN.md
last_updated: "2026-10-06T21:58:51.425Z"
last_activity: 2026-10-06
last_updated: "2026-10-07T16:43:18.298Z"
last_activity: 2026-10-07
last_activity_desc: Completed quick task 261006-s0v (remove ML copy-from locale UI)
state_head: 4a94380b5fe947b657dadd250e1960865ca3285e
state_head: fa25b4e000fac864b949be6daa4f800a4511a458
progress:
total_phases: 26
completed_phases: 12
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 15 (Journal plugin) — EXECUTING
Plan: 4 of 4
Status: Ready for phase verification
Last activity: 2026-10-06 — Completed quick task 261006-tnp: Add mltextarea field type
Last activity: 2026-10-07 — Completed quick task 261007-qgx: record heading from translatable (mltext) fields
Progress: [██████████] 100%
@@ -607,20 +607,20 @@ Recent decisions affecting current work:
### Pending Todos
- [2026-10-01] [docs] Benchmark Płytarium on WinterCMS vs SummerCMS and publish a measured table — [todo file](.planning/todos/pending/2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md)
- [2026-10-07] [sm-translate-plugin] Translate Messages as a Nuxt i18n JSON editor — [todo file](.planning/todos/pending/2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md)
- [unknown] [summercms.go admin auth] Backend admin personal API tokens (deferred Apparatus PersonalApiToken) — [todo file](.planning/todos/pending/backend-admin-api-tokens.md)
- [unknown] [summercms.go bonfire] Reject duplicate console command names in bonfire.NewRoot — [todo file](.planning/todos/pending/bonfire-duplicate-command-names.md)
- [unknown] [summercms.go/fetchguard] Extend fetchguard into a guarded outbound http.Client (replaces Apparatus RequestSender) — [todo file](.planning/todos/pending/fetchguard-guarded-http-client.md)
- [unknown] [summercms.go lagoon] lagoon README registers an after-commit GORM callback that can sort after the flush — [todo file](.planning/todos/pending/lagoon-readme-after-commit-callback-order.md)
- [unknown] [summercms.go lagoon] lagoon.Validate reports every numeric range failure with the max message — [todo file](.planning/todos/pending/lagoon-validate-min-message.md)
- [unknown] [sm-user-plugin mail] Port MailBlocker and the block-mail checkbox with the mailing work — [todo file](.planning/todos/pending/mailblocker-with-mailing.md)
- [unknown] [summercms.go] Nest framework packages under modules/ — same beach names — [todo file](.planning/todos/pending/nest-framework-packages-under-modules.md)
- [unknown] [summercms.go/modules] Short README.md per modules/* after the nest — [todo file](.planning/todos/pending/per-module-readmes-after-nest.md)
- [unknown] [summercms.go/modules/*/README.md, internal/docsite] Convert module README Go code blocks to compiled src= references — [todo file](.planning/todos/pending/readme-go-fences-src.md)
- [unknown] [summercms.go logging] Framework slog handler that redacts credentials (port RedactCredentialsTap) — [todo file](.planning/todos/pending/redacting-slog-handler.md)
- [unknown] [../fonoteka.go/README.md] Refresh fonoteka.go README — short status plus migrate/serve/login — [todo file](.planning/todos/pending/refresh-fonoteka-readme.md)
- [unknown] [summercms.go/README.md] Rewrite summercms.go README — onboard, recreate login, honest cutover — [todo file](.planning/todos/pending/rewrite-summercms-readme.md)
- [unknown] [summercms.go internal/build] make:admin-controller output does not boot and names the model after the controller — [todo file](.planning/todos/pending/scaffold-admin-controller-incomplete.md)
- [unknown] [summercms.go internal/build] Scaffolded files carry a DO NOT EDIT header, and make:command cannot reach the application — [todo file](.planning/todos/pending/scaffold-generated-header-and-command-deps.md)
- [unknown] [summercms.go internal/build] make:model and make:migration in the same second produce migrations that run out of order — [todo file](.planning/todos/pending/scaffold-same-second-migration-order.md)
- [unknown] [sm-sitemap-plugin] Port the sitemap plugin 1:1 as sm-sitemap-plugin for the next blog project — [todo file](.planning/todos/pending/sitemap-plugin-port.md)
- [unknown] [summercms.go wristband] Neutral default resource URL in wristband.DefaultOptions — [todo file](.planning/todos/pending/wristband-neutral-resource-default.md)
### Blockers/Concerns
@@ -651,6 +651,9 @@ Recent decisions affecting current work:
| 261006-tnp | Add mltextarea field type | 2026-10-06 | 3596df4 | [261006-tnp-add-mltextarea-field-type](./quick/261006-tnp-add-mltextarea-field-type/) |
| 15 | Commit accumulated planning documents properly | 2026-10-06 | c05ad09 | — |
| 16 | Default icon shouldn't be a square but a sun. | 2026-10-06 | 4a94380 | — |
| 261007-p4y | Switch toggle pixel sizing; settings entries linking to admin controllers; translate Locales moved to Settings | 2026-10-07 | 0b09acd | [261007-p4y-toggle-proportions-and-settings-controll](./quick/261007-p4y-toggle-proportions-and-settings-controll/) |
| 18 | cabana list: join to-many relation column values with ", " | 2026-10-07 | fa25b4e | — |
| 261007-qgx | Admin record heading, crumb and title from the first text or mltext field (default locale) | 2026-10-07 | a714bb4 | [261007-qgx-admin-record-heading-uses-translatable-m](./quick/261007-qgx-admin-record-heading-uses-translatable-m/) |
## Deferred Items

View File

@@ -0,0 +1,271 @@
---
phase: quick-261007-p4y
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- admin/src/components/form/fields/SwitchField.vue
- modules/boardwalk/dist/
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/cabana/registry.go
- modules/cabana/settings.go
- modules/cabana/navigation.go
- modules/cabana/contracts.go
- modules/cabana/settings_link_test.go
- modules/cabana/README.md
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/tests/fixtures/settings.json
- admin/src/state/useSettings.ts
- admin/src/views/SettingsIndexView.vue
- admin/src/components/shell/PluginRail.vue
- admin/src/components/shell/Breadcrumbs.vue
- admin/src/app/pageTitle.ts
- admin/tests/state/useSettings.test.ts
- admin/tests/form/Settings.test.ts
- admin/tests/shell/PluginRail.test.ts
- admin/tests/shell/Breadcrumbs.test.ts
- admin/tests/app/pageTitle.test.ts
- docs/backend/settings.md
- docs/setup/coming-from-wintercms.md
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_navigation.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_settings.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/plugin_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/locales_admin_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/README.md
autonomous: true
requirements: [QUICK-261007-p4y]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 3
confidence: low
must_haves:
truths:
- "The admin switch track is 44px by 26px and its knob 20px whatever the root font size (html is 14px), so the knob travels 18px inside a 3px inset on both ends; the embedded modules/boardwalk/dist matches a fresh build of that change"
- "A pact.SettingsItem with Controller set compiles without Form, NewModel, Model or an AdminFS and is not a singleton; Controller combined with Form, NewModel or Model, or Controller naming an admin controller no plugin registered, stops start-up with a cabana error; settings codes stay unique across singleton and link entries"
- "GET /settings returns a controller-link entry with `controller` set to its controller ID and `model` empty; every singleton entry carries `controller: \"\"`; a link entry is listed only when the principal passes the item's Permissions AND can open the controller (Registry.canOpen); singleton filtering is unchanged"
- "GET /settings/{code}/schema, GET /settings/{code} and PUT /settings/{code} answer 404 not_found for a link entry's code; singleton settings endpoints behave exactly as before"
- "In the admin SPA a settings index card for a link entry navigates to that controller's route (/vendor/plugin/controller, the same derivation navigation uses); a singleton card still goes to /settings/<encoded code>. On a link controller's pages with no main-navigation entry the rail marks Settings current, breadcrumbs read Settings > <entry label> (plus the record title on record and create routes), and the page title uses the entry label"
- "The translate plugin registers no main navigation; its Locales screen appears on the Settings page under category Translate (golem15.translate::lang.plugin.name) as 'Manage languages' (golem15.translate::lang.locale.title), order 550, gated by golem15.translate.manage_locales, linking to golem15.translate.locales, exactly as the PHP plugin's registerSettings entry"
- "admin/openapi/admin.json and admin/src/api/schema.d.ts match a fresh generation; the pact and cabana READMEs and docs/backend/settings.md describe the Controller link; TestDocsTree, go vet ./... and go test ./... are green in summercms.go and in sm-translate-plugin; existing keyed pact.SettingsItem literals in other plugins compile unchanged"
artifacts:
- path: modules/pact/capabilities.go
provides: "SettingsItem.Controller string (additive field), documented as WinterCMS registerSettings 'url' => Backend::url(...)"
- path: modules/cabana/settings.go
provides: "compileSettingLink: exclusivity check (no Form, NewModel or Model) returning a CompiledSetting with nil Form and Writable"
- path: modules/cabana/registry.go
provides: "compileContributions link branch (no AdminFS requirement) and post-collection unknown-controller validation for link entries"
- path: modules/cabana/navigation.go
provides: "SettingsEntry.Controller json:\"controller\"; Metadata filters link entries through Allows and canOpen"
- path: modules/cabana/contracts.go
provides: "Registry.Setting returns false for link entries so every singleton endpoint answers 404"
- path: modules/cabana/settings_link_test.go
provides: "Compile validation, metadata output and permission filtering, and singleton-endpoint 404 tests for link entries"
- path: admin/src/state/useSettings.ts
provides: "settingsPath(entry) and settingsLinkFor(controllerId)"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_settings.go
provides: "Plugin.Settings() returning the single locales link entry"
key_links:
- from: "pact.SettingsItem.Controller"
to: "cabana compileContributions link branch + unknown-controller check"
via: "reg.settings map shared with singletons (one code namespace)"
- from: "cabana.Registry.Metadata"
to: "SettingsIndexView RouterLink"
via: "SettingsEntry.controller -> admin.json -> schema.d.ts -> useSettings.settingsPath"
- from: "cabana.Registry.Setting"
to: "protectSetting in modules/cabana/http.go"
via: "link code is not found -> 404 not_found before any Form access"
- from: "translate Plugin.Settings()"
to: "GET /settings in the translate admin harness"
via: "cabana registry boot with the plugin's controllers"
---
<objective>
Two user-facing fixes in one quick task.
1. The admin toggle (SwitchField) looks too tall for its width. Root cause: admin/src/styles/main.css sets the html font size to 14px, so the Tailwind v4 rem utilities w-11 (38.5px) and size-5 (17.5px) shrink against the fixed h-[26px] track and translate-x-[18px] travel. The fix (w-[44px] and size-[20px]) is ALREADY APPLIED, uncommitted, in admin/src/components/form/fields/SwitchField.vue. It needs its test check, a dist rebuild and its own commit.
2. WinterCMS lets registerSettings point an entry at a backend controller ('url' => Backend::url('golem15/translate/locales')), so the translate plugin's Locales screen lives on the Settings page, not in the main navigation. SummerCMS core only supports singleton-model settings, so the Go translate plugin registers a main navigation item instead. This task adds an additive `Controller` field to pact.SettingsItem with registry validation, metadata output, permission filtering and singleton-endpoint 404s, plus the SPA card link and Settings-aware shell. The translate plugin then switches from Navigation() to Settings().
Purpose: restore the WinterCMS admin shape (Locales under Settings) without breaking the pact contract that many plugins and apps depend on, and fix a visibly wrong control.
Output: three summercms.go commits (switch fix with dist; Go contract with OpenAPI, READMEs and docs; SPA with tests and dist) and one sm-translate-plugin commit.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
Facts established during planning (do not re-derive):
- The SPA builds into modules/boardwalk/dist (admin/vite.config.ts outDir, emptyOutDir). Rebuild with `npm --prefix admin run build` (vue-tsc then vite build). `scripts/check-admin-dist.sh` fails when dist differs from a fresh build. Stage dist with `git add -A modules/boardwalk/dist` so hashed asset renames and deletions are captured (see commits f15b38e and a586357).
- No admin test asserts the switch's w-11 or size-5 classes. tests/form/fields.test.ts and tests/form/registry.test.ts mount SwitchField behaviourally.
- `scripts/check-admin-openapi.sh` (no argument) regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts from the cabana swag annotations. `--check` fails on drift. Every SettingsEntry property is currently `required` in admin.json, so a non-omitempty `json:"controller"` becomes a required string. That matches NavigationEntry.Controller, which is also always present.
- admin/tests/fixtures/typed.ts types settings.json as the generated SettingsEntry. Once `controller` is required, every fixture entry needs `"controller": ""` or `npm --prefix admin run typecheck` fails. Build link entries inline in tests rather than adding them to the shared fixture (existing tests count its entries).
- The registry builds `reg.byID` (compiled controllers) before compileContributions runs, so link validation can check `reg.byID` the same way validateNavigation does (modules/cabana/registry.go ~line 322).
- protectSetting (modules/cabana/http.go ~line 501) resolves the code only through Registry.Setting, so Registry.Setting returning false for a link code turns all three singleton endpoints into 404s. http.go needs no edit.
- Reusable Go fixtures: modules/cabana/metadata_settings_test.go has contributionPlugin, metadataController (requires acme.demo.access), metadataRegistry() (byID has acme.demo.widgets) and metadataPlugin(). HTTP-level settings calls exist in modules/cabana/openapi_conformance_test.go (conformEnv, "GET /settings") and modules/cabana/security_coverage_test.go. Reuse whichever harness mounts the admin API with a backend principal.
- Settings category convention: every Go settings item uses its own plugin phrase key as Category (feedback and fonoteka use lang.plugin.tab, journal uses lang.journal.menu_label). Core has no "System" category key. The PHP reference examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php registerSettings uses category 'golem15.translate::lang.plugin.name', label 'golem15.translate::lang.locale.title', description 'golem15.translate::lang.plugin.description', order 550 and permission golem15.translate.manage_locales, with no keywords. All three keys already exist in sm-translate-plugin/lang/en and lang/pl. Per the API-parity rule, follow the PHP reference and add no core category constant.
- sm-translate-plugin is a separate git repo whose go.mod has `replace git.golem15.com/golem15/summercms => ../summercms.go` (and a go.work with the same replace), so it builds against the local framework. Do not change its go.mod require version. Its admin harness (admin_harness_test.go: newAdminEnv, e.admin(t, permissions...), e.expect(...), adminAPI("/settings")) boots cabana with the plugin and Postgres via testcontainers.
- Consumers of the translate plugin (sm-grzybyfunkcjonalne-app, sm-summercmsio-app) pin it as a submodule. Bumping them is OUT of scope.
Commit rules: one logical change per commit; planning docs never go in a code commit; NO co-author trailers (the user's global CLAUDE.md overrides any attribution reminder); `go vet ./...` and `go test ./...` green at every commit. Do not commit anything under .planning (the orchestrator does).
</context>
<tasks>
<task type="auto">
<name>Task 1: Commit the pre-applied switch proportion fix with its rebuilt dist</name>
<files>admin/src/components/form/fields/SwitchField.vue, modules/boardwalk/dist/</files>
<read_first>admin/src/components/form/fields/SwitchField.vue, scripts/check-admin-dist.sh</read_first>
<action>
This change is already in the working tree. It must land first, alone, so the later SPA commit's dist rebuild does not mix in an unrelated fix.
1. Run `git status --short` in summercms.go. The only modified file must be admin/src/components/form/fields/SwitchField.vue. If anything else is dirty, stop and report it rather than building it into dist.
2. Confirm the track uses w-[44px] with h-[26px], and the knob uses size-[20px] with top-[3px], left-[3px] and translate-x-[18px]. The geometry is 3 + 20 + 18 + 3 = 44, so it is symmetric. Leave the colour, aria and transition classes untouched.
3. Grep admin/tests for any assertion on w-11 or size-5 tied to the switch. Planning found none. If one exists, update it to the pixel classes.
4. Run the switch tests, then `npm --prefix admin run build`, then `scripts/check-admin-dist.sh`.
5. Stage SwitchField.vue and `git add -A modules/boardwalk/dist`. Commit as `fix(admin): size the switch toggle in pixels at the 14px root` with a body explaining that the rem utilities shrank the track and knob under html font-size 14px. Add no co-author trailer.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && grep -q 'w-\[44px\]' admin/src/components/form/fields/SwitchField.vue && grep -q 'size-\[20px\]' admin/src/components/form/fields/SwitchField.vue && npm --prefix admin test -- tests/form/fields.test.ts tests/form/registry.test.ts && scripts/check-admin-dist.sh && go test ./modules/boardwalk -count=1 && test -z "$(git status --short)"</automated>
</verify>
<done>SwitchField uses pixel width and knob sizes, the switch tests pass, dist matches a fresh build, boardwalk tests pass, and one commit holds SwitchField.vue plus modules/boardwalk/dist with no co-author trailer. The working tree is clean afterwards.</done>
</task>
<task type="tracer" tdd="true">
<name>Task 2: Settings entries that link to an admin controller, end to end (pact, cabana, OpenAPI, SPA, dist, docs)</name>
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/settings.go, modules/cabana/registry.go, modules/cabana/navigation.go, modules/cabana/contracts.go, modules/cabana/settings_link_test.go, modules/cabana/README.md, docs/backend/settings.md, docs/setup/coming-from-wintercms.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/tests/fixtures/settings.json, admin/src/state/useSettings.ts, admin/src/views/SettingsIndexView.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/Breadcrumbs.vue, admin/src/app/pageTitle.ts, admin/tests/state/useSettings.test.ts, admin/tests/form/Settings.test.ts, admin/tests/shell/PluginRail.test.ts, admin/tests/shell/Breadcrumbs.test.ts, admin/tests/app/pageTitle.test.ts, modules/boardwalk/dist/</files>
<read_first>modules/pact/capabilities.go (SettingsItem, NavigationItem), modules/cabana/registry.go (compileContributions, validateNavigation), modules/cabana/settings.go (CompiledSetting, compileSetting), modules/cabana/navigation.go (SettingsEntry, Metadata, canOpen), modules/cabana/contracts.go (Registry.Setting), modules/cabana/metadata_settings_test.go (fixtures), admin/src/state/useSettings.ts, admin/src/state/useNavigation.ts, admin/src/app/controllerRoutes.ts, admin/src/views/SettingsIndexView.vue, admin/src/components/shell/PluginRail.vue, admin/src/components/shell/Breadcrumbs.vue, admin/src/app/pageTitle.ts, admin/tests/form/Settings.test.ts, admin/tests/shell/PluginRail.test.ts, admin/tests/shell/Breadcrumbs.test.ts, admin/tests/app/pageTitle.test.ts, docs/backend/settings.md</read_first>
<behavior>
- Go: a link item `{Code: "locales", Controller: "acme.demo.widgets", Permissions: ["acme.demo.manage_settings"]}` compiles from a plugin with no AdminFS. Registry.Setting("locales") returns false.
- Go: Controller plus Form, plus NewModel, or plus Model each fail compileContributions with an error naming the setting code. Controller "acme.demo.missing" fails with "cabana: setting locales references unknown controller acme.demo.missing". A link code equal to a singleton code fails with "duplicate setting".
- Go: Metadata for a developer holding acme.demo.access and acme.demo.manage_settings lists the link entry with Controller "acme.demo.widgets" and Model "", and the singleton entry with Controller "".
- Go: a principal granted only acme.demo.manage_settings (it passes the item but cannot open the controller, which requires acme.demo.access) does not see the link entry, but does see the singleton.
- Go: GET /settings/locales/schema, GET /settings/locales and PUT /settings/locales answer 404 not_found for an allowed backend principal. GET /settings JSON has a "controller" key on every entry.
- SPA: settingsPath returns /settings/<encoded code> for a singleton, /acme/demo/locales for controller acme.demo.locales, and /settings for a malformed controller ID. settingsLinkFor('acme.demo.locales') finds the link entry; settingsLinkFor('') and null find nothing (singleton entries with an empty controller never match).
- SPA: the settings index card for a link entry has an href ending in /acme/demo/locales. Singleton cards are unchanged.
- SPA: on route /acme/demo/locales with no navigation entry for acme.demo, data-rail-settings has aria-current="page" and no rail item is current. Breadcrumbs are Settings (link to /settings) > entry label (current), the record route adds the record title, and the document title starts with the entry label. On a navigation controller route, Settings is not current.
</behavior>
<action>
Write the failing Go tests and SPA tests from the behavior block first, then implement. This tracer wires one settings link through every layer. The behaviour-preserving rule is absolute: keyed pact.SettingsItem literals elsewhere must compile and behave unchanged, and singleton settings must not change.
Go contract, commit A:
- modules/pact/capabilities.go: add `Controller string` to SettingsItem, after Permissions and before Form, with no json tag (like the other exported fields). Its doc comment says: when set, the entry is a link to that admin controller ID (vendor.plugin.controller), the equivalent of WinterCMS registerSettings 'url' => Backend::url(...). A link entry declares no Model, Form or NewModel and is not a singleton. Extend the SettingsItem doc comment to say an item is either a singleton form or a controller link.
- modules/cabana/settings.go: add `compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetting, error)`. It errors when item.Form != "" or item.NewModel != nil, with message "cabana: setting %s links controller %s and declares a singleton form; declare one". It errors when item.Model != "", with message "cabana: setting %s links controller %s and names a model". Otherwise it returns &CompiledSetting{PluginID, Item} with Form and Writable nil. Rejecting Model is a planning discretion choice: a link has no model, so the SPA never sees a misleading model string. Update the CompiledSetting doc comment to say Form and Writable are nil for a link entry.
- modules/cabana/registry.go compileContributions: in the HasSettings loop, keep the identifier and duplicate checks first. If item.Controller != "", call compileSettingLink, store the result in reg.settings[item.Code] and continue. This skips the AdminFS requirement and compileSetting. Otherwise keep the existing path unchanged. In the post-collection loop over reg.settings, after validatePermissions, a link entry whose controller is not in reg.byID returns "cabana: setting %s references unknown controller %s", mirroring validateNavigation.
- modules/cabana/contracts.go Registry.Setting: return (nil, false) when the stored setting's Item.Controller is non-empty. Update the doc comment: link entries are not singletons and are not returned. This alone makes protectSetting answer 404 for all three singleton endpoints. Leave http.go unchanged.
- modules/cabana/navigation.go: add `Controller string` with tag `json:"controller"` (not omitempty, matching NavigationEntry) to SettingsEntry, after Model, with a doc comment ("the admin controller a link entry opens; empty for a singleton settings form"). In Metadata's settings loop, skip an item unless Allows(principal, item.Permissions) holds and also either item.Controller is empty or r.canOpen(principal, item.Controller) is true. Populate Controller in the appended entry.
- modules/cabana/settings_link_test.go (new, package cabana): implement the Go behavior cases, reusing contributionPlugin, metadataRegistry, metadataPlugin and metadataController from metadata_settings_test.go. Add a plugin with nil fsys for the no-AdminFS case. For the 404 and JSON-shape cases, reuse the existing admin HTTP harness (see the Context facts) instead of writing a new one.
- Run `scripts/check-admin-openapi.sh` with no argument to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts. Confirm the only diff is the new controller property, which is required on cabana.SettingsEntry.
- admin/tests/fixtures/settings.json: add `"controller": ""` to every existing list entry so `npm --prefix admin run typecheck` stays green against the regenerated schema.d.ts.
- Docs in the same commit, per the CLAUDE.md documentation rule:
- modules/pact/README.md: extend the SettingsItem row and the Features bullet to cover the controller link.
- modules/cabana/README.md: extend the Features bullet on settings, covering link entries, their validation, the 404 on singleton endpoints and the canOpen filtering. Update any Registry.Setting or SettingsEntry rows.
- docs/backend/settings.md: add a section "Linking a settings entry to an admin controller". Explain the WinterCMS 'url' equivalent and the Controller field, that a link entry declares no Model, Form or NewModel, the start-up validation, the permission plus controller-access filtering and how the SPA opens the controller, with a short Go example using neutral names such as acme.blog. A plain go fence without src= is fine unless TestDocsTree requires otherwise.
- docs/setup/coming-from-wintercms.md: extend the registerSettings row to mention 'url' mapping to the Controller field.
- Name no consuming application. If TestDocsTree rejects a field selector such as pact.SettingsItem.Controller, write "the `Controller` field of `pact.SettingsItem`" instead.
- Commit A, Go plus OpenAPI plus fixture plus docs: `feat(cabana): settings entries that link to an admin controller`. Add no co-author trailer.
SPA, commit B:
- admin/src/state/useSettings.ts: import controllerPath from ../app/controllerRoutes. Export `settingsPath(entry: SettingsEntry): string`. When entry.controller is non-empty it returns controllerPath(entry.controller), falling back to '/settings' when that is null. Otherwise it returns `/settings/` plus encodeURIComponent(entry.code). Export `settingsLinkFor(controllerId: string | null): SettingsEntry | null`. It returns null for null or an empty string, and otherwise the first entry whose non-empty controller equals the ID. Add both to useSettings().
- admin/src/views/SettingsIndexView.vue: bind the card RouterLink `:to` to settingsPath(entry). Keep data-settings. Update the header comment.
- admin/src/components/shell/PluginRail.vue: inSettings is also true when active is null and settingsLinkFor(controllerIdFromPath(route.path)) is non-null. A controller that also has a navigation entry keeps its plugin highlight. Update the comment.
- admin/src/components/shell/Breadcrumbs.vue: when activeEntry is null, look up settingsLinkFor(controllerIdFromPath(route.path)). If an entry is found, the crumbs are the Settings label (t('backend::lang.nav.settings'), to /settings), then the entry label (to its controller path), then the record title on record and create routes when non-empty, using the same rule as the plugin branch. Otherwise return [] as today.
- admin/src/app/pageTitle.ts: controllerLabel falls back to settingsLinkFor(controller)?.label after the section and plugin labels.
- Tests: implement the SPA behavior cases in useSettings.test.ts, Settings.test.ts (index), PluginRail.test.ts, Breadcrumbs.test.ts and pageTitle.test.ts. Build link entries inline (for example `setSettings([...settingsFixture.list.data, linkEntry])` with controller 'acme.demo.locales' and a vendor or plugin absent from the navigation fixture). Do not change existing assertions.
- `npm --prefix admin run build`, then `scripts/check-admin-dist.sh`.
- Commit B, SPA plus tests plus dist (`git add -A modules/boardwalk/dist`): `feat(admin): open controller-link settings entries from the settings index`. Add no co-author trailer.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && test -z "$(gofmt -l modules/pact modules/cabana)" && go vet ./modules/pact ./modules/cabana && go test ./modules/cabana -run 'TestSettingsLink' -count=1 -v && go test ./modules/pact ./modules/cabana ./modules/boardwalk -count=1 && scripts/check-admin-openapi.sh --check && grep -q '"controller"' admin/openapi/admin.json && npm --prefix admin run typecheck && npm --prefix admin test && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1</automated>
</verify>
<done>Every Go and SPA behavior case failed before the change and passes after it. Existing metadata, settings-singleton, conformance, security-coverage and SPA tests pass unchanged apart from the fixture's added controller key. admin.json and schema.d.ts match a fresh generation. Dist matches a fresh build. TestDocsTree passes. Two commits exist (feat(cabana) with docs, feat(admin) with dist), neither with a co-author trailer.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Translate plugin lists Locales on the Settings page instead of the main navigation</name>
<files>/media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_navigation.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_settings.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/plugin_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/locales_admin_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/README.md</files>
<read_first>/media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_navigation.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/admin_harness_test.go (newAdminEnv, admin, expect, adminAPI), /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/locales_admin_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/README.md, /media/nvme/dev/golem15/summercms.io/summercms/examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php (registerSettings)</read_first>
<behavior>
- Plugin.Settings() returns exactly one item. Its fields are: Code "locales", Label "golem15.translate::lang.locale.title", Description "golem15.translate::lang.plugin.description", Category "golem15.translate::lang.plugin.name", Icon "languages", Order 550, Permissions [controllers.PermissionManageLocales], Controller "golem15.translate.locales". Model and Form are empty, NewModel is nil and there are no Keywords.
- *Plugin does not implement pact.HasNavigation, and does implement pact.HasSettings.
- Harness, with Postgres: an admin holding golem15.translate.manage_locales gets a GET /settings entry with code locales, controller golem15.translate.locales, model "" and a translated label (English "Manage languages"). GET /navigation contains no entry with code translate. GET /settings/locales and GET /settings/locales/schema answer 404.
- Harness: an admin without manage_locales gets no locales entry from GET /settings.
</behavior>
<action>
Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin, a separate git repo. Use absolute paths. Run `pwd` if a path error appears. Start with `git status --short` and confirm the repo is clean. It builds against the local framework through the go.mod and go.work replace, so the Task 2 Controller field is visible. Do not change the go.mod require version.
1. Write the failing tests first.
- In plugin_test.go, add a test asserting the exact Settings() item from the behavior block, compared with reflect.DeepEqual against an expected pact.SettingsItem (NewModel nil on both sides). Assert the interface facts with type assertions on any(&Plugin{}).
- In locales_admin_test.go, add a harness test covering the GET /settings, GET /navigation and 404 cases using newAdminEnv, e.admin and e.expect.
2. Create admin_settings.go (package translate). It defines `func (*Plugin) Settings() []pact.SettingsItem` returning the single item, using controllers.PermissionManageLocales. Its doc comment says it replaces registerSettings and that Locales opens from the Settings page, as the PHP plugin's 'url' => Backend::url('golem15/translate/locales') entry did. The values follow the PHP reference exactly: category is the plugin name key, and there are no keywords. Planning chose the reference over a core "System" category because core has none and every other Go settings item uses its own plugin phrase key.
3. Remove admin_navigation.go with `git rm`. In admin.go's interface-assertion block, replace the navigation assertion with `_ pact.HasSettings = (*Plugin)(nil)`.
4. All three lang keys already exist in lang/en/lang.yaml and lang/pl/lang.yaml (locale.title, plugin.description, plugin.name). Add no lang changes.
5. README.md:
- Extend the "Locales admin" Features bullet: Locales opens from the Settings page (category Translate) through a settings entry linking to the controller, not from the main navigation, matching the PHP plugin.
- Add an API reference row `translate.Plugin.Settings`: one settings entry, locales, linking to controller golem15.translate.locales and gated by golem15.translate.manage_locales.
- Note the framework requirement: the entry uses the Controller field of pact.SettingsItem, so the plugin needs a framework release that has it. Do not edit go.mod.
6. Run gofmt, go vet ./... and go test ./... -count=1. The harness tests need Docker for testcontainers.
7. Commit in sm-translate-plugin as `feat(settings): open Locales from the Settings page`. Add no co-author trailer.
8. Back in summercms.go, run the full `go vet ./...` and `go test ./... -count=1` once more to confirm both repos are green. Leave the consuming apps' translate submodules unbumped and note that in the SUMMARY as a follow-up.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin && test ! -e admin_navigation.go && test -z "$(gofmt -l .)" && go vet ./... && go test ./... -count=1 && test -z "$(git status --short)" && cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./... -count=1</automated>
</verify>
<done>The translate plugin implements HasSettings and not HasNavigation. Settings() returns the single locales link entry matching the PHP registerSettings values. The harness proves the entry appears on /settings for manage_locales admins only, is absent from /navigation, and that the singleton endpoints 404 for it. The README documents it. One sm-translate-plugin commit with no co-author trailer. go vet and go test are green in both repos.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| backend principal -> GET /settings | Metadata decides which settings entries, and which controller IDs, a principal may learn about |
| backend principal -> /settings/{code} endpoints | Singleton read and write endpoints keyed by a client-supplied code |
| plugin registration -> cabana registry | Compiled plugins declare settings entries that the registry trusts at boot |
| server metadata -> SPA router | The SPA turns a server-sent controller ID into a client route |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-quick-261007-p4y-01 | Information disclosure | cabana Registry.Metadata settings loop | medium | mitigate | A link entry is listed only when Allows(principal, item.Permissions) and r.canOpen(principal, item.Controller) both hold, so a principal never learns a controller ID it cannot open; covered by the restricted-principal case in settings_link_test.go |
| T-quick-261007-p4y-02 | Tampering / Denial of service | protectSetting via Registry.Setting | medium | mitigate | Registry.Setting returns false for link entries, so GET/PUT on a link code answer 404 before any nil Form or model factory is touched (no panic, no write); covered by the three 404 cases |
| T-quick-261007-p4y-03 | Spoofing / Tampering | compileContributions link branch | low | mitigate | Boot fails when a link names an unregistered controller, combines Controller with Form, NewModel or Model, or reuses a settings code; there is no runtime path to a dangling or ambiguous entry |
| T-quick-261007-p4y-04 | Tampering | SPA settingsPath | low | mitigate | The route comes only from controllerPath, which accepts exactly three [A-Za-z0-9_-] segments and otherwise falls back to /settings; no server string is interpolated into a URL unchecked |
| T-quick-261007-p4y-05 | Elevation of privilege | controller pages opened from Settings | low | accept | Opening a controller still goes through the existing controller guard (requiredOf) on every API call; the Settings link adds no access path |
| T-quick-261007-p4y-SC | Tampering | npm/go installs | low | accept | No new npm or Go dependency; the work uses the committed lockfile, the existing Vitest setup and the pinned swag in check-admin-openapi.sh |
</threat_model>
<verification>
- summercms.go git log shows three new commits in order: fix(admin) switch with dist, feat(cabana) with OpenAPI, fixture, READMEs and docs, and feat(admin) with tests and dist. None has a co-author trailer and none touches .planning.
- sm-translate-plugin git log shows one new commit with no co-author trailer; admin_navigation.go is removed.
- scripts/check-admin-openapi.sh --check, scripts/check-admin-dist.sh and go test ./cmd/summer -run TestDocsTree pass.
- go vet ./... and go test ./... pass in both repos; npm --prefix admin test and typecheck pass.
</verification>
<success_criteria>
- The toggle renders a 44x26 track with a 20px knob and symmetric 3px insets.
- A plugin can declare a pact.SettingsItem that links to its admin controller. The Settings page shows it, permission- and controller-access-filtered, and opens the controller. The shell treats that controller as part of Settings. Singleton endpoints refuse it with 404. Existing singleton settings and other plugins are unaffected.
- The translate plugin's Locales screen is reached from Settings under Translate, as in WinterCMS, and no longer from the main navigation.
</success_criteria>
<output>
Create `.planning/quick/261007-p4y-toggle-proportions-and-settings-controll/261007-p4y-SUMMARY.md` when done. Record:
- the discretion choices: Model rejected on link entries; category follows the PHP reference rather than a core "System" constant;
- the follow-up that consuming apps must bump their translate submodule and need a framework tag that includes the Controller field.
</output>

View File

@@ -0,0 +1,155 @@
---
phase: quick-261007-p4y
plan: 01
status: complete
subsystem: admin, cabana, pact, sm-translate-plugin
tags: [admin-spa, settings, pact, cabana, translate, parity]
requires: []
provides:
- "pact.SettingsItem.Controller: a settings entry that links to an admin controller"
- "cabana link compile/validation, metadata filtering (Allows + canOpen), singleton-endpoint 404s"
- "SPA settingsPath / settingsLinkFor; Settings-aware rail, breadcrumbs and page title"
- "translate plugin: Locales on the Settings page instead of the main navigation"
affects: [sm-grzybyfunkcjonalne-app, sm-summercmsio-app]
tech-stack:
added: []
patterns:
- "Settings entries share one code namespace across singleton and link entries"
- "Registry.Setting hides link entries so protectSetting answers 404 with no http.go change"
key-files:
created:
- modules/cabana/settings_link_test.go
- modules/cabana/example_settings_test.go
- ../sm-translate-plugin/admin_settings.go
modified:
- admin/src/components/form/fields/SwitchField.vue
- modules/pact/capabilities.go
- modules/cabana/settings.go
- modules/cabana/registry.go
- modules/cabana/navigation.go
- modules/cabana/contracts.go
- modules/cabana/example_test.go
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/tests/fixtures/settings.json
- admin/src/state/useSettings.ts
- admin/src/views/SettingsIndexView.vue
- admin/src/components/shell/PluginRail.vue
- admin/src/components/shell/Breadcrumbs.vue
- admin/src/app/pageTitle.ts
- modules/pact/README.md
- modules/cabana/README.md
- docs/backend/settings.md
- docs/setup/coming-from-wintercms.md
- modules/boardwalk/dist/
- ../sm-translate-plugin/admin.go
- ../sm-translate-plugin/plugin_test.go
- ../sm-translate-plugin/locales_admin_test.go
- ../sm-translate-plugin/README.md
deleted:
- ../sm-translate-plugin/admin_navigation.go
decisions:
- "A settings link entry rejects Model as well as Form/NewModel, so the SPA never sees a misleading model string"
- "Translate's settings category follows the PHP reference (golem15.translate::lang.plugin.name); no core System category constant added"
- "The docs Go example for the link entry lives in its own file (example_settings_test.go) because example_controller_test.go is embedded whole in admin-controllers.md"
metrics:
duration: "13 min"
completed: 2026-10-07
estimate:
tokens: 140000
tasks: 3
actuals:
tokens: 15600
tasks: 3
commits: 3
plan_head_before: a586357a346a66111d37bd8c1d564c769a412715
plan_head_after: 0b09acdb0cca4955a2dcba2e9a2708a06b032104
---
# Quick 261007-p4y: Toggle proportions and Settings controller links Summary
The admin switch now has pixel-based proportions: a 44x26 track and a 20px knob. Settings entries can now link to an admin controller through an additive `pact.SettingsItem.Controller` field, covered from start-up validation through to the Settings page. The translate plugin now lists Locales on the Settings page, under the Translate category, as WinterCMS did.
## Commits
summercms.go (3 commits, measured `git rev-list --count a586357..HEAD` = 3):
| Task | Commit | Message |
|------|--------|---------|
| 1 | b040ee3 | fix(admin): size the switch toggle in pixels at the 14px root |
| 2A | 4a9b089 | feat(cabana): settings entries that link to an admin controller |
| 2B | 0b09acd | feat(admin): open controller-link settings entries from the settings index |
sm-translate-plugin (separate repo, 1 commit):
| Task | Commit | Message |
|------|--------|---------|
| 3 | bde3f1a | feat(settings): open Locales from the Settings page |
No commit has a co-author trailer. No commit touches `.planning`. Nothing was pushed.
## What was built
- **Switch:** `w-[44px]` and `size-[20px]` give 3 + 20 + 18 + 3 = 44, with a symmetric inset. Dist was rebuilt and committed on its own.
- **pact:** `SettingsItem.Controller` is a new additive field placed before `Form`. Keyed literals in other code compile unchanged, and a grep of the meta repo found no unkeyed ones.
- **cabana:**
- `compileSettingLink` rejects a link that also sets Form, NewModel or Model.
- The link branch in `compileContributions` skips the AdminFS requirement.
- After every plugin is collected, start-up fails on an unknown controller with `cabana: setting <code> references unknown controller <id>`.
- `Registry.Setting` hides link entries, so all three singleton endpoints answer 404.
- `SettingsEntry.Controller` is serialised as `json:"controller"` and is always present.
- Metadata lists a link entry only when the principal passes `Allows(item.Permissions)` and `canOpen(controller)`.
- **OpenAPI:** `controller` is a required string on `cabana.SettingsEntry` in admin.json and schema.d.ts. The settings fixture entries now carry `"controller": ""`.
- **SPA:**
- Index cards link through `settingsPath`.
- The rail marks Settings current on a linked controller that has no navigation entry. A linked controller that does have a navigation entry keeps its plugin highlighted.
- Breadcrumbs on such a page read `Ustawienia > <label> [> record]`.
- The page title falls back to the entry label.
- **Translate:** `Plugin.Settings()` returns the single locales link entry, with values taken from the PHP registerSettings reference. `admin_navigation.go` is removed, and `HasNavigation` is replaced by `HasSettings` in the assertion block.
## TDD evidence
- Go RED: `settings_link_test.go` failed to build (`unknown field Controller in struct literal of type pact.SettingsItem`). After the change it passes: 4 tests plus 9 subtests.
- SPA RED: 6 new cases failed across useSettings, Settings, PluginRail, Breadcrumbs and pageTitle. After the change it passes: all 1083 tests in 74 files.
- Translate RED: the build failed (`p.Settings undefined`). After the change it passes, and the harness test ran against Postgres via testcontainers.
## Verification
- summercms.go: `go vet ./...` and `go test ./... -count=1` pass. So do `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh`, `go test ./cmd/summer -run TestDocsTree`, `npm --prefix admin run typecheck` and `npm --prefix admin test`.
- sm-translate-plugin: `gofmt -l .` is clean, and `go vet ./...` and `go test ./... -count=1` pass.
- Both working trees are clean, apart from the orchestrator-owned `.planning/quick/...` directory.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] The docs snippet needed a `src=` reference and a shorter description**
- **Found during:** Task 2, commit A
- **Issue:** TestDocsTree rejects a Go fence without `src=` and a frontmatter description over 160 characters. Adding the example to `example_controller_test.go` changed a whole-file snippet in `docs/backend/admin-controllers.md`.
- **Fix:** I added `modules/cabana/example_settings_test.go`, which holds `LinkedBlogPlugin` and a `docs:start settings-link` region. `TestDocsSettingsLink` in `example_test.go` activates the plugin through `cabana.Activate`, which proves the link compiles end to end. The settings.md description is shortened.
- **Commit:** 4a9b089
**2. [Process] The admin-behaviour paragraph moved to commit B**
- The settings.md paragraph about the rail and breadcrumbs went into the SPA commit (0b09acd) rather than commit A, so each commit's docs match its code.
**3. [Test fixture choice] Rail, breadcrumb and title tests use `acme.lang.locales`**
- The plan suggested `acme.demo.locales`, but `acme.demo` is in the navigation fixture, which would make the plugin highlight win. These tests use a plugin that is absent from navigation, `acme.lang.locales`. The path tests in useSettings still use `acme.demo.locales`.
## Follow-ups
- Consuming apps (sm-grzybyfunkcjonalne-app, sm-summercmsio-app) must bump their translate submodule to bde3f1a or later. They also need a framework tag that includes `pact.SettingsItem.Controller` (4a9b089 or later). Neither bump is done here, because it was out of scope.
- The PHP plugin also registers a `messages` settings link. There is no Messages admin controller in the Go port yet, so only `locales` is registered.
## Known Stubs
None.
## Threat Flags
None. All new surface is covered by the plan's threat register: T-01 has the metadata filter tests, T-02 the 404 tests, T-03 the compile validation tests, and T-04 the settingsPath fallback test.
## Self-Check: PASSED
- FOUND: modules/cabana/settings_link_test.go, modules/cabana/example_settings_test.go, ../sm-translate-plugin/admin_settings.go
- MISSING (intended): ../sm-translate-plugin/admin_navigation.go
- FOUND commits: b040ee3, 4a9b089, 0b09acd (summercms.go); bde3f1a (sm-translate-plugin)

View File

@@ -0,0 +1,33 @@
---
quick_id: 261007-qgx
mode: quick
---
# Quick 261007-qgx: admin record heading uses translatable (mltext) fields
The admin record heading, breadcrumb record crumb and document title read the
first `text` field only, so records whose name field is `mltext` (journal Post
title, Category name) fall back to "Edit record".
## Task 1: shared recordDisplayName helper
- files: admin/src/components/form/formState.ts, admin/src/views/FormView.vue, admin/src/views/PreviewView.vue
- action: add `recordDisplayName(fields, values, enabledLocales)`: first field
of type `text` or an ML type (`isMLFieldType`) in schema order; plain values
as today (string/number, trimmed); ML values use the default (first enabled)
locale, else the first non-empty locale, else ''. Both views use it with
`schemaEnabledLocales(schema.meta)`.
- done: plain `text` behaviour unchanged; an mltext first field names the record.
## Task 2: tests
- files: admin/tests/form/formState.test.ts, admin/tests/form/FormView.test.ts, admin/tests/form/PreviewView.test.ts
- action: helper cases (plain, default locale filled, default empty + other
filled, all empty, non-text first field skipped); FormView and PreviewView
show the default-locale value as heading and record crumb for an mltext field.
- verify: `npm test`, `vue-tsc --noEmit`.
## Task 3: ship
- rebuild the embedded admin shell (`npm run build` → modules/boardwalk/dist),
`go vet ./... && go test ./...`, tag and push v0.1.12.

View File

@@ -0,0 +1,18 @@
---
quick_id: 261007-qgx
status: complete
commits: [a714bb4, b6b194e]
---
# Quick 261007-qgx: admin record heading uses translatable (mltext) fields
- `recordDisplayName(fields, values, enabledLocales)` in
`admin/src/components/form/formState.ts`: the first `text` or ML field in
schema order names the record; an ML value reads the default (first enabled)
locale, then the first non-empty locale, else '' (the generic message stays).
- FormView and PreviewView both use it with `schemaEnabledLocales(schema.meta)`;
plain `text` behaviour is unchanged.
- Tests: five helper cases in `formState.test.ts`; FormView and PreviewView
cases for an mltext first field (heading and record crumb). Both view tests
fail without the fix. Admin suite 1090/1090, `go vet` and `go test ./...` green.
- Embedded admin shell rebuilt (`modules/boardwalk/dist`), shipped as v0.1.12.

View File

@@ -0,0 +1,31 @@
---
created: 2026-10-07T16:31:50.525Z
title: Translate Messages as a Nuxt i18n JSON editor
area: sm-translate-plugin
severity: minor
files:
- ../sm-translate-plugin/admin_settings.go
- modules/pact/capabilities.go (SettingsItem.Controller)
- ../examples/golem15-wintercms-starter/plugins/golem15/translate/Plugin.php (registerSettings: messages)
---
## Problem
WinterCMS translate registers two settings entries: Locales and Messages. Messages
manages CMS theme Twig strings such as `{{ 'x.y.z'|_ }}`, stored in a messages table.
SummerCMS apps are headless and have no server-rendered theme strings, so the Go port
(quick 261007-p4y) registers only Locales. The frontend strings live in the Nuxt app's
i18n locale JSON files, which admins currently cannot edit.
## Solution
Integrate Nuxt i18n with the translate plugin as an i18n JSON editor, not a
WinterCMS-style messages table:
- admin screen edits per-locale message keys (nested JSON, one column per enabled
Locale), with missing-key highlighting against the default locale;
- the backend serves the merged messages to the Nuxt app (e.g. a lazy-load endpoint
per locale that @nuxtjs/i18n can fetch), with the bundled JSON as fallback;
- register as a second controller-link settings entry (`pact.SettingsItem.Controller`)
under the Translate category, next to Locales.
Open questions: storage (DB rows vs. versioned JSON), how keys are discovered/synced
from the Nuxt repo, caching and invalidation on edit.

View File

@@ -1875,6 +1875,10 @@
"code": {
"type": "string"
},
"controller": {
"description": "Controller is the admin controller a link entry opens; empty for a\nsingleton settings form.",
"type": "string"
},
"description": {
"type": "string"
},
@@ -1900,6 +1904,7 @@
"required": [
"category",
"code",
"controller",
"description",
"icon",
"keywords",

View File

@@ -4886,6 +4886,11 @@ export interface components {
"cabana.SettingsEntry": {
category: string;
code: string;
/**
* @description Controller is the admin controller a link entry opens; empty for a
* singleton settings form.
*/
controller: string;
description: string;
icon: string;
keywords: string[];

View File

@@ -3,7 +3,7 @@ import { controllerIdFromPath } from './controllerRoutes'
import { t } from './i18n'
import { recordCrumb } from '../state/useBreadcrumbs'
import { activeEntry } from '../state/useNavigation'
import { settingsEntry } from '../state/useSettings'
import { settingsEntry, settingsLinkFor } from '../state/useSettings'
const PRODUCT_TITLE = 'SummerCMS'
@@ -28,7 +28,7 @@ export function pageIndicator(route: RouteLocationNormalizedLoaded): string {
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
const controller = controllerIdFromPath(route.path)
const section = plugin?.sideMenu.find((item) => item.controller === controller)
const controllerLabel = section?.label ?? plugin?.label ?? ''
const controllerLabel = section?.label ?? plugin?.label ?? settingsLinkFor(controller)?.label ?? ''
if (route.name === 'create' || route.name === 'record' || route.name === 'preview') {
return recordCrumb.value || controllerLabel
}

View File

@@ -34,12 +34,12 @@ function toggle(): void {
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="on ? 'bg-primary' : 'bg-border-strong'"
class="relative h-[26px] w-11 shrink-0 rounded-pill transition-colors duration-150 ease-out"
class="relative h-[26px] w-[44px] shrink-0 rounded-pill transition-colors duration-150 ease-out"
@click="toggle"
>
<span
:class="on ? 'translate-x-[18px] bg-on-primary' : 'translate-x-0 bg-white'"
class="absolute top-[3px] left-[3px] size-5 rounded-full shadow-tab transition-transform duration-150 ease-out"
class="absolute top-[3px] left-[3px] size-[20px] rounded-full shadow-tab transition-transform duration-150 ease-out"
aria-hidden="true"
/>
</button>

View File

@@ -217,6 +217,35 @@ export function mergeMLValue(incoming: unknown, enabledLocales: readonly string[
return { ...seeded, ...localeRecord(incoming) }
}
/** The record's display name: the value of the first text-like field (plain
* `text` or an ML type) in schema order. An ML value reads the default (first
* enabled) locale, then the first non-empty locale; '' when nothing is set. */
export function recordDisplayName(
fields: readonly FormField[] | undefined,
values: AdminRecord,
enabledLocales: readonly string[],
): string {
const first = fields?.find((field) => field.type === 'text' || isMLFieldType(field.type))
const value = first ? values[first.name] : undefined
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim()
}
if (!first || !isMLFieldType(first.type)) {
return ''
}
const texts = localeRecord(value)
const preferred = enabledLocales[0] !== undefined ? (texts[enabledLocales[0]] ?? '').trim() : ''
if (preferred !== '') {
return preferred
}
for (const text of Object.values(texts)) {
if (text.trim() !== '') {
return text.trim()
}
}
return ''
}
/** D-10 422 details (field -> messages) as a string-list map. */
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
const out: Record<string, string[]> = {}

View File

@@ -6,12 +6,14 @@ import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes
import { t } from '../../app/i18n'
import { recordCrumb } from '../../state/useBreadcrumbs'
import { activeEntry, firstControllerPath } from '../../state/useNavigation'
import { settingsEntry } from '../../state/useSettings'
import { settingsEntry, settingsLinkFor, settingsPath } from '../../state/useSettings'
// Header breadcrumbs (design: Top header): plugin, controller and record
// labels, e.g. "Demo › Widgets › Blue widget". Parent crumbs are muted
// links; the current crumb uses the text colour at 600 with
// aria-current="page". Separators are 14px chevron-right icons.
// aria-current="page". Separators are 14px chevron-right icons. A controller
// opened from a settings link entry, with no navigation entry of its own,
// reads "Settings › <entry label>".
interface Crumb {
label: string
to?: string
@@ -29,11 +31,22 @@ const crumbs = computed<Crumb[]>(() => {
return out
}
const plugin = activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? ''))
const id = controllerIdFromPath(route.path)
if (!plugin) {
return []
const link = settingsLinkFor(id)
if (!link) {
return []
}
const out: Crumb[] = [
{ label: t('backend::lang.nav.settings'), to: '/settings' },
{ label: link.label, to: settingsPath(link) },
]
if ((route.name === 'record' || route.name === 'create') && recordCrumb.value !== '') {
out.push({ label: recordCrumb.value })
}
return out
}
const out: Crumb[] = [{ label: plugin.label, to: firstControllerPath(plugin) ?? undefined }]
const id = controllerIdFromPath(route.path)
const section = plugin.sideMenu.find((item) => item.controller === id) ?? null
if (section) {
out.push({ label: section.label, to: controllerPath(section.controller) ?? undefined })

View File

@@ -3,9 +3,10 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { PanelLeftOpen, Settings, Sun } from '@lucide/vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
import { hasSettings } from '../../state/useSettings'
import { hasSettings, settingsLinkFor } from '../../state/useSettings'
import { useSidebar } from '../../state/useSidebar'
import SectionFlyout from './SectionFlyout.vue'
@@ -25,8 +26,15 @@ const { collapsed, narrow, setCollapsed } = useSidebar()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
// Ustawienia (D-21) is pinned to the bottom and shown only when the admin may
// manage at least one settings page.
const inSettings = computed(() => route.path === '/settings' || route.path.startsWith('/settings/'))
// manage at least one settings page. It is also current on a controller a
// settings entry links to, unless that controller has its own navigation
// entry (then its plugin stays highlighted).
const inSettings = computed(
() =>
route.path === '/settings' ||
route.path.startsWith('/settings/') ||
(active.value === null && settingsLinkFor(controllerIdFromPath(route.path)) !== null),
)
const items = computed(() =>
railEntries.value.map((entry) => ({

View File

@@ -1,7 +1,10 @@
// Settings pages the admin may manage (D-21), server-filtered like the
// navigation. The rail shows "Ustawienia" only when this list is non-empty.
// An entry is either a singleton settings form (/settings/<code>) or a link
// that opens an admin controller (its `controller` is set).
import { computed, readonly, ref } from 'vue'
import { api } from '../api/client'
import { controllerPath } from '../app/controllerRoutes'
import type { SettingsEntry } from '../api/types'
const entries = ref<SettingsEntry[]>([])
@@ -24,6 +27,25 @@ export function settingsEntry(code: string): SettingsEntry | null {
return entries.value.find((entry) => entry.code === code) ?? null
}
/**
* Route a settings entry opens: its controller for a link entry (the index
* when the ID is malformed), otherwise its singleton form.
*/
export function settingsPath(entry: SettingsEntry): string {
if (entry.controller) {
return controllerPath(entry.controller) ?? '/settings'
}
return `/settings/${encodeURIComponent(entry.code)}`
}
/** The link entry that opens the given controller, if any. */
export function settingsLinkFor(controllerId: string | null): SettingsEntry | null {
if (!controllerId) {
return null
}
return entries.value.find((entry) => entry.controller !== '' && entry.controller === controllerId) ?? null
}
/** Entries grouped by category, categories in first-seen order, by order. */
export const settingsGroups = computed(() => {
const sorted = entries.value
@@ -43,5 +65,5 @@ export const settingsGroups = computed(() => {
})
export function useSettings() {
return { settings, hasSettings, settingsGroups, loadSettings, settingsEntry }
return { settings, hasSettings, settingsGroups, loadSettings, settingsEntry, settingsPath, settingsLinkFor }
}

View File

@@ -28,6 +28,7 @@ import {
mergeMLValue,
panelDomId,
presetUpdates,
recordDisplayName,
schemaEnabledLocales,
snapshot,
tabDomId,
@@ -139,12 +140,10 @@ const panelFields = computed(() => {
return fields.value.filter((field) => tabOf(field) === key)
})
/** The record's display name: the first text field's value, in schema order. */
const recordName = computed(() => {
const first = schema.value?.fields.find((field) => field.type === 'text')
const value = first ? values.value[first.name] : undefined
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
})
/** The record's display name: the first text-like field's value, in schema order. */
const recordName = computed(() =>
recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)),
)
const title = computed(() => {
if (mode === 'update' && recordName.value !== '') {

View File

@@ -14,7 +14,16 @@ import FormTabs from '../components/form/FormTabs.vue'
import PreviewField from '../components/form/PreviewField.vue'
import RecordActions from '../components/form/RecordActions.vue'
import { RELATION_MANAGER } from '../components/form/registry'
import { DEFAULT_TAB, contextAllows, panelDomId, tabDomId, tabOf, type TabItem } from '../components/form/formState'
import {
DEFAULT_TAB,
contextAllows,
panelDomId,
recordDisplayName,
schemaEnabledLocales,
tabDomId,
tabOf,
type TabItem,
} from '../components/form/formState'
import PartialHost from '../components/partial/PartialHost.vue'
import Button from '../components/ui/Button.vue'
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
@@ -96,12 +105,10 @@ const panelFields = computed(() => {
return fields.value.filter((field) => tabOf(field) === key)
})
/** The record's display name: the first text field's value, in schema order. */
const recordName = computed(() => {
const first = schema.value?.fields.find((field) => field.type === 'text')
const value = first ? values.value[first.name] : undefined
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
})
/** The record's display name: the first text-like field's value, in schema order. */
const recordName = computed(() =>
recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)),
)
const subtitle = computed(() => message(schema.value?.messages.preview, undefined, { name: recordName.value }))
const hintPartial = computed(() => schema.value?.preview?.headerPartial ?? '')

View File

@@ -2,10 +2,11 @@
import { RouterLink } from 'vue-router'
import { t } from '../app/i18n'
import { iconFor } from '../app/icons'
import { settingsGroups } from '../state/useSettings'
import { settingsGroups, settingsPath } from '../state/useSettings'
// Settings index (D-21): every permitted settings page, grouped by category,
// with its icon, label and description. The server filters the list.
// with its icon, label and description. The server filters the list. A
// singleton entry opens its settings form; a link entry opens its controller.
</script>
<template>
@@ -27,7 +28,7 @@ import { settingsGroups } from '../state/useSettings'
<RouterLink
v-for="entry in group.entries"
:key="entry.code"
:to="`/settings/${encodeURIComponent(entry.code)}`"
:to="settingsPath(entry)"
:data-settings="entry.code"
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
>

View File

@@ -33,6 +33,17 @@ describe('documentTitleForRoute', () => {
expect(documentTitleForRoute((await routerAt('/settings/mail')).currentRoute.value)).toBe('Mail | SummerCMS')
})
it('uses the settings entry label on a settings-linked controller', async () => {
await signIn()
setNavigation(navigationFixture.data)
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
setSettings([...settingsFixture.list.data, locales])
expect(documentTitleForRoute((await routerAt('/acme/lang/locales')).currentRoute.value)).toBe('Locales | SummerCMS')
expect(documentTitleForRoute((await routerAt('/acme/lang/locales/3')).currentRoute.value)).toBe('Locales | SummerCMS')
setRecordCrumb('Polish')
expect(documentTitleForRoute((await routerAt('/acme/lang/locales/3')).currentRoute.value)).toBe('Polish | SummerCMS')
})
it('uses translated indicators for login, the empty home and not found', async () => {
expect(documentTitleForRoute((await routerAt('/login')).currentRoute.value)).toBe('Witaj ponownie | SummerCMS')
await signIn()

View File

@@ -9,6 +9,7 @@
"category": "System",
"keywords": [],
"model": "Acme\\Demo\\Models\\Settings",
"controller": "",
"order": 20
},
{
@@ -19,6 +20,7 @@
"category": "System",
"keywords": [],
"model": "Acme\\Demo\\Models\\MailSettings",
"controller": "",
"order": 10
},
{
@@ -29,6 +31,7 @@
"category": "Appearance",
"keywords": [],
"model": "Acme\\Demo\\Models\\Branding",
"controller": "",
"order": 5
}
],

View File

@@ -611,6 +611,14 @@ describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
expect(editor(wrapper, 'slug').element.value).toBe('ada-lovelace')
})
it('titles a record by the default locale of an mltext first field, and publishes it as the crumb', async () => {
const record = clone(rosterRecordFixture)
record.data.name = { en: 'Ada Lovelace', pl: 'Ada Lovelace PL' }
const { wrapper } = await mountApp('/acme/roster/people/1', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text'), [`GET ${PERSON}`]: { body: record } }))
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
expect(recordCrumb.value).toBe('Ada Lovelace')
})
it('drives a text slug from the active locale of an mltext name, until edited', async () => {
const { wrapper } = await mountApp('/acme/roster/people/create', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text') }))
await editor(wrapper, 'name').setValue('Hello World')

View File

@@ -4,6 +4,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { recordCrumb } from '../../src/state/useBreadcrumbs'
import { toasts } from '../../src/state/useToasts'
import PreviewView from '../../src/views/PreviewView.vue'
import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
@@ -121,6 +122,23 @@ describe('preview view (UI-SPEC S3, D-11)', () => {
expect(editor.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true)
})
it('heads the record by the default locale of an mltext first field, and publishes it as the crumb', async () => {
const { wrapper } = await mountApp(
PREVIEW,
routes({
[`GET ${LIST}/schema/form`]: schemaWith((schema) => {
schema.meta.enabledLocales = ['en', 'pl']
schema.fields.find((field) => field.name === 'name')!.type = 'mltext'
}),
[`GET ${RECORD}`]: recordWith((record) => {
record.data.name = { en: 'Ada Lovelace', pl: 'Ada Lovelace PL' }
}),
}),
)
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
expect(recordCrumb.value).toBe('Ada Lovelace')
})
it('shows the muted dash for an empty value and the label of a relation, never its id', async () => {
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.data.joined_ip = null)) }))
expect(wrapper.find('[data-preview-field="joined_ip"] [data-empty]').text()).toBe('—')

View File

@@ -53,6 +53,14 @@ describe('settings index (D-21)', () => {
expect(link.text()).toBe('Search')
expect(wrapper.find('[data-settings-group] h2').exists()).toBe(false)
})
it('opens a controller-link entry on its controller route and keeps singleton links', async () => {
const link = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.demo.locales' }
setSettings([...settingsFixture.list.data, link])
const { wrapper } = await mountApp('/settings', routes())
expect(wrapper.find('[data-settings="locales"]').attributes('href')).toMatch(/\/acme\/demo\/locales$/)
expect(wrapper.find('[data-settings="mail"]').attributes('href')).toBe('/settings/mail')
})
})
describe('settings form (D-21)', () => {

View File

@@ -11,6 +11,7 @@ import {
panelDomId,
presetUpdates,
presetValue,
recordDisplayName,
snapshot,
tabDomId,
tabOf,
@@ -271,3 +272,33 @@ describe('ML create seed and adopt merge', () => {
expect(mergeMLValue({ en: 'Hello', pl: 'Witaj' }, ['en', 'pl'])).toEqual({ en: 'Hello', pl: 'Witaj' })
})
})
describe('recordDisplayName', () => {
const field = (name: string, type: string): FormField => ({ ...formField('name'), name, type })
const text = field('name', 'text')
const title = field('title', 'mltext')
const locales = ['en', 'pl']
it('reads a plain text value, trimmed', () => {
expect(recordDisplayName([text], { name: ' Anna ' }, locales)).toBe('Anna')
expect(recordDisplayName([text], { name: 42 }, locales)).toBe('42')
})
it('reads the default locale of an ML value', () => {
expect(recordDisplayName([title], { title: { en: 'Hello', pl: 'Cześć' } }, locales)).toBe('Hello')
})
it('falls back to the first non-empty locale when the default is empty', () => {
expect(recordDisplayName([title], { title: { en: ' ', pl: 'Cześć' } }, locales)).toBe('Cześć')
})
it('is empty when every locale is empty', () => {
expect(recordDisplayName([title], { title: { en: '', pl: '' } }, locales)).toBe('')
expect(recordDisplayName([title], {}, locales)).toBe('')
})
it('skips fields that are not text-like, in favour of a later text field', () => {
const fields = [field('notify', 'checkbox'), field('tags', 'relation'), title, text]
expect(recordDisplayName(fields, { notify: true, tags: [1], title: { en: 'Post', pl: '' }, name: 'Anna' }, locales)).toBe('Post')
})
})

View File

@@ -72,6 +72,37 @@ describe('breadcrumbs', () => {
expect((await crumbsAt('/settings/unknown')).labels).toEqual(['Ustawienia'])
})
it('reads Ustawienia and the entry label on a settings-linked controller', async () => {
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
const crumbsWithLink = async (path: string) => {
await signIn()
setNavigation(navigationFixture.data)
setSettings([...settingsFixture.list.data, locales])
const router = await routerAt(path)
const wrapper = mount(Breadcrumbs, { global: { plugins: [router] } })
await flushPromises()
const items = wrapper.findAll('[data-crumb]')
return {
labels: items.map((item) => item.text()),
links: items.map((item) => (item.find('a').exists() ? item.find('a').attributes('href') : null)),
current: items.map((item) => item.find('[aria-current="page"]').exists()),
}
}
let crumbs = await crumbsWithLink('/acme/lang/locales')
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales'])
expect(crumbs.links).toEqual(['/settings', null])
expect(crumbs.current).toEqual([false, true])
setRecordCrumb('Polish')
crumbs = await crumbsWithLink('/acme/lang/locales/3')
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales', 'Polish'])
expect(crumbs.links).toEqual(['/settings', '/acme/lang/locales', null])
setRecordCrumb('')
crumbs = await crumbsWithLink('/acme/lang/locales/create')
expect(crumbs.labels).toEqual(['Ustawienia', 'Locales'])
})
it('is empty outside any permitted plugin', async () => {
expect((await crumbsAt('/acme/secret/things')).labels).toEqual([])
expect((await crumbsAt('/acme/demo/unlisted')).labels).toEqual(['Demo'])

View File

@@ -59,6 +59,20 @@ describe('plugin rail', () => {
expect(link.attributes('aria-current')).toBe('page')
})
it('marks Ustawienia current on a settings-linked controller without a navigation entry', async () => {
const locales = { ...settingsFixture.list.data[0]!, code: 'locales', label: 'Locales', model: '', controller: 'acme.lang.locales' }
const widgets = { ...locales, code: 'widgets', label: 'Widgets', controller: 'acme.demo.widgets' }
setSettings([...settingsFixture.list.data, locales, widgets])
const { wrapper } = await mountRail('/acme/lang/locales')
expect(wrapper.find('[data-rail-settings]').attributes('aria-current')).toBe('page')
expect(wrapper.findAll('[data-rail-item][aria-current="page"]')).toHaveLength(0)
// A linked controller that also has a navigation entry keeps its plugin.
const nav = await mountRail('/acme/demo/widgets')
expect(nav.wrapper.find('[data-rail-settings]').attributes('aria-current')).toBeUndefined()
expect(nav.wrapper.find('[data-rail-item="demo"]').attributes('aria-current')).toBe('page')
})
it('offers menu semantics and the expand button after a manual collapse', async () => {
const { wrapper } = await mountRail('/acme/demo/widgets')
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)

View File

@@ -6,6 +6,8 @@ import {
settings,
settingsEntry,
settingsGroups,
settingsLinkFor,
settingsPath,
useSettings,
} from '../../src/state/useSettings'
import { settingsFixture } from '../fixtures/typed'
@@ -44,4 +46,22 @@ describe('useSettings (D-21)', () => {
['System', ['mail', 'search']],
])
})
it('links a singleton to its form and a controller link to the controller route', () => {
const singleton = settingsFixture.list.data[0]!
expect(settingsPath({ ...singleton, code: 'a b' })).toBe('/settings/a%20b')
expect(settingsPath({ ...singleton, code: 'locales', model: '', controller: 'acme.demo.locales' })).toBe('/acme/demo/locales')
expect(settingsPath({ ...singleton, code: 'broken', model: '', controller: 'acme.demo' })).toBe('/settings')
expect(useSettings().settingsPath).toBe(settingsPath)
})
it('finds the link entry that opens a controller, never a singleton', () => {
const link = { ...settingsFixture.list.data[0]!, code: 'locales', model: '', controller: 'acme.demo.locales' }
setSettings([...settingsFixture.list.data, link])
expect(settingsLinkFor('acme.demo.locales')?.code).toBe('locales')
expect(settingsLinkFor('acme.demo.widgets')).toBeNull()
expect(settingsLinkFor('')).toBeNull()
expect(settingsLinkFor(null)).toBeNull()
expect(useSettings().settingsLinkFor).toBe(settingsLinkFor)
})
})

View File

@@ -236,4 +236,4 @@ A relation's `messages` block overrides the relation manager's copy, each key a
## Relations in lists
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.
A list column can show a related value with `relation` and `select` in `columns.yaml`; see [Lists and filters](lists-and-filters.md). On a to-many relation the column joins the selected value of every related record with `, `, as WinterCMS does, and stays empty when there are none. A controller that maps a relation column to a physical column itself implements `pact.ListRelationColumnMapper`.

View File

@@ -1,6 +1,6 @@
---
title: Settings
description: Declare singleton settings pages with pact.SettingsItem, backed by a model, a fields.yaml form and validation rules, and read them from plugin code.
description: Declare settings pages with pact.SettingsItem, as singleton forms backed by a model or as links to admin controllers, and read settings from plugin code.
section: backend
order: 60
---
@@ -64,6 +64,29 @@ fields:
Create the table in a migration like any other; see [Migrations](../database/migrations.md).
## Linking a settings entry to an admin controller
A WinterCMS `registerSettings` entry can point at a backend controller instead of a settings model, with `'url' => Backend::url('acme/blog/posts')`. The entry appears on the Settings page and opens that controller's list. In SummerCMS the `Controller` field of `pact.SettingsItem` does the same: set it to the admin controller ID (`vendor.plugin.controller`) and leave `Model`, `Form` and `NewModel` empty. The plugin returns this entry from `Settings` next to, or instead of, its singleton pages:
```go src=modules/cabana/example_settings_test.go#settings-link
link := pact.SettingsItem{
Code: "posts",
Label: "acme.blog::lang.posts.title",
Description: "acme.blog::lang.posts.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Order: 500,
Permissions: []string{"acme.blog.access_settings"},
Controller: "acme.blog.posts",
}
```
A link entry is not a singleton and needs no embedded admin tree. [cabana](../../modules/cabana/README.md) stops start-up when a link entry also declares `Model`, `Form` or `NewModel`, when it names a controller no plugin registered, or when its code repeats another settings code; singleton and link entries share one code namespace.
`GET .../settings` lists a link entry with its `controller` set (and `model` empty) only when the administrator passes the entry's permissions and may also open the controller, so a controller the administrator cannot reach is never advertised. Singleton entries carry an empty `controller`. The singleton endpoints (`.../settings/{code}`, `.../settings/{code}/schema`) answer 404 for a link entry's code.
In the admin, the entry's card on the Settings page opens the controller's pages. When the controller has no main navigation item of its own, the admin treats it as part of Settings: the rail marks Settings as current and the breadcrumbs read Settings, then the entry's label.
## How values are stored
The settings row is the one with ID 1. Before it exists, the page shows the fields' `default` values and the API reports that the row does not exist yet; the first save creates it. A save writes only fillable fields, validates them with the model's rules and the form's `required` flags, and runs in a transaction, as a controller form save does.

View File

@@ -35,7 +35,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
| Queued jobs | `pact.HasJobs` with jobs built by `conga.Job`, dispatched with `conga.Manager.Dispatch` inside the caller's transaction | [Queued jobs](../services/jobs.md), [conga](../../modules/conga/README.md) |
| `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](../plugins/scheduling.md), [pact](../../modules/pact/README.md) |
| Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](../services/mail.md), [postcard](../../modules/postcard/README.md) |
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries; an entry with `'url' => Backend::url(...)` sets the `Controller` field to the admin controller ID | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
| Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) |
| Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) |
| The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) |

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,8 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-CE_etpB7.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-X2xCWuiY.css">
<script type="module" crossorigin src="./assets/index-BFvYYWYJ.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BhDUy2Lr.css">
</head>
<body>
<div id="app"></div>

View File

@@ -30,7 +30,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
@@ -46,7 +46,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
@@ -191,7 +191,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. |
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. `cabana.Registry.Setting` returns singleton settings only, never a controller link. |
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options; `cabana.CRUDService.BulkAction` runs a declared bulk action on a scoped, locked id set. |
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |

View File

@@ -131,13 +131,18 @@ func (r *Registry) Get(id string) (*CompiledController, bool) {
return cc, ok && cc != nil
}
// Setting returns one compiled singleton setting by stable code.
// Setting returns one compiled singleton setting by stable code. Link entries
// (pact.SettingsItem with Controller set) are not singletons and are not
// returned, so the singleton settings endpoints answer 404 for their codes.
func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
if r == nil {
return nil, false
}
setting, ok := r.settings[code]
return setting, ok && setting != nil
if !ok || setting == nil || setting.Item.Controller != "" {
return nil, false
}
return setting, true
}
// Allows reports whether principal satisfies any of the required permission

View File

@@ -0,0 +1,24 @@
package cabana_test
import "git.golem15.com/golem15/summercms/modules/pact"
// LinkedBlogPlugin adds a settings entry that opens the posts controller from
// the Settings page.
type LinkedBlogPlugin struct{ BlogPlugin }
// Settings keeps the singleton page and adds a controller link.
func (p *LinkedBlogPlugin) Settings() []pact.SettingsItem {
// docs:start settings-link
link := pact.SettingsItem{
Code: "posts",
Label: "acme.blog::lang.posts.title",
Description: "acme.blog::lang.posts.description",
Category: "acme.blog::lang.plugin.name",
Icon: "icon-pencil",
Order: 500,
Permissions: []string{"acme.blog.access_settings"},
Controller: "acme.blog.posts",
}
// docs:end settings-link
return append(p.BlogPlugin.Settings(), link)
}

View File

@@ -101,3 +101,20 @@ func TestDocsDeclarations(t *testing.T) {
t.Fatal("BlogSettings has no posts_per_page rule")
}
}
// TestDocsSettingsLink activates the plugin with a settings entry that links
// to its posts controller, as the Settings page shows.
func TestDocsSettingsLink(t *testing.T) {
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
_ = cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes")
if _, err := cabana.Activate(backpack.New(cfg), []party.Plugin{&LinkedBlogPlugin{}}); err != nil {
t.Fatal(err)
}
settings := (&LinkedBlogPlugin{}).Settings()
if len(settings) != 2 || settings[1].Controller != "acme.blog.posts" || settings[1].Model != "" {
t.Fatalf("Settings() = %+v", settings)
}
}

View File

@@ -84,3 +84,41 @@ func TestFieldByColumnTagBeatsGoName(t *testing.T) {
t.Fatalf("a tagged field matched by its Go name: %v", got)
}
}
type listTag struct {
ID uint `gorm:"primarykey"`
Name *string `gorm:"column:name"`
}
type listTagged struct {
ID uint `gorm:"primarykey"`
Tags []listTag `gorm:"many2many:tagged_tags"`
Pointers []*listTag `gorm:"many2many:tagged_pointer_tags"`
}
// TestProjectRowJoinsToManyRelation pins a to-many relation column (relation:
// tags, select: name): the list shows every related record's selected value
// joined with ", " like Winter, and leaves the key unset when there are none.
func TestProjectRowJoinsToManyRelation(t *testing.T) {
news, guides := "News", "Guides"
row := &listTagged{
ID: 3,
Tags: []listTag{{ID: 1, Name: &news}, {ID: 2, Name: &guides}, {ID: 4}},
Pointers: []*listTag{nil, {ID: 2, Name: &guides}},
}
cols := []ListColumn{
{Key: "tags", Relation: "tags", Select: "name"},
{Key: "pointers", Relation: "pointers", Select: "name"},
}
projected := projectRow(row, nil, cols)
if projected["tags"] != "News, Guides" {
t.Fatalf("tags = %#v, want %q", projected["tags"], "News, Guides")
}
if projected["pointers"] != "Guides" {
t.Fatalf("pointers = %#v, want %q", projected["pointers"], "Guides")
}
empty := projectRow(&listTagged{ID: 5}, nil, cols)
if _, ok := empty["tags"]; ok {
t.Fatalf("empty relation projected %#v, want the key unset", empty)
}
}

View File

@@ -29,6 +29,9 @@ type SettingsEntry struct {
Order int `json:"order"`
Keywords []string `json:"keywords"`
Model string `json:"model"`
// Controller is the admin controller a link entry opens; empty for a
// singleton settings form.
Controller string `json:"controller"`
}
// Metadata returns only entries the backend principal may open. Denied
@@ -65,7 +68,9 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
})
for _, compiled := range r.settings {
item := compiled.Item
if !Allows(principal, item.Permissions) {
// A link entry is listed only when the principal can also open its
// controller, so a denied controller ID never leaks.
if !Allows(principal, item.Permissions) || (item.Controller != "" && !r.canOpen(principal, item.Controller)) {
continue
}
keywords := append([]string(nil), item.Keywords...)
@@ -77,6 +82,7 @@ func (r *Registry) Metadata(ctx context.Context, principal *bouncer.Principal, t
Description: translateKey(ctx, tr, item.Description),
Category: translateKey(ctx, tr, item.Category), Icon: item.Icon,
Order: item.Order, Keywords: keywords, Model: item.Model,
Controller: item.Controller,
})
}
sort.SliceStable(settings, func(i, j int) bool {

View File

@@ -580,16 +580,48 @@ func relatedSelect(row reflect.Value, controller pact.AdminController, col ListC
}
field = field.Elem()
}
column := listRelationColumn(controller, col.Relation, col.Select)
if field.Kind() == reflect.Slice {
return relatedSelectMany(field, column)
}
if field.Kind() != reflect.Struct {
return nil, false
}
selected := fieldByColumn(field, listRelationColumn(controller, col.Relation, col.Select))
selected := fieldByColumn(field, column)
if !selected.IsValid() || !selected.CanInterface() {
return nil, false
}
return selected.Interface(), true
}
// relatedSelectMany joins the selected column of every related record with
// ", ", as Winter's list does for a to-many relation column. No related
// records leave the key unset, so the list shows its empty placeholder.
func relatedSelectMany(records reflect.Value, column string) (any, bool) {
values := make([]string, 0, records.Len())
for i := 0; i < records.Len(); i++ {
record := records.Index(i)
for record.Kind() == reflect.Pointer && !record.IsNil() {
record = record.Elem()
}
if record.Kind() != reflect.Struct {
continue
}
selected := fieldByColumn(record, column)
for selected.IsValid() && selected.Kind() == reflect.Pointer && !selected.IsNil() {
selected = selected.Elem()
}
if !selected.IsValid() || !selected.CanInterface() || selected.Kind() == reflect.Pointer {
continue
}
values = append(values, fmt.Sprint(selected.Interface()))
}
if len(values) == 0 {
return nil, false
}
return strings.Join(values, ", "), true
}
func listRelationColumn(controller pact.AdminController, relation, column string) string {
mapper, ok := controller.(pact.ListRelationColumnMapper)
if !ok || mapper == nil {

View File

@@ -196,6 +196,16 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
if _, exists := reg.settings[item.Code]; exists {
return fmt.Errorf("cabana: duplicate setting %s", item.Code)
}
if item.Controller != "" {
// A link entry opens an admin controller and needs no
// singleton form, so no AdminFS either.
compiled, err := compileSettingLink(plugin.ID(), item)
if err != nil {
return err
}
reg.settings[item.Code] = compiled
continue
}
if !hasAssets || assets == nil || assets.AdminFS() == nil {
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
}
@@ -250,6 +260,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
if err := reg.validatePermissions("setting "+code, setting.Item.Permissions); err != nil {
return err
}
if controller := setting.Item.Controller; controller != "" {
if _, ok := reg.byID[controller]; !ok {
return fmt.Errorf("cabana: setting %s references unknown controller %s", code, controller)
}
}
}
return nil
}

View File

@@ -17,7 +17,8 @@ import (
)
// CompiledSetting is a singleton settings registration after strict schema
// compilation and writable-field binding.
// compilation and writable-field binding. For a link entry (Item.Controller
// set) Form and Writable are nil.
type CompiledSetting struct {
PluginID string
Item pact.SettingsItem
@@ -34,6 +35,19 @@ type SettingsResult struct {
// SettingsService reads and transactionally updates compiled singleton rows.
type SettingsService struct{ DB *gorm.DB }
// compileSettingLink registers a settings entry that opens an admin
// controller. It declares no singleton form and no model; the controller ID
// itself is checked against the registry once every plugin is collected.
func compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetting, error) {
if item.Form != "" || item.NewModel != nil {
return nil, fmt.Errorf("cabana: setting %s links controller %s and declares a singleton form; declare one", item.Code, item.Controller)
}
if item.Model != "" {
return nil, fmt.Errorf("cabana: setting %s links controller %s and names a model", item.Code, item.Controller)
}
return &CompiledSetting{PluginID: pluginID, Item: item}, nil
}
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
if item.NewModel == nil {
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)

View File

@@ -0,0 +1,202 @@
package cabana
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// settingsLinkItem is a settings entry that opens an admin controller instead
// of a singleton form.
func settingsLinkItem() pact.SettingsItem {
return pact.SettingsItem{
Code: "locales", Label: "Locales", Description: "Manage locales", Category: "Demo",
Icon: "languages", Order: 30, Permissions: []string{"acme.demo.manage_settings"},
Controller: "acme.demo.widgets",
}
}
// settingsLinkPlugin declares only a link entry and ships no admin assets.
func settingsLinkPlugin(items ...pact.SettingsItem) contributionPlugin {
return contributionPlugin{
id: "acme.links",
permissions: []pact.Permission{{Code: "acme.links.unused", Roles: []string{"developer"}}},
settings: items,
}
}
// settingsLinkRegistry compiles the metadata plugin (one singleton setting)
// together with a plugin holding one controller link.
func settingsLinkRegistry(t *testing.T) *Registry {
t.Helper()
reg := metadataRegistry()
if err := compileContributions(reg, []party.Plugin{metadataPlugin(), settingsLinkPlugin(settingsLinkItem())}); err != nil {
t.Fatal(err)
}
return reg
}
func TestSettingsLinkCompilesWithoutAdminFS(t *testing.T) {
reg := settingsLinkRegistry(t)
stored, ok := reg.settings["locales"]
if !ok || stored == nil {
t.Fatal("link entry was not registered")
}
if stored.Form != nil || stored.Writable != nil || stored.Item.Controller != "acme.demo.widgets" || stored.PluginID != "acme.links" {
t.Fatalf("link entry = %#v", stored)
}
if _, ok := reg.Setting("locales"); ok {
t.Fatal("Registry.Setting returned a link entry")
}
if setting, ok := reg.Setting("demo"); !ok || setting.Form == nil {
t.Fatalf("singleton lookup = %#v %v", setting, ok)
}
}
func TestSettingsLinkCompileValidation(t *testing.T) {
withForm := settingsLinkItem()
withForm.Form = "models/settings/fields.yaml"
withFactory := settingsLinkItem()
withFactory.NewModel = func() any { return &singletonSetting{} }
withModel := settingsLinkItem()
withModel.Model = "Settings"
missing := settingsLinkItem()
missing.Controller = "acme.demo.missing"
duplicate := settingsLinkItem()
duplicate.Code = "demo"
unknownPermission := settingsLinkItem()
unknownPermission.Permissions = []string{"acme.demo.nope"}
cases := []struct {
name string
item pact.SettingsItem
want string
}{
{"form", withForm, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
{"model factory", withFactory, "cabana: setting locales links controller acme.demo.widgets and declares a singleton form"},
{"model", withModel, "cabana: setting locales links controller acme.demo.widgets and names a model"},
{"unknown controller", missing, "cabana: setting locales references unknown controller acme.demo.missing"},
{"duplicate code", duplicate, "duplicate setting demo"},
{"unknown permission", unknownPermission, "unknown permission"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := compileContributions(metadataRegistry(), []party.Plugin{metadataPlugin(), settingsLinkPlugin(tc.item)})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want %q", err, tc.want)
}
})
}
}
func TestSettingsLinkMetadata(t *testing.T) {
reg := settingsLinkRegistry(t)
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
_, settings := reg.Metadata(context.Background(), developer, nil)
if len(settings) != 2 {
t.Fatalf("developer settings = %#v", settings)
}
byCode := map[string]SettingsEntry{}
for _, entry := range settings {
byCode[entry.Code] = entry
}
link, single := byCode["locales"], byCode["demo"]
if link.Controller != "acme.demo.widgets" || link.Model != "" || link.Label != "Locales" || link.Order != 30 {
t.Fatalf("link entry = %#v", link)
}
if link.Keywords == nil {
t.Fatal("link entry keywords are nil")
}
if single.Controller != "" || single.Model != "Settings" {
t.Fatalf("singleton entry = %#v", single)
}
// The principal passes the item's permission but cannot open the
// controller (it requires acme.demo.access): the link is hidden, the
// singleton stays.
restricted := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.demo.manage_settings": true}}
_, settings = reg.Metadata(context.Background(), restricted, nil)
if len(settings) != 1 || settings[0].Code != "demo" {
t.Fatalf("restricted settings = %#v", settings)
}
// Controller access alone does not pass the item's own permission.
controllerOnly := &bouncer.Principal{ID: 3, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
_, settings = reg.Metadata(context.Background(), controllerOnly, nil)
if len(settings) != 0 {
t.Fatalf("controller-only settings = %#v", settings)
}
}
func TestSettingsLinkEndpoints(t *testing.T) {
reg := settingsLinkRegistry(t)
svc := &service{reg: reg}
developer := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: reg.rolePermissions("developer")}
request := func(method, rel, code string) *http.Request {
var body *strings.Reader
if method == http.MethodPut {
body = strings.NewReader(`{"enabled":true}`)
} else {
body = strings.NewReader("")
}
req := httptest.NewRequest(method, adminAPI(rel), body)
if code != "" {
req.SetPathValue("code", code)
}
return req.WithContext(bouncer.WithUser(req.Context(), developer))
}
for _, tc := range []struct {
name string
method string
rel string
handler func(*service, http.ResponseWriter, *http.Request)
}{
{"schema", http.MethodGet, "/settings/locales/schema", (*service).settingsSchema},
{"get", http.MethodGet, "/settings/locales", (*service).settingsGet},
{"put", http.MethodPut, "/settings/locales", (*service).settingsPut},
} {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
tc.handler(svc, rec, request(tc.method, tc.rel, "locales"))
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), `"not_found"`) {
t.Fatalf("%s %s = %d %s", tc.method, tc.rel, rec.Code, rec.Body.String())
}
})
}
rec := httptest.NewRecorder()
svc.settingsList(rec, request(http.MethodGet, "/settings", ""))
if rec.Code != http.StatusOK {
t.Fatalf("GET /settings = %d %s", rec.Code, rec.Body.String())
}
var envelope struct {
Data []map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if len(envelope.Data) != 2 {
t.Fatalf("GET /settings data = %#v", envelope.Data)
}
for _, entry := range envelope.Data {
controller, ok := entry["controller"]
if !ok {
t.Fatalf("entry without controller key: %#v", entry)
}
want := ""
if entry["code"] == "locales" {
want = "acme.demo.widgets"
}
if controller != want {
t.Fatalf("entry %v controller = %v, want %q", entry["code"], controller, want)
}
}
}

View File

@@ -12,7 +12,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`.
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
@@ -100,7 +100,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasMailTemplates` | Ships mail templates and layout aliases. |
| `pact.Permission` | One backend permission entry (code, tab, label, roles). |
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu. |
| `pact.SettingsItem` | One settings screen entry. |
| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
| `pact.AdminAssets` | Embedded tree of the plugin's admin YAML. |
| `pact.AdminRecordSource` | Supplies a new model record for the generic admin handlers. |

View File

@@ -398,7 +398,9 @@ type HasNavigation interface {
Navigation() []NavigationItem
}
// SettingsItem is one registerSettings() entry.
// SettingsItem is one registerSettings() entry. An item is either a singleton
// settings form (Model, Form and NewModel) or a link to an admin controller
// (Controller), never both.
type SettingsItem struct {
Code string
Label string
@@ -409,8 +411,13 @@ type SettingsItem struct {
Order int
Keywords []string
Permissions []string
Form string `json:"-"`
NewModel func() any `json:"-"`
// Controller, when set, makes the entry a link to that admin controller
// ID (vendor.plugin.controller): the equivalent of a WinterCMS
// registerSettings 'url' => Backend::url(...) entry. A link entry
// declares no Model, Form or NewModel and is not a singleton.
Controller string
Form string `json:"-"`
NewModel func() any `json:"-"`
}
// HasSettings is implemented by plugins that declare settings screens.