milestone 23 s2: react aria primitives and their cluster

This commit is contained in:
2026-08-12 22:12:18 +02:00
parent 994bbf922c
commit 01e455c8af
32 changed files with 2316 additions and 532 deletions
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+18
View File
@@ -39,7 +39,12 @@ mbedtls url=https://github.com/Mbed-TLS/mbedtls/archive/refs/tags/mbedtls-3.6.7.
sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8
[npm runtime closure]
@internationalized/date 3.12.3 Apache-2.0
@internationalized/number 3.6.7 Apache-2.0
@internationalized/string 3.2.10 Apache-2.0
@react-types/shared 3.36.1 Apache-2.0
@stylexjs/stylex 0.19.0 MIT
@swc/helpers 0.5.23 Apache-2.0
@tanstack/history 1.162.0 MIT
@tanstack/query-core 5.101.4 MIT
@tanstack/react-query 5.101.4 MIT
@@ -47,6 +52,9 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
@tanstack/react-store 0.9.3 MIT
@tanstack/router-core 1.171.15 MIT
@tanstack/store 0.9.3 MIT
aria-hidden 1.2.6 MIT
client-only 0.0.1 MIT
clsx 2.1.1 MIT
cookie-es 3.1.1 MIT
css-mediaquery 0.1.2 BSD
invariant 2.2.4 MIT
@@ -54,11 +62,15 @@ isbot 5.2.1 Unlicense
js-tokens 4.0.0 MIT
loose-envify 1.4.0 MIT
react 19.2.8 MIT
react-aria 3.51.0 Apache-2.0
react-aria-components 1.20.0 Apache-2.0
react-dom 19.2.8 MIT
react-stately 3.49.0 Apache-2.0
scheduler 0.27.0 MIT
seroval 1.5.6 MIT
seroval-plugins 1.5.6 MIT
styleq 0.2.1 MIT
tslib 2.8.1 0BSD
use-sync-external-store 1.6.0 MIT
[npm build-time generators whose output ships]
@@ -70,6 +82,8 @@ vite 8.1.5 MIT
alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
[npm packages bundled into web/dist]
@internationalized/string
@stylexjs/stylex
@tanstack/history
@tanstack/query-core
@tanstack/react-query
@@ -77,7 +91,11 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
@tanstack/react-store
@tanstack/router-core
@tanstack/store
clsx
react
react-aria
react-aria-components
react-dom
react-stately
scheduler
use-sync-external-store
+32 -5
View File
@@ -45,11 +45,26 @@
// sourcemap build of a component calling stylex.props shows none of them in any
// `sources` list.
//
// StyleX itself is inventoried below although nothing imports it yet, so no
// chunk carries it today. The licence is carried now because the same sourcemap
// build settles what the page conversion will land, and it is not the obvious
// answer: only @stylexjs/stylex appears as a bundled package, and styleq
// arrives inside it. The published lib/es/stylex.mjs vendors styleq's source
// Seven more joined that list with React Aria, and the sourcemap build is again
// what settled which: @internationalized/date, @internationalized/number,
// @react-types/shared, @swc/helpers, aria-hidden, client-only and tslib are in
// the runtime closure but contribute no module to any chunk. The date and number
// packages serve the calendar and number-field components ruling 4 keeps out of
// scope; @react-types/shared is types only and emits no runtime code;
// client-only is a build-time marker for React Server Components; @swc/helpers
// and tslib are downlevelling helpers the published ESM builds never reach for;
// and aria-hidden sits behind a react-aria path the four adopted components do
// not take. Their licences differ and are recorded one by one in
// src/licenses_drift_test.zig rather than lumped together: @internationalized/
// date, @internationalized/number, @react-types/shared and @swc/helpers are
// Apache-2.0, tslib is 0BSD, aria-hidden and client-only are MIT. Keeping all
// seven out of the bundle is a fact worth re-checking rather than assuming, so
// the guard fails if any of them turns up in a `sources` list.
//
// StyleX now ships: the converted pages call stylex.props, and a sourcemap build
// puts @stylexjs/stylex in the bundle. What that build settles is not the
// obvious answer, though: only @stylexjs/stylex appears as a bundled package,
// and styleq arrives inside it. The published lib/es/stylex.mjs vendors styleq's source
// instead of importing it, so styleq's bytes ship while styleq never appears in
// a `sources` list — no guard would raise it, which is why it is written down
// here.
@@ -109,6 +124,18 @@
.note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.",
.file = "tanstack-mit.txt",
},
.{
.component = "React Aria (react-aria-components, react-aria, react-stately, @internationalized/string)",
.version = "react-aria-components 1.20.0, react-aria 3.51.0, react-stately 3.49.0, @internationalized/string 3.2.10",
.note = "The dialog, alert dialog, tab and select behaviour of the admin UI, bundled into the JavaScript embedded in the binary. The first Apache-2.0 npm dependency this project has taken, and the first non-MIT one: Mokhtar Mial accepted Apache-2.0 inbound for nxdns on 2026-08-12, which is the decision that let these four ship. Apache-2.0 Section 4 attribution is satisfied by carrying the licence text in THIRD-PARTY-NOTICES, which the file below does; none of the four ships a NOTICE file, so 4(d) adds nothing. All four carry a byte-identical LICENSE. In the tarballs and in the image.",
.file = "react-aria-apache-2.0.txt",
},
.{
.component = "clsx",
.version = "clsx 2.1.1",
.note = "The class-name joiner React Aria calls when it merges a render-prop class name with its own. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
.file = "clsx-mit.txt",
},
.{
.component = "StyleX",
.version = "@stylexjs/stylex 0.19.0",
+2
View File
@@ -51,6 +51,8 @@ pub const texts: []const Text = &.{
.{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") },
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
.{ .name = "react-aria-apache-2.0.txt", .body = @embedFile("react-aria-apache-2.0.txt") },
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
.{ .name = "tailwindcss-mit.txt", .body = @embedFile("tailwindcss-mit.txt") },
+25
View File
@@ -0,0 +1,25 @@
React Aria: react-aria-components, react-aria, react-stately and
@internationalized/string, bundled into the admin UI JavaScript that is
embedded in every nxdns binary.
Copyright 2019 Adobe
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
these files except in compliance with the License. You may obtain a copy of the
License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
The full text of the Apache License, Version 2.0 is reproduced above, in the
Mbed TLS section of this file. The LICENSE file each of these four packages
ships is that same document: diffing it against the Mbed TLS copy shows one
differing line, the sample copyright inside the "APPENDIX: How to apply the
Apache License to your work" boilerplate, which is not part of the licence
terms. None of the four ships a NOTICE file, so Section 4(d) attaches nothing
further.
+115
View File
@@ -347,6 +347,121 @@ Tailwind dependency pair and its vite plugin line and CSS import.
- [ ] `web/src/lib/types.ts` and `web/src/lib/contractSamples.gen.ts` are
byte-identical to HEAD.
## Recorded (implementation)
Written during S1 and the S2 hand-off. Each entry is a place the spec was wrong
or a decision the spec did not have.
### S1 could not put StyleX in the bundle, and its acceptance says otherwise
S1 owns no page file, so no production module imports StyleX and rollup emits
none of it. Two S1 boxes — "`npm run build` emits StyleX CSS into the `web/dist`
assets" and "`assert-bundled` passes with `@stylexjs/stylex` in the recorded
set" — are therefore unmeetable inside S1 as scoped, and the recorded bundled
set is unchanged at the S1 commit. Both were proven instead against a temporary
probe component, then deferred to S2, which converts real pages and does change
the bundled set. Ruling 8's "S1 adds `@stylexjs/stylex` ... to the runtime
closure and the bundled set" is half right: the closure moves in S1, the bundled
set in S2. The inventory entry for StyleX is anticipatory at the S1 commit,
which is deliberate — see the styleq entry for why it cannot wait.
### The probe results, recorded rather than left as a claim
Against the temporary probe: the built CSS carries `@layer priority1..N` and the
token variables, resolving to `#fafafa` light and `#09090b` dark with
lightningcss `lab()` fallbacks. The dev server needs no wiring of its own — the
plugin injects `<link rel="stylesheet" href="/virtual:stylex.css">` into
`index.html`, so the `DevStyleXInject` component in StyleX's Vite guide is for
frameworks that bypass Vite's HTML transform and is not used here. Editing a
token regenerates the virtual stylesheet and the dev server issues a page
reload.
### S1 wrote outside its owned files, and outside an anti-requirement
The licence guard forced it: `licenses/stylex-mit.txt`, `licenses/styleq-mit.txt`
and their registration in `licenses/licenses.zig`, plus the `npm_not_shipped`
array in `src/licenses_drift_test.zig`. The last contradicts the "no edits ...
under `src/`" anti-requirement below. The anti-requirement is amended, not
ignored: `src/licenses_drift_test.zig` is writable for its licence-fact arrays
and, from S2, for the per-package licence change described next. The rest of
`src/` stays closed.
### Apache-2.0 inbound: a decision, not a deduction
`react-aria-components` 1.20.0 brings eight Apache-2.0 packages
(react-aria-components, react-aria, react-stately, `@react-types/shared`, three
`@internationalized/*`, `@swc/helpers`) and `tslib` under 0BSD into an EUPL-1.2
project. The user accepted Apache-2.0 inbound on 2026-08-12 and authorised the
guard change it needs. This is recorded as their decision because nothing in the
licence settles it: EUPL-1.2's appendix does not list Apache-2.0 — it names GPL,
AGPL, OSL, EPL, CeCILL, MPL, LGPL, CC BY-SA, EUPL and LiLiQ — and that appendix
governs Article 5 outbound relicensing, not inbound consumption. Any claim that
the appendix permits this is false and must not be written into the inventory.
`src/licenses_drift_test.zig:122` hardcodes a single expected licence and
becomes per-package in S2; the guard stays strict, an unexpected licence still
fails.
### Ruling 8's picture of the React Aria closure is wrong
There are no `@react-aria/*` or `@react-stately/*` scoped packages. RAC 1.20.0
ships consolidated `react-aria` and `react-stately`. The runtime closure it adds
is the eleven packages named above plus `aria-hidden`, `clsx` and `client-only`
under MIT — not the "dozens" the ruling anticipated.
### Ruling 11's claim about the tab tests is wrong
`LocalDnsPage.tsx` already renders `role="tablist"` and `role="tab"` by hand, so
no test query moves from `getByRole("button")` to `getByRole("tab")`. What RAC
adds there is keyboard arrow-key navigation, which the hand-rolled switcher
lacks, and that is what the S2 acceptance box asserts.
### `formCard` is a layout change, not a port
`space-y-3` set sibling margins on a block container; the StyleX version is a
flex column with `gap`. StyleX cannot express the `> * + *` selector `space-y`
compiles to, so `gap` is the only mechanism available. Both call sites
(`RecordsTab`, `ZonesTab`) are plain vertical form stacks, where the difference
in child sizing and margin behaviour does not show. Recorded against ruling 7,
which pins layout structure.
### S2 needed two build-config changes the spec did not anticipate
Both are in files S1 and S3 own, both were forced, and neither is optional.
`web/vitest.setup.ts` (new) plus `setupFiles` in `web/vite.config.ts`: jsdom
implements no `CSS` interface at all, and React Aria's collection code calls
`CSS.escape` unguarded when it looks an item up by key
(`react-aria/dist/private/selection/utils.mjs:22`). Every RAC Select and Tabs
test threw `Cannot read properties of undefined (reading 'escape')` before this
landed. The setup file implements CSSOM's *serialize an identifier* algorithm
rather than approximating it, because a wrong escape would break the key lookups
silently instead of loudly.
The `@` alias passed to `stylex.vite()` in `web/vite.config.ts`: StyleX resolves
the `.stylex.ts` theme file itself and cannot see Vite's `resolve.alias`, so
`import { colors } from "@/ui/tokens.stylex"` in a feature file failed the build
with "Could not resolve the path to the imported file". Relative imports were the
alternative; passing the alias keeps the repo's `@/` convention instead.
### RAC Select changes the accessible name of a labelled control
`useSelect` labels the trigger button with `aria-labelledby="<value> <label>"`,
so a select labelled "Type" showing "A" has the accessible name "A Type", and
`getByLabelText("Type")` no longer finds it. Converted tests query
`getByRole("button", { name: /Type$/ })` and read the options by opening the
listbox. RAC also renders a hidden native `<select>` for form submission; it sits
inside an `aria-hidden` container, so role and label queries skip it.
### Two notes for S3
`textMuted` resolves to zinc-400 in dark mode. Several current `text-zinc-500`
call sites carry no dark override and stay zinc-500, so the token cannot replace
those without a shade shift — decide per call site rather than sweeping.
Vitest began reporting "Tests closed successfully but something prevents Vite
server from exiting" with the StyleX plugin in the pipeline. The suite passes
and exits 0. It should not be allowed to become the accepted baseline.
## Anti-requirements
- No router or query changes: TanStack Router and TanStack Query stay,
+95 -17
View File
@@ -66,6 +66,13 @@ const npm_not_shipped = [_][]const u8{
"invariant",
"js-tokens",
"loose-envify",
"@internationalized/date",
"@internationalized/number",
"@react-types/shared",
"@swc/helpers",
"aria-hidden",
"client-only",
"tslib",
};
/// Components the shipped artifacts contain that no dependency file mentions,
@@ -112,15 +119,61 @@ const ZigDependencyVersion = struct {
},
};
/// The licence every *shipped* npm package carries today, and the licence of
/// every npm text under `licenses/`. The identity file records a licence token
/// per package; nothing used to read it, so a package that relicensed passed as
/// long as its version had not moved. A token other than this one needs its own
/// licence text and a decision about EUPL-1.2 compatibility, which is a human
/// review, not a paste. The `npm_not_shipped` packages are exempt — they
/// redistribute nothing, and one of them is Unlicense.
/// The licence each npm package of the recorded sets carries today. The identity
/// file records a licence token per package; nothing used to read it, so a
/// package that relicensed passed as long as its version had not moved.
///
/// MIT is the default because almost every package here is MIT, so only the
/// exceptions are written down. A package whose recorded token stops matching
/// its expectation fails — including the packages below, which is the point of
/// naming them rather than waving them through: a new licence on any of them is
/// a new decision, not a fact to absorb quietly.
///
/// Adding a name here is a human review, not a paste. A licence this project has
/// not taken before needs its own text under `licenses/`, an inventory entry
/// that says who accepted it and when, and whatever that licence's attribution
/// terms require. Apache-2.0 arrived that way: Mokhtar Mial accepted it inbound
/// on 2026-08-12, and `licenses/react-aria-apache-2.0.txt` carries the text
/// Section 4 asks for.
///
/// Packages that put no byte in `web/dist` are here too. They redistribute
/// nothing today, so their licence carries no obligation today — but "not
/// shipped" is a claim about the build that a future build can falsify, and
/// recording the licence now means the answer is already reviewed when it does.
const npm_expected_licence = "MIT";
const npm_licence_exceptions = [_]NpmLicence{
// Shipped: React Aria and the one @internationalized package it pulls into
// the bundle.
.{ .name = "react-aria-components", .licence = "Apache-2.0" },
.{ .name = "react-aria", .licence = "Apache-2.0" },
.{ .name = "react-stately", .licence = "Apache-2.0" },
.{ .name = "@internationalized/string", .licence = "Apache-2.0" },
// Not shipped: the rest of the React Aria closure.
.{ .name = "@internationalized/date", .licence = "Apache-2.0" },
.{ .name = "@internationalized/number", .licence = "Apache-2.0" },
.{ .name = "@react-types/shared", .licence = "Apache-2.0" },
.{ .name = "@swc/helpers", .licence = "Apache-2.0" },
.{ .name = "tslib", .licence = "0BSD" },
// Not shipped: reached only through @tanstack/router-core's server paths
// and @stylexjs/stylex's compiler.
.{ .name = "isbot", .licence = "Unlicense" },
.{ .name = "css-mediaquery", .licence = "BSD" },
};
const NpmLicence = struct {
name: []const u8,
licence: []const u8,
};
/// The reviewed licence for one package: its exception, or MIT.
fn expectedLicence(name: []const u8) []const u8 {
for (npm_licence_exceptions) |entry| {
if (std.mem.eql(u8, entry.name, name)) return entry.licence;
}
return npm_expected_licence;
}
/// The two licence texts that must be reproduced in full, pinned by content.
/// The marker-string probes below prove the right *document* is present; they
/// cannot tell a complete Apache-2.0 from one with its middle sections deleted,
@@ -490,22 +543,21 @@ test "the CA bundle entry names the Alpine release the Dockerfile pins" {
}
}
test "every npm package in the recorded closure ships under the licence the inventory assumes" {
test "every npm package in the recorded closure carries the licence the inventory expects" {
for ([_][]const u8{ npm_section, generator_section }) |header| {
const recorded = try recordedSection(header);
var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine;
// A package that puts no byte in web/dist redistributes nothing, so
// its licence carries no obligation. isbot is Unlicense and is one
// of these.
if (isNotShipped(pkg.name)) continue;
if (!std.mem.eql(u8, pkg.licence, npm_expected_licence)) {
const expected = expectedLicence(pkg.name);
if (!std.mem.eql(u8, pkg.licence, expected)) {
std.debug.print(
"npm package '{s}' is recorded under '{s}', not {s}. Every npm licence text under" ++
" licenses/ is an {s} text, so this package needs one of its own and a" ++
" decision about EUPL-1.2 compatibility.\n",
.{ pkg.name, pkg.licence, npm_expected_licence, npm_expected_licence },
"npm package '{s}' is recorded under '{s}', but the reviewed expectation is" ++
" '{s}'. Work out what the new licence means for the shipped artifacts, give" ++
" it a text under licenses/ and an inventory entry naming who accepted it if" ++
" this project has not taken it before, then record the new expectation in" ++
" npm_licence_exceptions.\n",
.{ pkg.name, pkg.licence, expected },
);
return error.NpmLicenceChanged;
}
@@ -513,6 +565,32 @@ test "every npm package in the recorded closure ships under the licence the inve
}
}
test "every npm licence exception names a package the identity file records" {
const closure = try recordedSection(npm_section);
const generators = try recordedSection(generator_section);
for (npm_licence_exceptions) |entry| {
// A stale exception is how a package quietly regains the MIT default
// after it leaves the tree and comes back under a different licence.
if (recordsPackage(closure, entry.name) or recordsPackage(generators, entry.name)) continue;
std.debug.print(
"npm_licence_exceptions names '{s}', which licenses/dependency-identity.txt no longer" ++
" records. Drop the exception when the package leaves the closure.\n",
.{entry.name},
);
return error.StaleLicenceException;
}
}
/// Whether a recorded `<package> <version> <licence>` section names a package.
fn recordsPackage(section: []const u8, name: []const u8) bool {
var lines = std.mem.tokenizeScalar(u8, section, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse continue;
if (std.mem.eql(u8, pkg.name, name)) return true;
}
return false;
}
test "the licence texts that must be reproduced in full are unmodified" {
for (pinned_texts) |pinned| {
const body = textBody(pinned.file) orelse {
+131 -3
View File
@@ -12,6 +12,7 @@
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
},
"devDependencies": {
@@ -620,6 +621,33 @@
}
}
},
"node_modules/@internationalized/date": {
"version": "3.12.3",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz",
"integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz",
"integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/string": {
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz",
"integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1046,6 +1074,15 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@react-types/shared": {
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
"integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -1389,6 +1426,15 @@
"unplugin": "^2.3.11"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2419,6 +2465,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -2527,6 +2585,21 @@
"node": ">=18"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -3422,6 +3495,46 @@
"node": ">=0.10.0"
}
},
"node_modules/react-aria": {
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz",
"integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.49.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-aria-components": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz",
"integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.51.0",
"react-stately": "3.49.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
@@ -3441,6 +3554,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-stately": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.49.0.tgz",
"integrity": "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3694,9 +3824,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/typescript": {
"version": "7.0.2",
+1
View File
@@ -29,6 +29,7 @@
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
},
"devDependencies": {
@@ -41,14 +41,20 @@ const RESPONSES: Record<string, unknown> = {
};
let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null;
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => {
resolveUpdate = resolve;
@@ -227,3 +233,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useRealTimers();
}
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleted).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that source", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
+131 -49
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
@@ -13,21 +14,89 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection";
import {
dangerLinkButtonClass,
focusRing,
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
done: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.border,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.text,
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
numeric: {
fontVariantNumeric: "tabular-nums",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function BlocklistsPage() {
const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
const create = useMutation(blocklistCreateMutation(queryClient));
const save = useMutation(blocklistUpdateMutation(queryClient));
@@ -57,10 +126,10 @@ export default function BlocklistsPage() {
});
}
function deleteBlocklist(b: Blocklist) {
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
remove.mutate(b.id);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
const formError = editing === null ? create.error : save.error;
@@ -68,60 +137,56 @@ export default function BlocklistsPage() {
return (
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Blocklists</h1>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
</div>
{updateNow.isSuccess && !updateNow.isPending && (
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status">
<p {...stylex.props(styles.done)} role="status">
Update completed; source status refreshed below.
</p>
)}
<InlineError error={updateNow.error} />
{blocklists.length === 0 ? (
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>Name</th>
<th className={thClass}>URL</th>
<th className={thClass}>Enabled</th>
<th className={thClass}>Domains</th>
<th className={thClass}>Wildcards</th>
<th className={thClass}>Skipped regex</th>
<th className={thClass}>Last updated</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td className={tdClass}>
<span className="font-medium">{b.name}</span>
{b.is_suggested && (
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
Suggested
</span>
)}
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
</td>
<td className={tdClass}>
<span className="block max-w-72 truncate" title={b.url}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={b.url}>
{b.url}
</span>
</td>
<td className={tdClass}>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
aria-label={`${b.name} enabled`}
@@ -129,32 +194,36 @@ export default function BlocklistsPage() {
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
</td>
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td, styles.numeric)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, styles.numeric)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, styles.numeric)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td className={tdClass}>
<div className="flex gap-3">
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => setEditing(b)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => deleteBlocklist(b)}
onClick={() => setPendingDelete(b)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -179,6 +248,19 @@ export default function BlocklistsPage() {
/>
<SourceStatusSection sources={sources} namesById={namesById} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete blocklist"
message={
pendingDelete === null
? ""
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
+76 -60
View File
@@ -1,9 +1,12 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
import Dialog from "@/ui/Dialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
@@ -12,7 +15,34 @@ interface Props {
onClose: () => void;
}
const dialogInputClass = `mt-1 w-full ${smallInputClass}`;
const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
dialogInput: {
marginTop: "0.25rem",
width: "100%",
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
},
});
export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient();
@@ -22,64 +52,50 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const readOnly = useReadOnlyConfig();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div
role="dialog"
aria-modal="true"
aria-label={`Edit client ${client.ip}`}
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<form
{...stylex.props(styles.form)}
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
>
<h2 className="text-lg font-semibold">Edit {client.ip}</h2>
<form
className="mt-4 space-y-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
>
<label className="block text-sm font-medium">
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className={dialogInputClass}
autoFocus
/>
</label>
<label className="block text-sm font-medium">
Group
<select
value={String(groupId)}
onChange={(event) => setGroupId(Number(event.target.value))}
className={dialogInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
</label>
<InlineError error={mutation.error} />
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className={buttonClass}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Save
</button>
</div>
</form>
</div>
</div>
<label {...stylex.props(styles.fieldLabel)}>
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
/>
</label>
<Select
label="Group"
variant="compactField"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save
</button>
</div>
</form>
</Dialog>
);
}
+20 -5
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -106,10 +106,25 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
expect(dialog).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1");
// The dialog portals out of the table, so every field query is scoped to it.
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
// The RAC Select names its trigger with the value and then the label.
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
fireEvent.click(screen.getByRole("option", { name: "kids" }));
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
});
test("prefix editor starts clean and dirties on add", async () => {
+138 -34
View File
@@ -1,16 +1,16 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
import { formatTime } from "@/lib/format";
import type { Client } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const cellClass = "px-3 py-2";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
@@ -18,6 +18,97 @@ const cellClass = "px-3 py-2";
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "48rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
mono: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
right: {
textAlign: "right",
},
dash: {
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.primary,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
color: colors.primaryText,
},
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: {
display: "inline-flex",
gap: "0.5rem",
},
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
/**
* The accessible name of the actions column, kept out of the visual table
* without leaving the accessibility tree.
*/
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
@@ -30,46 +121,46 @@ export default function ClientsPage() {
return (
<section>
<h1 className="text-2xl font-semibold">Clients</h1>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
{clients.length === 0 ? (
<p className="mt-4 text-zinc-500">
<p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries there is
nothing to create by hand.
</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-[48rem] text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
<th className={cellClass}>IP</th>
<th className={cellClass}>Name</th>
<th className={cellClass}>Group</th>
<th className={cellClass}>First seen</th>
<th className={cellClass}>Last seen</th>
<th className={cellClass}>
<span className="sr-only">Actions</span>
<tr {...stylex.props(styles.headRow)}>
<th {...stylex.props(styles.cell)}>IP</th>
<th {...stylex.props(styles.cell)}>Name</th>
<th {...stylex.props(styles.cell)}>Group</th>
<th {...stylex.props(styles.cell)}>First seen</th>
<th {...stylex.props(styles.cell)}>Last seen</th>
<th {...stylex.props(styles.cell)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800">
<td className={`${cellClass} font-mono`}>{client.ip}</td>
<td className={cellClass}>
{client.name === "" ? <span className="text-zinc-400"></span> : client.name}
{client.hand_edited && (
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
edited
</span>
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, styles.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell)}>
{client.name === "" ? (
<span {...stylex.props(styles.dash)}></span>
) : (
client.name
)}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td>
<td className={cellClass}>{client.group}</td>
<td className={cellClass}>{formatTime(client.first_seen)}</td>
<td className={cellClass}>{formatTime(client.last_seen)}</td>
<td className={`${cellClass} text-right`}>
<td {...stylex.props(styles.cell)}>{client.group}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.right)}>
{confirmingId === client.id ? (
<span className="inline-flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-zinc-500">
<span {...stylex.props(styles.confirmGroup)}>
<span {...stylex.props(styles.note)}>
Deleted clients re-materialize on their next DNS query.
</span>
<button
@@ -78,26 +169,34 @@ export default function ClientsPage() {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
className={smallButtonClass}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</span>
) : (
<span className="inline-flex gap-2">
<span {...stylex.props(styles.actionGroup)}>
<button
type="button"
onClick={() => setEditing(client)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${smallButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
@@ -110,7 +209,12 @@ export default function ClientsPage() {
? DECLARED_CLIENT_NOTE
: undefined
}
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
+91 -26
View File
@@ -1,11 +1,14 @@
import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
@@ -13,6 +16,72 @@ interface Props {
groups: Group[];
}
const styles = stylex.create({
section: {
marginTop: "2.5rem",
},
heading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
rows: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "1rem",
listStyleType: "none",
padding: 0,
},
row: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
prefixInput: {
width: "13rem",
},
priorityInput: {
width: "5rem",
},
removeButton: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: "transparent",
paddingInline: "0.5rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
validation: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
actions: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
},
});
export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -21,6 +90,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
const save = () => {
const problem = firstProblem(state.rows);
@@ -32,18 +102,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
};
return (
<section className="mt-10">
<h2 className="text-xl font-semibold">Client prefixes</h2>
<p className="mt-1 text-sm text-zinc-500">
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
<p {...stylex.props(styles.intro)}>
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
match wins.
</p>
{state.rows.length === 0 ? (
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p>
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
) : (
<ul className="mt-4 space-y-2">
<ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => (
<li key={index} className="flex flex-wrap items-center gap-2">
<li key={index} {...stylex.props(styles.row)}>
<input
type="text"
aria-label={`Prefix ${index + 1}`}
@@ -52,22 +122,17 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
}
className={`${smallInputClass} w-52`}
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/>
<select
<Select
aria-label={`Group for prefix ${index + 1}`}
variant="inline"
value={String(row.group_id)}
onChange={(event) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
onChange={(value) =>
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
}
className={smallInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
options={groupOptions}
/>
<input
type="text"
inputMode="numeric"
@@ -77,12 +142,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
}
className={`${smallInputClass} w-20`}
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
/>
<button
type="button"
onClick={() => dispatch({ type: "remove", index })}
className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`}
{...stylex.props(styles.removeButton, shared.focusRing)}
>
Remove
</button>
@@ -91,16 +156,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400">
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
</p>
)}
<InlineError error={mutation.error} />
<div className="mt-4 flex gap-2">
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
className={buttonClass}
{...stylex.props(shared.button, shared.focusRing)}
>
Add prefix
</button>
@@ -109,7 +174,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onClick={save}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save prefixes
</button>
@@ -120,7 +185,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
className={buttonClass}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes
</button>
+87 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -24,6 +24,12 @@ function createFetchMock() {
records = [...records, created];
return json(created, 201);
}
if (url.startsWith("/api/local-records/") && method === "DELETE") {
const id = Number(url.slice("/api/local-records/".length));
records = records.filter((record) => record.id !== id);
return new Response(null, { status: 204 });
}
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
}
@@ -65,13 +71,34 @@ test("renders the records table and switches to the forward zones tab", async ()
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
});
test("the arrow keys move between tabs", async () => {
renderPage();
await screen.findByText("nas.lan.home");
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
const records = screen.getByRole("tab", { name: "Records" });
expect(records.getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
const zones = screen.getByRole("tab", { name: "Forward zones" });
expect(zones.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
await screen.findByText("lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
await screen.findByText("nas.lan.home");
});
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } });
// The record type is a RAC Select now: open the listbox, then pick.
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -83,3 +110,61 @@ test("creates a record: POST body per LocalRecordInput, list refreshes", async (
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
});
test("cancelling the record delete dialog sends no request", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
expect(screen.getByText("nas.lan.home")).toBeTruthy();
});
test("confirming the record delete dialog issues the DELETE", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
),
).toBe(true),
);
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
});
test("the forward zone delete dialog names the zone and confirms", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
),
).toBe(true),
);
});
+17 -69
View File
@@ -1,79 +1,27 @@
import { useState } from "react";
import * as stylex from "@stylexjs/stylex";
import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab";
import { focusRing } from "@/ui/classes";
import Tabs from "@/ui/Tabs";
type Tab = "records" | "zones";
function TabButton({
id,
controls,
selected,
onClick,
children,
}: {
id: string;
controls: string;
selected: boolean;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
role="tab"
id={id}
aria-controls={controls}
aria-selected={selected}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${
selected
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{children}
</button>
);
}
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
});
export default function LocalDnsPage() {
const [tab, setTab] = useState<Tab>("records");
return (
<section>
<h1 className="text-2xl font-semibold">Local DNS</h1>
<div
role="tablist"
aria-label="Local DNS"
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800"
>
<TabButton
id="tab-records"
controls="panel-records"
selected={tab === "records"}
onClick={() => setTab("records")}
>
Records
</TabButton>
<TabButton
id="tab-zones"
controls="panel-zones"
selected={tab === "zones"}
onClick={() => setTab("zones")}
>
Forward zones
</TabButton>
</div>
{tab === "records" ? (
<div role="tabpanel" id="panel-records" aria-labelledby="tab-records">
<RecordsTab />
</div>
) : (
<div role="tabpanel" id="panel-zones" aria-labelledby="tab-zones">
<ZonesTab />
</div>
)}
<h1 {...stylex.props(styles.heading)}>Local DNS</h1>
<Tabs
label="Local DNS"
tabs={[
{ id: "records", label: "Records", content: <RecordsTab /> },
{ id: "zones", label: "Forward zones", content: <ZonesTab /> },
]}
/>
</section>
);
}
+160 -58
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
localRecordCreateMutation,
localRecordDeleteMutation,
@@ -8,18 +9,100 @@ import {
} from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
mono: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
function RecordForm({
initial,
@@ -50,10 +133,12 @@ function RecordForm({
}
return (
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New record" : `Edit ${initial.name}`}
</h3>
<div>
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
Name
</label>
<input
@@ -62,28 +147,19 @@ function RecordForm({
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium">
Type
</label>
<select
id={`${id}-rtype`}
<Select
label="Type"
value={rtype}
onChange={(event) => setRtype(event.target.value as LocalRecordType)}
className={inputClass}
>
{RTYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
onChange={(next) => setRtype(next as LocalRecordType)}
options={RTYPE_OPTIONS}
/>
</div>
<div>
<label htmlFor={`${id}-value`} className="block text-sm font-medium">
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
Value
</label>
<input
@@ -94,11 +170,11 @@ function RecordForm({
placeholder={
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium">
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
TTL (seconds)
</label>
<input
@@ -108,19 +184,19 @@ function RecordForm({
value={ttl}
onChange={(event) => setTtl(event.target.value)}
placeholder="300"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="flex gap-2">
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
@@ -131,10 +207,19 @@ function RecordForm({
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
LocalRecord,
LocalRecordInput
>({
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<LocalRecord, LocalRecordInput>({
create: localRecordCreateMutation,
update: localRecordUpdateMutation,
remove: localRecordDeleteMutation,
@@ -144,14 +229,14 @@ export default function RecordsTab() {
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add record
</button>
@@ -166,37 +251,37 @@ export default function RecordsTab() {
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Type
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Value
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
TTL
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} className="py-4 text-zinc-500">
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet.
</td>
</tr>
)}
{records.map((record) => (
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
@@ -210,17 +295,21 @@ export default function RecordsTab() {
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{record.name}</td>
<td className="py-2 pr-4">{record.rtype}</td>
<td className="py-2 pr-4 font-mono">{record.value}</td>
<td className="py-2 pr-4">{record.ttl}</td>
<td className="py-2 text-right whitespace-nowrap">
<td {...stylex.props(styles.cell, styles.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, styles.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
@@ -229,7 +318,12 @@ export default function RecordsTab() {
onClick={() => onDelete(record)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -241,6 +335,14 @@ export default function RecordsTab() {
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+147 -38
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
forwardZoneCreateMutation,
forwardZoneDeleteMutation,
@@ -8,17 +9,97 @@ import {
} from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
mono: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
function ZoneForm({
initial,
busy,
@@ -44,10 +125,12 @@ function ZoneForm({
}
return (
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
</h3>
<div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
Zone
</label>
<input
@@ -56,11 +139,11 @@ function ZoneForm({
value={zone}
onChange={(event) => setZone(event.target.value)}
placeholder="lan.home"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium">
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
Resolver
</label>
<input
@@ -69,19 +152,19 @@ function ZoneForm({
value={resolver}
onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="flex gap-2">
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
@@ -92,10 +175,19 @@ function ZoneForm({
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
ForwardZone,
ForwardZoneInput
>({
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<ForwardZone, ForwardZoneInput>({
create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation,
remove: forwardZoneDeleteMutation,
@@ -105,8 +197,8 @@ export default function ZonesTab() {
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>
Names under these zones go to their own resolver. Changes apply live.
</p>
<button
@@ -114,7 +206,7 @@ export default function ZonesTab() {
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add zone
</button>
@@ -129,31 +221,31 @@ export default function ZonesTab() {
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Zone
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Resolver
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} className="py-4 text-zinc-500">
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
No forward zones yet.
</td>
</tr>
)}
{zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
@@ -167,15 +259,19 @@ export default function ZonesTab() {
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{zone.zone}</td>
<td className="py-2 pr-4 font-mono">{zone.resolver}</td>
<td className="py-2 text-right whitespace-nowrap">
<td {...stylex.props(styles.cell, styles.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, styles.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
@@ -184,7 +280,12 @@ export default function ZonesTab() {
onClick={() => onDelete(zone)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -196,6 +297,14 @@ export default function ZonesTab() {
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+60 -13
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -33,16 +33,26 @@ const RESPONSES: Record<string, unknown> = {
// The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[];
function deleteCalls(): string[] {
return deleted;
}
beforeEach(() => {
groups = [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
];
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/rules" && init?.method === "POST") {
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
@@ -75,6 +85,25 @@ function renderRulesRoute() {
);
}
/**
* A RAC Select names its trigger with the current value and then the label, so
* the label alone is a suffix match. Opening it is the only way to read the
* options: there is no `<select>` carrying them any more.
*/
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function optionsOf(label: string): Promise<(string | null)[]> {
fireEvent.click(trigger(label));
const options = await screen.findAllByRole("option");
const labels = options.map((option) => option.textContent);
// Re-picking the current value closes the listbox and changes nothing.
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
return labels;
}
test("renders the rule table and the create form with contract enums", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
@@ -87,14 +116,9 @@ test("renders the rule table and the create form with contract enums", async ()
expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement;
expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]);
const actionSelect = screen.getByLabelText("Action") as HTMLSelectElement;
expect(Array.from(actionSelect.options).map((o) => o.value)).toEqual(["allow", "block"]);
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Default", "Kids"]);
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard"]);
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
});
test("rule create shows a countdown when rate limited with Retry-After", async () => {
@@ -116,9 +140,8 @@ test("the group select preselects the id-1 default, not the alphabetically first
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]);
expect(groupSelect.value).toBe("1");
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
expect(trigger("Group").textContent).toContain("Default");
});
test("the group select falls back to the first group when the default is absent", async () => {
@@ -129,5 +152,29 @@ test("the group select falls back to the first group when the default is absent"
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5");
expect(trigger("Group").textContent).toContain("Attic");
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleteCalls()).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that rule", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
});
+155 -89
View File
@@ -1,13 +1,96 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pattern: {
fontWeight: 500,
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1.5rem",
maxWidth: "36rem",
},
formHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** One column on a phone, three from the `sm` breakpoint, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
},
},
submitRow: {
display: "flex",
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function RulesPage() {
const queryClient = useQueryClient();
const { data: rules } = useSuspenseQuery(rulesQuery());
@@ -20,6 +103,7 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) {
@@ -30,58 +114,52 @@ export default function RulesPage() {
);
}
function deleteRule(rule: Rule) {
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) {
remove.mutate(rule.id);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
return (
<section>
<h1 className="text-2xl font-semibold">Rules</h1>
<h1 {...stylex.props(styles.heading)}>Rules</h1>
{rules.length === 0 ? (
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>Pattern</th>
<th className={thClass}>Kind</th>
<th className={thClass}>Action</th>
<th className={thClass}>Group</th>
<th className={thClass}>Created</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Group</th>
<th {...stylex.props(shared.th)}>Created</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td className={`${tdClass} font-medium`}>{rule.pattern}</td>
<td className={tdClass}>{rule.kind}</td>
<td className={tdClass}>
<span
className={
rule.action === "allow"
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
</span>
</td>
<td className={tdClass}>{rule.group}</td>
<td className={tdClass}>{formatTime(rule.created_at)}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td)}>{rule.group}</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td {...stylex.props(shared.td)}>
<button
type="button"
onClick={() => deleteRule(rule)}
onClick={() => setPendingDelete(rule)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -94,10 +172,10 @@ export default function RulesPage() {
)}
<InlineError error={remove.error} />
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3">
<h2 className="text-lg font-medium">Create rule</h2>
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
<div>
<label htmlFor="rule-pattern" className="block text-sm font-medium">
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern
</label>
<input
@@ -107,66 +185,54 @@ export default function RulesPage() {
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com or *.example.com"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label htmlFor="rule-kind" className="block text-sm font-medium">
Kind
</label>
<select
id="rule-kind"
value={kind}
onChange={(event) => setKind(event.target.value as RuleKind)}
className={inputClass}
>
<option value="exact">exact</option>
<option value="wildcard">wildcard</option>
</select>
</div>
<div>
<label htmlFor="rule-action" className="block text-sm font-medium">
Action
</label>
<select
id="rule-action"
value={action}
onChange={(event) => setAction(event.target.value as RuleAction)}
className={inputClass}
>
<option value="allow">allow</option>
<option value="block">block</option>
</select>
</div>
<div>
<label htmlFor="rule-group" className="block text-sm font-medium">
Group
</label>
<select
id="rule-group"
value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div {...stylex.props(styles.fieldGrid)}>
<Select
label="Kind"
value={kind}
onChange={(value) => setKind(value as RuleKind)}
options={KIND_OPTIONS}
/>
<Select
label="Action"
value={action}
onChange={(value) => setAction(value as RuleAction)}
options={ACTION_OPTIONS}
/>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<div {...stylex.props(styles.submitRow)}>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
</div>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<InlineError error={create.error} />
</form>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
+64 -15
View File
@@ -1,7 +1,9 @@
import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100";
@@ -16,6 +18,49 @@ interface UpstreamFormProps {
onCancel?: () => void;
}
const styles = stylex.create({
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
hint: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
actions: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
cancelButton: {
fontWeight: 500,
},
});
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
@@ -45,10 +90,10 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
}
return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
<h2 className="text-lg font-medium">{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<div>
<label htmlFor="upstream-url" className="block text-sm font-medium">
<label htmlFor="upstream-url" {...stylex.props(styles.fieldLabel)}>
URL
</label>
<input
@@ -58,11 +103,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="udp://1.1.1.1:53"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor="upstream-priority" className="block text-sm font-medium">
<label htmlFor="upstream-priority" {...stylex.props(styles.fieldLabel)}>
Priority
</label>
<input
@@ -71,11 +116,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
min={0}
value={priority}
onChange={(event) => setPriority(event.target.value)}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor="upstream-tls-name" className="block text-sm font-medium">
<label htmlFor="upstream-tls-name" {...stylex.props(styles.fieldLabel)}>
TLS name
</label>
<input
@@ -84,32 +129,36 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={tlsName}
onChange={(event) => setTlsName(event.target.value)}
placeholder="one.one.one.one"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
<p className="mt-1 text-xs text-zinc-500">
<p {...stylex.props(styles.hint)}>
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
</p>
</div>
<label className="flex items-center gap-2 text-sm font-medium">
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
Enabled
</label>
<div className="flex items-center gap-2">
<div {...stylex.props(styles.actions)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
<button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancelButton, shared.focusRing)}
>
Cancel
</button>
)}
@@ -134,15 +134,29 @@ test("toggling enabled resends the whole row", async () => {
await screen.findByRole("status");
});
test("delete asks for confirmation and skips the request when refused", async () => {
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog() {
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
return await screen.findByRole("alertdialog");
}
test("delete asks for confirmation and skips the request when cancelled", async () => {
await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(false);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(calls).toHaveLength(0);
});
test("confirming the delete dialog issues the DELETE and raises the restart banner", async () => {
await renderUpstreamsRoute();
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]!.method).toBe("DELETE");
expect(calls[0]!.url).toBe("/api/upstreams/1");
@@ -185,14 +199,14 @@ test("a 409 on toggle renders the last-enabled conflict above the form", async (
test("a 409 on delete renders the last-enabled conflict", async () => {
await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(true);
writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409,
headers: { "content-type": "application/json" },
});
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
+103 -27
View File
@@ -1,17 +1,76 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
maxWidth: "42rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
},
numeric: {
fontVariantNumeric: "tabular-nums",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function UpstreamsPage() {
const queryClient = useQueryClient();
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
const [editing, setEditing] = useState<Upstream | null>(null);
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
const create = useMutation(upstreamCreateMutation(queryClient));
const save = useMutation(upstreamUpdateMutation(queryClient));
@@ -39,10 +98,10 @@ export default function UpstreamsPage() {
);
}
function deleteUpstream(u: Upstream) {
if (window.confirm(`Delete upstream "${u.url}"? Queries stop being forwarded to it.`)) {
remove.mutate(u.id, { onSuccess: () => raiseRestartBanner() });
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
setPendingDelete(null);
}
const formError = editing === null ? create.error : save.error;
@@ -50,38 +109,38 @@ export default function UpstreamsPage() {
return (
<section>
<h1 className="text-2xl font-semibold">Upstreams</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-500">
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p {...stylex.props(styles.intro)}>
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
health table on the Dashboard reflects the running pool, not this list.
</p>
{upstreams.length === 0 ? (
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p>
<p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>URL</th>
<th className={thClass}>Priority</th>
<th className={thClass}>Enabled</th>
<th className={thClass}>TLS name</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td className={tdClass}>
<span className="block max-w-72 truncate font-medium" title={u.url}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={u.url}>
{u.url}
</span>
</td>
<td className={`${tdClass} tabular-nums`}>{u.priority}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td, styles.numeric)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
aria-label={`${u.url} enabled`}
@@ -89,27 +148,31 @@ export default function UpstreamsPage() {
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(u)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
</td>
<td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={tdClass}>
<div className="flex gap-3">
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => setEditing(u)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => deleteUpstream(u)}
onClick={() => setPendingDelete(u)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -132,6 +195,19 @@ export default function UpstreamsPage() {
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
/>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete upstream"
message={
pendingDelete === null
? ""
: `Delete upstream "${pendingDelete.url}"? Queries stop being forwarded to it.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* The destructive-action confirmation (milestone 23, ruling 5), which replaces
* every `window.confirm` call in the app.
*
* `role="alertdialog"` rather than `dialog`: the message is the reason the
* dialog exists, so it is announced with the dialog instead of after it. The
* overlay is deliberately not dismissable a delete needs an explicit answer,
* and a stray outside click is not one. Escape still cancels.
*/
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
isOpen: boolean;
title: string;
/** The full sentence the operator reads before confirming; names the entity. */
message: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
panel: {
width: "100%",
maxWidth: "26rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
body: {
outlineStyle: "none",
},
title: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
marginTop: "1.5rem",
},
dangerButton: {
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.danger,
color: colors.primaryText,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
});
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onCancel();
}}
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog role="alertdialog" {...stylex.props(styles.body)}>
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
{title}
</Heading>
<p {...stylex.props(styles.message)}>{message}</p>
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
{...stylex.props(styles.dangerButton, shared.focusRing)}
>
{confirmLabel}
</button>
</div>
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The modal dialog (milestone 23, ruling 4).
*
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
* wiring that the hand-rolled overlay only approximated. State is controlled by
* the caller because the trigger is a table row button, not a `DialogTrigger`.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
interface Props {
/** The dialog's accessible name. */
label: string;
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}
const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
panel: {
width: "100%",
maxWidth: "28rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
/** The panel already draws the boundary; the dialog's own ring would double it. */
body: {
outlineStyle: "none",
},
});
export default function Dialog({ label, isOpen, onClose, children }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
isDismissable
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
{children}
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* The single-choice picker (milestone 23, ruling 4), replacing every native
* `<select>` in the app.
*
* RAC composes a Select out of Label, Button, SelectValue, Popover, ListBox and
* ListBoxItem; those parts are the Select, not extra components adopted beyond
* ruling 4's scope, and nothing outside this file imports them.
*
* Keys are strings because a `<select>`'s value was a string. A call site that
* models an id converts on both edges.
*/
import * as stylex from "@stylexjs/stylex";
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
export interface SelectOption {
value: string;
label: string;
}
interface Props {
options: readonly SelectOption[];
value: string;
onChange: (value: string) => void;
/** The visible label. Omit it only when `aria-label` names the control. */
label?: string;
"aria-label"?: string;
/**
* `field` matches a full-width form input, `compactField` the smaller one a
* dialog uses, `inline` a control sitting in a row of other controls.
*/
variant?: "field" | "compactField" | "inline";
}
const styles = stylex.create({
root: {
display: "block",
},
label: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
trigger: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
textAlign: "left",
cursor: "pointer",
},
compact: {
marginTop: "0.25rem",
width: "100%",
},
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
value: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
chevron: {
color: colors.textMuted,
},
popover: {
width: "var(--trigger-width)",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
listBox: {
outlineStyle: "none",
paddingBlock: "0.25rem",
},
item: {
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
cursor: "pointer",
outlineStyle: "none",
},
itemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
},
itemSelected: {
fontWeight: 600,
},
});
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
const base = variant === "field" ? shared.input : shared.smallInput;
const block = variant === "compactField" ? styles.compact : null;
return (
<AriaSelect
aria-label={ariaLabel}
value={value}
onChange={(key) => onChange(String(key ?? ""))}
{...stylex.props(styles.root)}
>
{label !== undefined && <Label {...stylex.props(styles.label)}>{label}</Label>}
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
</span>
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<ListBox {...stylex.props(styles.listBox)}>
{options.map((option) => (
<ListBoxItem
key={option.value}
id={option.value}
textValue={option.label}
className={({ isFocused, isSelected }) =>
stylex.props(
styles.item,
isSelected && styles.itemSelected,
isFocused && styles.itemFocused,
).className ?? ""
}
>
{option.label}
</ListBoxItem>
))}
</ListBox>
</Popover>
</AriaSelect>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* The tab switcher (milestone 23, ruling 4).
*
* The hand-rolled version spelled the ARIA attributes by hand but had no
* keyboard navigation; React Aria brings arrow-key movement and roving
* tabindex with the same roles. State stays inside RAC no caller needs to
* read which tab is open.
*
* RAC exposes state as render-prop booleans, so every rule below is a
* boolean-guarded style object: StyleX cannot express `[data-selected]`.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
import { colors } from "./tokens.stylex";
export interface TabSpec {
id: string;
label: string;
content: ReactNode;
}
interface Props {
/** The tab list's accessible name. */
label: string;
tabs: readonly TabSpec[];
}
const styles = stylex.create({
list: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
tab: {
marginBottom: -1,
borderBottomWidth: 2,
borderBottomStyle: "solid",
borderBottomColor: "transparent",
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textMuted,
cursor: "pointer",
},
tabSelected: {
borderBottomColor: colors.primary,
color: colors.primaryOnSurface,
},
tabHovered: {
color: colors.text,
},
/**
* The milestone-9 focus floor. A Tab is a `div` with a roving tabindex, so
* the ring is driven by RAC's `isFocusVisible` rather than `:focus-visible`.
*/
tabFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
panel: {
outlineStyle: "none",
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
root: {
display: "block",
},
});
export default function Tabs({ label, tabs }: Props) {
return (
<AriaTabs className={() => stylex.props(styles.root).className ?? ""}>
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
{tabs.map((tab) => (
<Tab
key={tab.id}
id={tab.id}
className={({ isSelected, isHovered, isFocusVisible }) =>
stylex.props(
styles.tab,
isHovered && !isSelected && styles.tabHovered,
isSelected && styles.tabSelected,
isFocusVisible && styles.tabFocusVisible,
).className ?? ""
}
>
{tab.label}
</Tab>
))}
</TabList>
{tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id} className={() => stylex.props(styles.panel).className ?? ""}>
{tab.content}
</TabPanel>
))}
</AriaTabs>
);
}
+69 -8
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
import ConfirmDialog from "./ConfirmDialog";
import { useCrudForm } from "./useCrudForm";
interface Row {
@@ -41,6 +42,50 @@ function setup() {
return { hook, created, updated, removed };
}
/**
* The delete path is only finished when a real dialog drives it, so this
* harness wires the hook's pending-delete state to the component operators
* actually see (ruling 5). `window.confirm` no longer exists to stub.
*/
function DeleteHarness({ onRemoved }: { onRemoved: (id: number) => void }) {
const { onDelete, pendingDelete, confirmPendingDelete, cancelPendingDelete } = useCrudForm<Row, string>({
create: (_qc: QC) => ({ mutationFn: async () => undefined }),
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
remove: (_qc: QC) => ({
mutationFn: async (id: number) => {
onRemoved(id);
},
}),
confirmDelete: (row) => `Delete "${row.name}"?`,
});
return (
<>
<button type="button" onClick={() => onDelete({ id: 3, name: "gamma" })}>
Delete gamma
</button>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete row"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</>
);
}
function renderDeleteHarness() {
const removed: number[] = [];
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<DeleteHarness onRemoved={(id) => removed.push(id)} />
</QueryClientProvider>,
);
return removed;
}
afterEach(() => vi.unstubAllGlobals());
test("the create form submits through the create mutation and closes", async () => {
@@ -96,18 +141,34 @@ test("openForm clears a stale create error so the reopened form starts clean", a
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
});
test("delete asks for confirmation and only removes when confirmed", async () => {
test("onDelete opens the confirmation instead of removing the row", () => {
const { hook, removed } = setup();
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
expect(hook.result.current.pendingDelete).toEqual({ entity: { id: 3, name: "gamma" }, message: 'Delete "gamma"?' });
expect(removed).toEqual([]);
});
confirm.mockReturnValue(true);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
test("cancelling the confirmation dialog leaves the row alone", async () => {
const removed = renderDeleteHarness();
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete "gamma"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(removed).toEqual([]);
});
test("confirming the dialog removes the row by id", async () => {
const removed = renderDeleteHarness();
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(removed).toEqual([3]));
expect(screen.queryByRole("alertdialog")).toBeNull();
});
test("closeForm closes whichever form is open", () => {
+36 -6
View File
@@ -4,20 +4,28 @@ import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions
/** Which form is open: none, the create form, or the edit form for one row. */
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
/** The row a confirmation dialog is open for, and the message it shows. */
export interface PendingDelete<Entity> {
entity: Entity;
message: string;
}
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
export interface CrudFormSpec<Entity extends { id: number }, Input> {
create: MutationFactory<Input>;
update: MutationFactory<{ id: number; input: Input }>;
remove: MutationFactory<number>;
/** The `window.confirm` text shown before a delete. */
/** The message the confirmation dialog shows before a delete. */
confirmDelete: (entity: Entity) => string;
}
/**
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
* three mutations, the open-form state, and the three handlers. The field JSX
* and the table stay in the caller.
* three mutations, the open-form state, the pending-delete state and the
* handlers. The field JSX, the table and the confirmation dialog stay in the
* caller: `onDelete` opens the confirmation instead of deciding anything, so
* the hook never reaches for `window.confirm` (ruling 5).
*/
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
const queryClient = useQueryClient();
@@ -25,6 +33,7 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
const update = useMutation(spec.update(queryClient));
const remove = useMutation(spec.remove(queryClient));
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
const [pendingDelete, setPendingDelete] = useState<PendingDelete<Entity> | null>(null);
function openForm(next: CrudFormState<Entity>) {
create.reset();
@@ -46,9 +55,30 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
}
function onDelete(entity: Entity) {
if (!window.confirm(spec.confirmDelete(entity))) return;
remove.mutate(entity.id);
setPendingDelete({ entity, message: spec.confirmDelete(entity) });
}
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
function confirmPendingDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.entity.id);
setPendingDelete(null);
}
function cancelPendingDelete() {
setPendingDelete(null);
}
return {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
};
}
+9 -1
View File
@@ -7,7 +7,14 @@ import { defineConfig } from "vite";
export default defineConfig(({ mode }) => ({
plugins: [
stylex.vite({ useCSSLayers: true, dev: mode === "development", runtimeInjection: false }),
// StyleX resolves the `.stylex.ts` theme file itself, so it needs the `@`
// alias too — `resolve.alias` below is Vite's and the plugin cannot see it.
stylex.vite({
useCSSLayers: true,
dev: mode === "development",
runtimeInjection: false,
aliases: { "@/*": [fileURLToPath(new URL("./src/*", import.meta.url))] },
}),
react(),
tailwindcss(),
],
@@ -25,5 +32,6 @@ export default defineConfig(({ mode }) => ({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
},
}));
+49
View File
@@ -0,0 +1,49 @@
/**
* jsdom implements no `CSS` interface at all, and React Aria's collection code
* calls `CSS.escape` unguarded when it looks an item up by key
* (`react-aria/dist/private/selection/utils.mjs:22`). Without this, opening a
* RAC Select or moving between RAC Tabs throws in tests but works in a browser.
*
* The implementation is CSSOM's `serialize an identifier` algorithm
* (https://drafts.csswg.org/cssom/#serialize-an-identifier), not an
* approximation: a wrong escape would silently break the key lookups instead of
* failing loudly.
*/
function escapeIdentifier(value: string): string {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
const char = value[index]!;
if (code === 0x0000) {
result += "";
} else if (
(code >= 0x0001 && code <= 0x001f) ||
code === 0x007f ||
(index === 0 && code >= 0x0030 && code <= 0x0039) ||
(index === 1 && code >= 0x0030 && code <= 0x0039 && value.charCodeAt(0) === 0x002d)
) {
result += `\\${code.toString(16)} `;
} else if (index === 0 && code === 0x002d && value.length === 1) {
result += `\\${char}`;
} else if (
code >= 0x0080 ||
code === 0x002d ||
code === 0x005f ||
(code >= 0x0030 && code <= 0x0039) ||
(code >= 0x0041 && code <= 0x005a) ||
(code >= 0x0061 && code <= 0x007a)
) {
result += char;
} else {
result += `\\${char}`;
}
}
return result;
}
if (globalThis.CSS === undefined) {
Object.defineProperty(globalThis, "CSS", { value: { escape: escapeIdentifier }, configurable: true });
} else if (typeof globalThis.CSS.escape !== "function") {
globalThis.CSS.escape = escapeIdentifier;
}