Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,36 @@ permissions:
contents: read

jobs:
forms:
name: Shared forms browser QA
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Verify exact tested source
env:
EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA"
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install isolated browser tooling
run: |
npm install --prefix "$RUNNER_TEMP/cms-ui" --no-audit --no-fund --save-exact playwright@1.61.1
"$RUNNER_TEMP/cms-ui/node_modules/.bin/playwright" install --with-deps chromium
- name: Exercise actual editor and platform forms
env:
CMS_PLAYWRIGHT_MODULE: ${{ runner.temp }}/cms-ui/package.json
CMS_UI_ARTIFACTS: ${{ runner.temp }}/cms-form-results
run: node adminui/testdata/form-usability.cjs
- uses: actions/upload-artifact@v4
if: always()
with:
name: cms-form-browser-results
path: ${{ runner.temp }}/cms-form-results
retention-days: 7
test:
runs-on: ubuntu-latest
services:
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ The module path must resolve an installed Playwright package; optionally set
and a test cookie auth hook. It does not exercise production authorization or
PostgreSQL restart durability.

The shared form layout/interaction regression needs no Go build or live service:

```sh
CMS_PLAYWRIGHT_MODULE=/path/to/playwright/package.json \
CMS_UI_ARTIFACTS=/tmp/cms-forms node adminui/testdata/form-usability.cjs
```

It drives the actual editor/platform UI at desktop, tablet and narrow mobile
widths against synthetic loopback APIs, including raw source resize/preservation,
draft/save/reload/conflict behavior, required fields, dates and site creation.
Optionally set `CMS_BROWSER_CHANNEL=msedge` to reuse an installed browser.
Hosted CI runs this harness on the exact source head and retains screenshots.

> ⚠️ **Experimental** — This plugin compiles and passes its unit tests but has not been validated in any active GoCodeAlone-internal production deployment. Use with caution. Please [open an issue](https://github.com/GoCodeAlone/workflow-plugin-cms/issues/new) if you adopt it so we can promote it to **verified** status.

[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
Expand Down
35 changes: 26 additions & 9 deletions adminui/static/admin.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
:root { --control-border: #768697; }
* { box-sizing: border-box; }
button { min-height:44px; padding:.6rem .9rem; border:1px solid var(--control-border); border-radius:4px; background:white; color:#192638; font:inherit; cursor:pointer; }
button:disabled { cursor:default; }
input,select,textarea { min-width:0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
Expand Down Expand Up @@ -128,7 +131,7 @@ form > button:hover { background: #155bb5; }
border-color: #1a73e8;
}

button.danger { background: #dc3545; }
button.danger { background: #b42332; color:white; border-color:#b42332; }
button.danger:hover { background: #b02a37; }

button.link {
Expand All @@ -149,10 +152,20 @@ button.link {
color: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
max-width: 24rem;
}
#toast.error { background: #dc3545; }
max-width: min(32rem, calc(100% - 2rem));
max-height:calc(100vh - 2rem);
overflow:auto;
display:flex;
align-items:flex-start;
gap:1rem;
overflow-wrap:anywhere;
z-index:10;
}
#toast.error { background: #b42332; }
#toast.ok { background: #198754; }
#toast > span { min-width:0; }
#toast button { flex:none; color:inherit; background:transparent; border-color:currentColor; padding:.35rem .55rem; }
#toast button:focus-visible { outline-color:white; }

details { margin-top: 1rem; }
details summary { cursor: pointer; padding: 0.5rem 0; }
Expand All @@ -164,9 +177,10 @@ details summary { cursor: pointer; padding: 0.5rem 0; }
background: white;
}

[data-event-tool] { display:grid; gap:.6rem; padding:1rem; border:1px solid var(--control-border); }
[data-event-tool] { display:grid; min-width:0; gap:.6rem; padding:1rem; border:1px solid var(--control-border); }
[data-event-tool][hidden] { display:none; }
[data-event-tool] label { display:grid; gap:.2rem; }
[data-event-tool] label { display:grid; grid-template-columns:minmax(0,1fr); gap:.4rem; }
[data-event-tool] label > input,[data-event-tool] label > select { grid-column:auto; min-height:44px; }
[data-event-tool] input { font:inherit; padding:.5rem; width:100%; }
button:focus-visible, a:focus-visible { outline:3px solid #154f9d; outline-offset:3px; }
@media(max-width:640px) { body{padding:.6rem} section{padding:.8rem} form{display:block} form label{display:block;margin:.8rem 0} form .form-row{display:block} table{font-size:.85rem} th,td{padding:.4rem} header{gap:.5rem;flex-wrap:wrap} }
Expand All @@ -187,19 +201,22 @@ h1 { font-size:1.6rem; } h2 { margin-top:0; font-size:1.45rem; } h3 { margin-top
#page-tabs a,.page-tabs a { padding:.65rem .85rem; border-radius:6px; text-decoration:none; color:#3c526d; font-weight:550; }
#page-tabs a[aria-current],.page-tabs a[aria-current] { background:#e7eefb; color:#174985; }
#page-editor-form { display:block; margin:0; }
.settings-pane { max-width:640px; display:block; }
.settings-pane { width:100%; max-width:640px; display:block; }
.settings-pane label { display:block; font-weight:550; margin:1rem 0; }
.settings-pane label input,.settings-pane label select { display:block; margin-top:.4rem; padding:.65rem; }
.settings-pane label input,.settings-pane label select { display:block; margin-top:.4rem; padding:.65rem; min-height:44px; }
.field-help { font-size:.875rem; font-weight:400; color:#4c6076; line-height:1.5; margin:.5rem 0 1rem; }
.content-layout { display:grid; grid-template-columns:210px minmax(0,1fr); gap:1.5rem; }
#section-tabs { display:flex; flex-direction:column; gap:.25rem; border-right:1px solid #dce2ea; padding-right:.8rem; }
#section-tabs a { padding:.65rem .75rem; border-radius:6px; text-decoration:none; line-height:1.4; overflow-wrap:anywhere; }
#section-tabs a[aria-current] { background:#e7eefb; color:#174985; font-weight:600; }
.content-editor { min-width:0; }.content-editor .form-row { display:block; }.content-editor .rich-editor { display:grid; }
#section-tabs[hidden] + .content-editor { grid-column:1 / -1; }
.editor-surface { min-height:20rem; border-color:var(--control-border); padding:1rem; line-height:1.6; }
.editor-surface section { padding:0; box-shadow:none; border:0; margin:0; background:none; }
.editor-surface img { max-height:330px; object-fit:contain; }
[data-editor-source] { width:100%; min-height:24rem; font:13px/1.55 ui-monospace,monospace; padding:1rem; border:1px solid var(--control-border); border-radius:6px; }
[data-editor-source] { width:100%; max-width:100%; min-height:24rem; height:clamp(24rem,60vh,48rem); resize:vertical; overflow:auto; font:14px/1.55 ui-monospace,monospace; padding:1rem; border:1px solid var(--control-border); border-radius:6px; }
[data-source-notice] { font-size:.875rem; color:#664300; background:#fff5db; padding:.8rem; border-radius:6px; }
.table-wrap { overflow-x:auto; } th,td { padding:.8rem; }.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); }
#page-preview { width:100%; min-height:650px; border:1px solid #dce2ea; margin-top:1rem; background:white; }
@media(max-width:700px) { body { padding:1rem; }.view { padding:1rem; }.editor-heading,.view-heading { align-items:flex-start; flex-direction:column; }.content-layout { grid-template-columns:1fr; }#section-tabs { flex-direction:row; flex-wrap:wrap; border-right:0; border-bottom:1px solid #dce2ea; padding:0 0 .8rem; }#section-tabs a { font-size:.85rem; }.editor-surface { min-height:16rem; }#page-preview { min-height:550px; } }
@media(max-width:700px) { input,select,textarea,[data-editor-source] { font-size:16px; } }
12 changes: 10 additions & 2 deletions adminui/static/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@ function pagePayload(form) {
}
function dateTimeLocalToISO(value){if(!value)return "";const d=new Date(value);return Number.isNaN(d.getTime())?"":d.toISOString()}
function isoToDateTimeLocal(value){if(!value)return "";const d=new Date(value);if(Number.isNaN(d.getTime()))return "";const pad=n=>String(n).padStart(2,"0");return d.getFullYear()+"-"+pad(d.getMonth()+1)+"-"+pad(d.getDate())+"T"+pad(d.getHours())+":"+pad(d.getMinutes())}
function toast(msg,kind){const t=$("#toast");t.textContent=msg;t.className=kind||"";t.hidden=false;setTimeout(()=>{t.hidden=true},3500)}
let toastTimer;
function toast(msg,kind){
const t=$("#toast"),previousFocus=document.activeElement;clearTimeout(toastTimer);
const message=document.createElement("span");message.textContent=msg;
const dismiss=document.createElement("button");dismiss.type="button";dismiss.textContent="Dismiss";dismiss.setAttribute("aria-label","Dismiss notification");
dismiss.onclick=()=>{const focused=t.contains(document.activeElement);t.hidden=true;clearTimeout(toastTimer);if(focused&&previousFocus?.isConnected)previousFocus.focus()};
t.replaceChildren(message,dismiss);t.className=kind||"";t.hidden=false;
if(kind!=="error")toastTimer=setTimeout(()=>{if(!t.contains(document.activeElement))t.hidden=true},5000);
}
function fail(err){console.error(err);toast(err.message,"error")}
function escapeHTML(s){return String(s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}
function canonicalID(s){return /^[1-9][0-9]*$/.test(s)&&Number.isSafeInteger(Number(s))?Number(s):0}
Expand Down Expand Up @@ -151,7 +159,7 @@ async function showPagePane(route,generation){
$$("[data-page-pane]",form).forEach(el=>el.hidden=el.dataset.pagePane!==(route.view==="html"?"content":route.view));
if(route.view==="content"||route.view==="html"){
const isHTML=route.view==="html",advanced=editor.dataset.advanced==="true";
source.hidden=!isHTML;surface.hidden=isHTML;$(".editor-toolbar",editor).hidden=isHTML||advanced||!(route.pid==="new"?pagePermissions.create:pagePermissions.edit);
source.hidden=!isHTML;$(":scope > #page-source-help",editor).hidden=!isHTML;surface.hidden=isHTML;$(".editor-toolbar",editor).hidden=isHTML||advanced||!(route.pid==="new"?pagePermissions.create:pagePermissions.edit);
surface.contentEditable=String(!advanced&&(route.pid==="new"?pagePermissions.create:pagePermissions.edit));
const sections=$("#section-tabs");sections.hidden=isHTML;sections.replaceChildren();
const children=[...surface.children],plain=[...surface.childNodes].some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim());
Expand Down
9 changes: 5 additions & 4 deletions adminui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
<nav id="page-tabs" aria-label="Page views"></nav>
<form id="page-editor-form" novalidate><input type="hidden" name="id" data-page-field>
<div data-page-pane="publishing" hidden class="settings-pane"><h3>Page &amp; publishing</h3><p>Save publishes only to this site's configured destinations. Review sites remain isolated from production.</p>
<label>Title <input name="title" data-page-field required maxlength="250" placeholder="Page title"></label><label>Path <input name="path" data-page-field required pattern="/.*" placeholder="/welcome"></label>
<label>Title (required) <input name="title" data-page-field required maxlength="250" placeholder="Page title"></label><label>Path (required) <input name="path" data-page-field required pattern="/.*" placeholder="/welcome" aria-describedby="page-path-help"></label><p id="page-path-help" class="field-help">Start with /, for example /welcome. This is the page's address within this site.</p>
<label>Status <select name="status" data-page-field><option value="draft">draft</option><option value="published">published</option><option value="scheduled">scheduled</option><option value="archived">archived</option></select></label>
<label>Publish at <input name="publish_at" data-page-field type="datetime-local"></label><label>Unpublish at <input name="unpublish_at" data-page-field type="datetime-local"></label><p id="schedule-zone"></p></div>
<div data-page-pane="appearance" hidden class="settings-pane"><h3>Appearance</h3><p>Choose a template from this site's own bundle, then check Preview.</p><label>Template <input name="template_id" data-page-field list="page-templates" placeholder="Choose a tenant template"></label></div>
<label>Publish at (optional) <input name="publish_at" data-page-field type="datetime-local" aria-describedby="schedule-zone"></label><label>Unpublish at (optional) <input name="unpublish_at" data-page-field type="datetime-local" aria-describedby="schedule-zone"></label><p id="schedule-zone" class="field-help"></p></div>
<div data-page-pane="appearance" hidden class="settings-pane"><h3>Appearance</h3><p id="template-help">Choose a template from this site's own bundle, then check Preview. Leave blank to render the page body without a template.</p><label>Template (optional) <input name="template_id" data-page-field list="page-templates" placeholder="Choose a tenant template" aria-describedby="template-help"></label></div>
<div id="content-pane" data-page-pane="content" hidden><div class="content-layout"><nav id="section-tabs" aria-label="Content sections"></nav><div class="content-editor"><h3 id="section-title">Content</h3> <div class="form-row full">
<span class="sr-only">Body</span>
<div class="rich-editor" data-rich-editor>
Expand All @@ -27,7 +27,8 @@
<button type="button" data-editor-action="source">HTML</button>
</div>
<div class="editor-surface" contenteditable="true" role="textbox" aria-multiline="true" data-editor-surface aria-label="Page body"></div>
<textarea name="body_html" rows="12" data-editor-source aria-label="Page HTML source" hidden></textarea>
<p id="page-source-help" class="field-help" hidden>Edit the page's HTML. Drag the lower-right corner to make the editor taller.</p>
<textarea name="body_html" rows="12" data-editor-source aria-label="Page HTML source" aria-describedby="page-source-help" spellcheck="false" autocapitalize="off" hidden></textarea>
</div>
</div>
</div></div></div>
Expand Down
6 changes: 3 additions & 3 deletions adminui/static/platform.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Multisite administration</title><link rel="stylesheet" href="{{.BasePath}}/admin.css"></head><body data-cms-mode="platform" data-cms-base="{{.BasePath}}">
<header class="app-header"><div><p class="eyebrow">Platform workspace</p><h1>Multisite administration</h1></div></header><nav class="page-tabs" aria-label="Platform views"><a data-route="/tenants" href="{{.BasePath}}/tenants">Tenants</a><a data-route="/tenants/new" href="{{.BasePath}}/tenants/new">New tenant</a><a data-route="/cache" href="{{.BasePath}}/cache">Cache</a></nav>
<main id="main-content"><section id="tenants-section" class="view"><h2>Tenants</h2><p>Manage site registration and domains. Page content is edited in the separate Site editor.</p><div class="table-wrap"><table><thead><tr><th>Tenant</th><th>Slug</th><th>Theme</th><th></th></tr></thead><tbody id="tenants-body"></tbody></table></div></section>
<section id="new-tenant-section" class="view" hidden><h2>New tenant</h2><form id="new-tenant-form" class="settings-pane"><label>Slug <input name="slug" required pattern="[a-z0-9-]+" placeholder="acme"></label><label>Label <input name="label" required placeholder="Acme Inc"></label><label>Theme <input name="theme_id" placeholder="default"></label><button type="submit">Create tenant</button></form></section>
<section id="domains-section" class="view" hidden><p id="td-slug" class="eyebrow"></p><h2>Domains</h2><div class="table-wrap"><table><thead><tr><th>Host</th><th>Subsite</th><th>Kind</th><th></th></tr></thead><tbody id="domains-body"></tbody></table></div><form id="new-domain-form" class="settings-pane"><label>Host <input name="host" required placeholder="acme.example"></label><label>Subsite <input name="subsite_label" placeholder="(root)"></label><label>Kind <select name="kind"><option value="vanity">vanity</option><option value="preview">preview</option><option value="subdomain">subdomain</option></select></label><button type="submit">Add domain</button></form></section>
<section id="cache-section" class="view" hidden><h2>Tenant cache</h2><p>Reload the platform tenant resolver and page caches after reviewed administrative changes.</p><button id="btn-reload" type="button">Reload tenants cache</button></section><section id="route-error" class="view" hidden><h2>View unavailable</h2><p id="route-error-message"></p><a data-route="/tenants" href="{{.BasePath}}/tenants">Return to tenants</a></section></main><div id="toast" role="status" aria-live="polite" hidden></div><script src="{{.BasePath}}/admin.js"></script></body></html>
<section id="new-tenant-section" class="view" hidden><h2>New tenant</h2><form id="new-tenant-form" class="settings-pane"><label>Slug (required) <input name="slug" required pattern="[a-z0-9-]+" placeholder="acme" aria-describedby="tenant-slug-help"></label><p id="tenant-slug-help" class="field-help">Use lowercase letters, numbers and hyphens.</p><label>Label (required) <input name="label" required placeholder="Acme Inc"></label><label>Theme (optional) <input name="theme_id" placeholder="default"></label><button type="submit">Create tenant</button></form></section>
<section id="domains-section" class="view" hidden><p id="td-slug" class="eyebrow"></p><h2>Domains</h2><div class="table-wrap"><table><thead><tr><th>Host</th><th>Subsite</th><th>Kind</th><th></th></tr></thead><tbody id="domains-body"></tbody></table></div><form id="new-domain-form" class="settings-pane"><label>Host (required) <input name="host" required placeholder="acme.example" aria-describedby="domain-host-help"></label><p id="domain-host-help" class="field-help">Enter a hostname, for example acme.example, without https:// or a page path.</p><label>Subsite (optional) <input name="subsite_label" placeholder="(root)"></label><label>Kind <select name="kind"><option value="vanity">vanity</option><option value="preview">preview</option><option value="subdomain">subdomain</option></select></label><button type="submit">Add domain</button></form></section>
<section id="cache-section" class="view" hidden><h2>Tenant cache</h2><p>Reload the platform tenant resolver and page caches after reviewed administrative changes.</p><button id="btn-reload" type="button">Reload tenants cache</button></section><section id="route-error" class="view" hidden><h2>View unavailable</h2><p id="route-error-message"></p><a data-route="/tenants" href="{{.BasePath}}/tenants">Return to tenants</a></section></main><div id="toast" role="status" aria-live="polite" hidden></div><script src="{{.BasePath}}/admin.js"></script></body></html>
Loading
Loading