user form, backup: two gaps the mockup comparison found
Owner, 2026-10-18: "Что ещё из макетов мы также прошляпили?" - every mockup held against the panel; two small gaps beyond the queues: - user.html: with All ticked, a list's single domains are disabled (mockup user.html:34-36). A panel.js show-when rule with disable: the boxes data-single-out / data-single-in follow data-all-out / data-all-in, at load and on change; a disabled box is not submitted, which the server ignores with All anyway. node test testdata/alldomains_test.js (fails on the previous panel.js). - backup.html: "domain's settings" is a link, to Outbound › Domains (mockup backup.html:35 links a domain's settings; a server page has no one domain). Tests: go test ./internal/web/... pass, TestPanelJS with node 24. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
must be named in `docs/roadmap.md`. The two queues had been deferred on
|
||||
2026-10-09 with no roadmap row and were forgotten; development.md says so.
|
||||
|
||||
### Fixed
|
||||
|
||||
Two gaps a comparison of every mockup with the panel found (the rest
|
||||
matches):
|
||||
|
||||
- **Server › Users, the user form:** with *All outbound domains* (or *All
|
||||
inbound domains*) ticked, that list's single domains are disabled, as the
|
||||
mockup draws them - the server ignored them already, which the help line
|
||||
says. panel.js does it; without scripts they stay clickable.
|
||||
- **Server › Backup, Import a domain:** "the domain's settings" links to
|
||||
Outbound › Domains, where each domain's settings are, as the mockup's link
|
||||
does.
|
||||
|
||||
## [2.15.0] - 2026-10-18
|
||||
|
||||
The two proposals the security audit left as product choices. Migration
|
||||
|
||||
@@ -431,7 +431,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
|
||||
body := send(h.HandleUserEdit, &root, "GET", "/server/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String()
|
||||
for _, want := range []string{
|
||||
`name="all_domains"`, `name="all_inbound_domains"`, "out.example.com",
|
||||
`name="inbound_domain_ids" value="` + idStr(in.ID) + `" checked`,
|
||||
`name="inbound_domain_ids" value="` + idStr(in.ID) + `" data-single-in checked`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the user form is missing %q", want)
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestPanelJS(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skip("node is not installed")
|
||||
}
|
||||
for _, script := range []string{"poll_test.js", "unsaved_test.js", "filename_test.js"} {
|
||||
for _, script := range []string{"poll_test.js", "unsaved_test.js", "filename_test.js", "alldomains_test.js"} {
|
||||
t.Run(script, func(t *testing.T) {
|
||||
out, err := exec.Command(node, filepath.Join("testdata", script), filepath.Join("static", "panel.js")).CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -59,6 +59,7 @@ func TestBackupHasBothFormsAndTheEncryptionHooks(t *testing.T) {
|
||||
`name="password"`, `name="password_confirm"`, `minlength="12"`, `placeholder="at least 12 characters"`,
|
||||
`name="file"`, `accept=".json,.spde,application/json"`, `required data-import-file`,
|
||||
`data-import-password-fields`, `name="import_password"`, `<div class="box" id="import">`,
|
||||
`The export itself is made on the <a href="/outbound/domains">domain's settings</a>.`,
|
||||
)
|
||||
pageLacks(t, "Backup", out, `notification`)
|
||||
}
|
||||
@@ -157,8 +158,8 @@ func TestUserFormShowsWhatIsStoredUnderTheHandlersFieldNames(t *testing.T) {
|
||||
`name="password" type="password" autocomplete="new-password">`,
|
||||
`Leave empty to keep the current one.`,
|
||||
`<select id="role" name="role" data-global-role="global">`, `<option value="domain" selected>`, `<option value="global">`,
|
||||
`name="all_domains" value="1" checked`, `name="domain_ids" value="1">`, `name="domain_ids" value="3">`,
|
||||
`name="all_inbound_domains" value="1">`, `name="inbound_domain_ids" value="1">`, `name="inbound_domain_ids" value="2" checked`,
|
||||
`name="all_domains" value="1" data-all-out checked`, `name="domain_ids" value="1" data-single-out>`, `name="domain_ids" value="3" data-single-out>`,
|
||||
`name="all_inbound_domains" value="1" data-all-in>`, `name="inbound_domain_ids" value="1" data-single-in>`, `name="inbound_domain_ids" value="2" data-single-in checked`,
|
||||
`>Save user</button>`, `href="/server/users">Cancel</a>`,
|
||||
)
|
||||
pageLacks(t, "User", out, `name="password" type="password" autocomplete="new-password" required`, `disabled`,
|
||||
@@ -194,7 +195,11 @@ func TestUserFormOfTheOnlyGlobalUser(t *testing.T) {
|
||||
// domains is marked, so panel.js can hide the lists while the role is Global.
|
||||
func TestUserFormMarksTheListsTheGlobalRoleHides(t *testing.T) {
|
||||
out := renderSignedIn(t, "user", userFixture())
|
||||
pageHas(t, "User", out, `<select id="role" name="role" data-global-role="global">`)
|
||||
pageHas(t, "User", out, `<select id="role" name="role" data-global-role="global">`,
|
||||
// panel.js disables a list's single domains while its All is ticked
|
||||
// (testdata/alldomains_test.js).
|
||||
`name="all_domains" value="1" data-all-out`, `name="domain_ids" value="`, `data-single-out`,
|
||||
`name="all_inbound_domains" value="1" data-all-in`, `data-single-in`)
|
||||
if n := strings.Count(out, `<div class="box" data-domain-pick>`); n != 2 {
|
||||
t.Errorf("%d boxes are marked data-domain-pick, want the outbound and the inbound list", n)
|
||||
}
|
||||
|
||||
@@ -190,6 +190,24 @@
|
||||
target: "[data-domain-pick]",
|
||||
visible: function (el) { return el.value !== el.dataset.globalRole; }
|
||||
},
|
||||
// The user form: with "All outbound domains" (or inbound) ticked the
|
||||
// single domains of that list are ignored, and are shown disabled as the
|
||||
// mockup user.html draws them. A disabled box is not submitted (HTML
|
||||
// Living Standard, form-control-infrastructure.html#constructing-entry-list),
|
||||
// which changes nothing: the server ignores them with All. Without this
|
||||
// script they stay clickable, and the help line says they are ignored.
|
||||
{
|
||||
match: "input[data-all-out]",
|
||||
target: "input[data-single-out]",
|
||||
visible: function (el) { return !el.checked; },
|
||||
disable: true
|
||||
},
|
||||
{
|
||||
match: "input[data-all-in]",
|
||||
target: "input[data-single-in]",
|
||||
visible: function (el) { return !el.checked; },
|
||||
disable: true
|
||||
},
|
||||
{
|
||||
match: "input[data-encrypt-toggle]",
|
||||
target: "[data-encrypt-fields]",
|
||||
@@ -222,6 +240,10 @@
|
||||
// A block can be several elements (the user form's two lists of domains).
|
||||
var show = rule.visible(control);
|
||||
form.querySelectorAll(rule.target).forEach(function (target) {
|
||||
if (rule.disable) {
|
||||
target.disabled = !show;
|
||||
return;
|
||||
}
|
||||
// hidden alone does not hide an element whose own stylesheet gives it a
|
||||
// display (Bulma's .box), so Bulma's is-hidden helper goes with it.
|
||||
// HTML Living Standard, rendering.html#hidden-elements: [hidden] is
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Import}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the domain's settings page.</p>
|
||||
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="/outbound/domains">domain's settings</a>.</p>
|
||||
<form class="sp-form" method="post" action="{{.ImportAction}}" enctype="multipart/form-data">
|
||||
<div class="field"><label class="label" for="importfile">Domain export file</label>
|
||||
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">No file chosen</span></label></div></div>
|
||||
|
||||
@@ -34,16 +34,16 @@
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Outbound}}
|
||||
{{template "body_open"}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="all_domains" value="1"{{if .AllOut}} checked{{end}}> <strong>All outbound domains</strong>, including ones added later</label></div>{{range .OutDomains}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" value="{{.ID}}"{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="all_domains" value="1" data-all-out{{if .AllOut}} checked{{end}}> <strong>All outbound domains</strong>, including ones added later</label></div>{{range .OutDomains}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" value="{{.ID}}" data-single-out{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
|
||||
<p class="help">With <em>All</em> ticked the single domains are ignored.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
{{if .ShowInbound}}
|
||||
{{template "box_open" .Inbound}}
|
||||
{{template "body_open"}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="all_inbound_domains" value="1"{{if .AllIn}} checked{{end}}> <strong>All inbound domains</strong>, including ones added later</label></div>{{range .InDomains}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids" value="{{.ID}}"{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="all_inbound_domains" value="1" data-all-in{{if .AllIn}} checked{{end}}> <strong>All inbound domains</strong>, including ones added later</label></div>{{range .InDomains}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids" value="{{.ID}}" data-single-in{{if .Checked}} checked{{end}}> {{.Name}}</label></div>{{end}}
|
||||
<p class="help">Granted separately from outbound: the same name on both lists is two assignments. The <em>domain</em> role needs at least one domain, or <em>All</em>, on either list.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// The user form (Server › Users): with "All outbound domains" ticked the
|
||||
// single outbound domains are disabled, as the mockup user.html draws them, and
|
||||
// enabled again when it is unticked; the inbound list follows its own All the
|
||||
// same way, and neither touches the other. Run by TestPanelJS
|
||||
// (paneljs_test.go): node alldomains_test.js <panel.js>. Exits non-zero with
|
||||
// what went wrong.
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
|
||||
const failures = [];
|
||||
function check(cond, what) {
|
||||
if (!cond) failures.push(what);
|
||||
}
|
||||
|
||||
const listeners = {};
|
||||
const box = (attr, checked) => ({ attr, checked, disabled: false, matches: (sel) => sel === "input[" + attr + "]", closest: () => form });
|
||||
const allOut = box("data-all-out", true);
|
||||
const allIn = box("data-all-in", false);
|
||||
const singlesOut = [box("data-single-out", true), box("data-single-out", false)];
|
||||
const singlesIn = [box("data-single-in", true)];
|
||||
const form = {
|
||||
querySelectorAll: (sel) =>
|
||||
sel === "input[data-single-out]" ? singlesOut : sel === "input[data-single-in]" ? singlesIn : [],
|
||||
};
|
||||
const document = {
|
||||
hidden: false,
|
||||
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
|
||||
body: { addEventListener() {} },
|
||||
getElementById: () => null,
|
||||
querySelectorAll: (sel) =>
|
||||
sel === "input[data-all-out]" ? [allOut] : sel === "input[data-all-in]" ? [allIn] : [],
|
||||
};
|
||||
vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
|
||||
document, window: { addEventListener() {} }, htmx: { config: {}, ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console,
|
||||
});
|
||||
const fire = (type, ev) => (listeners[type] || []).forEach((fn) => fn(ev));
|
||||
|
||||
fire("DOMContentLoaded", {});
|
||||
check(singlesOut.every((b) => b.disabled), "at load, with All outbound ticked, the single outbound domains are not disabled");
|
||||
check(singlesIn.every((b) => !b.disabled), "at load, with All inbound unticked, a single inbound domain is disabled");
|
||||
|
||||
allOut.checked = false;
|
||||
fire("change", { target: allOut });
|
||||
check(singlesOut.every((b) => !b.disabled), "unticking All outbound leaves the single outbound domains disabled");
|
||||
check(singlesOut[0].checked && !singlesOut[1].checked, "unticking All outbound changed which single domains are ticked");
|
||||
|
||||
allIn.checked = true;
|
||||
fire("change", { target: allIn });
|
||||
check(singlesIn.every((b) => b.disabled), "ticking All inbound does not disable the single inbound domains");
|
||||
check(singlesOut.every((b) => !b.disabled), "ticking All inbound disabled the outbound list");
|
||||
|
||||
if (failures.length) {
|
||||
console.error(failures.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user