milestone 23 s2: react aria primitives and their cluster
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Generated
+131
-3
@@ -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",
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user