diff --git a/.env.example b/.env.example
deleted file mode 100644
index 8c625a7..0000000
--- a/.env.example
+++ /dev/null
@@ -1,16 +0,0 @@
-# civfix-admin (operator dashboard, admin.civfix.org) environment.
-#
-# NEXT_PUBLIC_* values are INLINED into the static export at BUILD time (not read at runtime). Set
-# them before `pnpm build` (or `pnpm -C apps/admin build`). In CI they come from repo Variables
-# (vars.*); see .github/workflows/deploy.yml.
-
-# Base URL of the civfix API. The dashboard is served at admin.civfix.org and calls this API for all
-# /admin/* data and auth. The API's WEB_ORIGINS must include https://admin.civfix.org so CORS and the
-# session cookie work. Defaults to the local backend when unset.
-# Example (production): https://api.civfix.example
-NEXT_PUBLIC_API_URL=http://localhost:8080
-
-# Publishable CARTO basemap api key, appended to the Leaflet raster tile URLs. Optional: unset, the maps
-# still render the same CARTO tiles, just with CARTO's watermark. Public by nature (it is inlined into
-# the client bundle).
-# NEXT_PUBLIC_CARTO_API_KEY=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 33f2635..3883b43 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,10 @@
name: CI
-# main is the only long-lived branch and the only PR target (issue civfix/issue-tracker#108): main is
-# the staging lane, and production ships from a published v* release.
+# main is the only long-lived branch (issue civfix/issue-tracker#108): main is the staging lane, and
+# production ships from a published v* release. No base filter: a stacked PR targets its parent slice's
+# branch and still needs CI before it retargets onto main.
on:
pull_request:
- branches: [main]
# Cancel superseded runs on the same ref.
concurrency:
@@ -36,6 +36,12 @@ jobs:
- name: Lint
run: pnpm lint
+ - name: Unused files, exports and dependencies (knip)
+ run: pnpm --filter admin knip
+
+ - name: Duplication (jscpd)
+ run: pnpm --filter admin dup
+
- name: Typecheck
run: pnpm typecheck
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index e0322c5..4de4087 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,8 +1,8 @@
name: Deploy (Cloudflare Pages)
-# Builds the civfix-admin static export and publishes it to its Cloudflare Pages project on every
-# push to main, plus on-demand via the Actions "Run workflow" button. One app ships from this repo:
-# - admin -> Pages project "civfix-admin" (apps/admin/out, repo-root wrangler.jsonc) -> admin.civfix.org
+# Builds the civfix-admin static export and publishes it to its Cloudflare Pages project "civfix-admin"
+# (apps/admin/out, repo-root wrangler.jsonc). A push to main (or a manual run on main) deploys STAGING,
+# admin.civfix.dev; a published v* release deploys PRODUCTION, admin.civfix.org (ref -> environment below).
#
# Required GitHub configuration (Settings -> Secrets and variables -> Actions):
#
@@ -11,21 +11,21 @@ name: Deploy (Cloudflare Pages)
# CLOUDFLARE_ACCOUNT_ID The Cloudflare account id that owns the Pages project.
#
# Variables (vars.*), inlined into the static export at BUILD time (NEXT_PUBLIC_*):
-# NEXT_PUBLIC_API_URL Base URL of the civfix API (e.g. https://api.civfix.example). The admin
-# dashboard is served at admin.civfix.org and talks to this API. The API's
-# WEB_ORIGINS must include https://admin.civfix.org for CORS + cookies.
+# NEXT_PUBLIC_API_URL Leave unset: both environments call the API same-origin (see below).
+# Setting it points the build at a cross-origin API instead.
+# NEXT_PUBLIC_CARTO_API_KEY Optional publishable CARTO key for the basemap tiles.
#
# The project name + prebuilt output directory come from wrangler.jsonc, so the deploy step is just
# `pages deploy` (no args). The project is created automatically by the first run. We use Direct Upload
# (build here, ship the prebuilt out/) rather than Cloudflare's native Git build; @civfix/shared installs
-# from the public-read private registry via the committed .npmrc, so no submodule checkout is needed.
+# from the public-read private registry via the committed .npmrc.
# SPA deep links and security/cache headers are handled by the app's public/{_redirects,_headers}
# (copied into out/).
# Ref -> environment (issue https://github.com/civfix/issue-tracker/issues/108):
# push to main -> STAGING --branch=staging -> staging.civfix-admin.pages.dev -> admin.civfix.dev
# (via the civfix-infra staging Caddy, which reverse-proxies the SPA to that
-# alias — env/staging.sh ADMIN_SPA_UPSTREAM)
+# alias: env/staging.sh ADMIN_SPA_UPSTREAM)
# published v* tag -> PRODUCTION --branch=main -> civfix-admin.pages.dev -> admin.civfix.org
#
# There is no `dev` branch: feature branches PR into main, main IS the staging lane, and cutting a
@@ -38,7 +38,7 @@ on:
branches: [main]
release:
# `released`, NOT `published`: `published` also fires for a PRERELEASE, and a prerelease must never
- # reach admin.civfix.org — the operator plane. The regex gate below is the second guard.
+ # reach admin.civfix.org, the operator plane. The regex gate below is the second guard.
types: [released]
workflow_dispatch:
@@ -117,7 +117,7 @@ jobs:
# hash that already includes the NEXT_PUBLIC_* vars, so a changed API URL or source file rebuilds.
# The key is scoped by DEPLOY_ENV anyway: staging and production build the same commit, and
# `.next/cache` is restored underneath Turbo, so the two must never share one. (Admin happens to
- # build identically in both environments today — this keeps that from becoming load-bearing.)
+ # build identically in both environments today; this keeps that from becoming load-bearing.)
- name: Restore Turbo + Next.js build cache
uses: actions/cache@v4
with:
diff --git a/.gitignore b/.gitignore
index 2486917..26786cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,10 +21,10 @@ dist/
npm-debug.log*
pnpm-debug.log*
-# Env files (never commit secrets)
+# Env files (never commit secrets); only the documented example is tracked
.env
-.env.local
-.env.*.local
+.env.*
+!.env.example
# Editor / OS
.DS_Store
diff --git a/README.md b/README.md
index deb7d8a..ee6c666 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,9 @@
Workspace for the civfix ADMIN / OPERATOR dashboard, served at `admin.civfix.org`. A pnpm + Turbo
monorepo. This is the internal tool operators use to triage reports, route jurisdiction contacts,
-moderate content, run mail outreach, provision government accounts, and read analytics. It is a
-sibling of `civfix-shared`, `civfix-backend`, `civfix-web`, and `civfix-mobile`.
+moderate content, run mail outreach, provision government accounts, and read analytics. Its siblings
+are `civfix-app` (community web + mobile, and the `@civfix/shared` contract), `civfix-backend` (the
+API it calls) and `civfix-infra` (the edge that serves it).
## Layout
@@ -18,9 +19,9 @@ civfix-admin/
`@civfix/shared` (the shared contract package) is installed from the private Verdaccio registry at
`https://repo.civfix.org`. The repo-root `.npmrc` scopes `@civfix` to it and the app depends on a
-published version (`^0.1.0` today); the registry allows anonymous read, so no credentials are needed.
-After cloning, `pnpm install` fetches it — there is no submodule to initialize. The published tarball
-already contains the built `dist`, so no local build of the contract is required.
+published version by caret range (see `apps/admin/package.json`); the registry allows anonymous read, so
+no credentials are needed. After cloning, `pnpm install` fetches it. The published tarball already
+contains the built `dist`, so no local build of the contract is required.
## Commands (from this root)
@@ -28,18 +29,24 @@ already contains the built `dist`, so no local build of the contract is required
pnpm install # fetches @civfix/shared from repo.civfix.org
pnpm build # admin (next build, static export -> apps/admin/out)
pnpm typecheck
-pnpm lint
+pnpm lint # eslint (flat config, typed rules) over apps/admin
+pnpm test # vitest unit tests
+pnpm --filter admin knip # unused files, exports and dependencies
+pnpm --filter admin dup # jscpd duplication gate (app code and tests measured separately)
pnpm dev # runs the dashboard dev server (admin)
```
## Auth + API
-The dashboard talks to the civfix API at `NEXT_PUBLIC_API_URL` (inlined at build time; see
-`.env.example`). Operator sign-in reuses the civfix Email-OTP flow through the allowlist-gated
-`/admin/auth/*` routes; only emails in the backend `ADMIN_EMAILS` allowlist can receive a code. The
-shell renders only after an authenticated `operator` session exists; otherwise the login gate shows.
-Cookies + CSRF + `x-client: web` are sent exactly as the public web app does. The API's
-`WEB_ORIGINS` must include `https://admin.civfix.org`.
+In production the dashboard calls the API same-origin: `admin.civfix.org` sits behind Cloudflare Access,
+and the edge serves the SPA and proxies `/v1/admin/*` to the backend, so `NEXT_PUBLIC_API_URL` stays
+unset. A development build defaults to `http://localhost:8080`; see `apps/admin/.env.example`.
+
+Operator sign-in is the Cloudflare Access exchange: the dashboard first reuses an existing operator
+session, and otherwise posts to the admin Access-exchange route, which trades the Access identity the
+edge attached for an `operator` session. An Access identity whose email is not on the operator
+allowlist gets the "forbidden" screen. The shell renders only after an operator session exists.
+Cookies, CSRF and `x-client: web` are sent exactly as the public web app sends them.
## Deploy
@@ -47,8 +54,8 @@ Cloudflare Pages (Direct Upload). GitHub Actions builds the static export (a pla
`pnpm install`, which fetches `@civfix/shared` from `repo.civfix.org`; pinned Node 22 + pnpm 9.12.0)
and ships the prebuilt `apps/admin/out` via `wrangler pages deploy`. The Pages project name and output
dir come from the repo-root `wrangler.jsonc`
-(`civfix-admin` -> `apps/admin/out`). See `.github/workflows/deploy.yml` and
-`../documents/phase2/07-civfix-admin-repo.md`.
+(`civfix-admin` -> `apps/admin/out`). A push to `main` deploys staging (`admin.civfix.dev`); a published
+`v*` release deploys production (`admin.civfix.org`). See `.github/workflows/deploy.yml`.
## License
@@ -58,6 +65,6 @@ covered by the declaration in [REUSE.toml](REUSE.toml); there are no per-file
license headers. The dashboard honors the AGPL's source offer with the
"Source code" link on its home screen and sign-in screen, which points at the
deployed commit. Contributions are accepted under the
-[Contributor License Agreement](CLA.md) — see
+[Contributor License Agreement](CLA.md); see
[CONTRIBUTING.md](CONTRIBUTING.md). civfix is a project of Reach Out Los Angeles Inc.; the civfix name and
logos are its trademarks and are not covered by the license.
diff --git a/apps/admin/.env.example b/apps/admin/.env.example
index 2ac3ae0..25b2706 100644
--- a/apps/admin/.env.example
+++ b/apps/admin/.env.example
@@ -1,14 +1,12 @@
# civfix admin / operator dashboard environment (admin.civfix.org).
#
-# NEXT_PUBLIC_* values are INLINED into the static export at BUILD time (not read at runtime). Set
-# them before `pnpm -C apps/admin build` (or `pnpm build` from the repo root). In CI they come from
-# repo Variables (vars.*); see .github/workflows/deploy.yml.
+# NEXT_PUBLIC_* values are INLINED into the static export at BUILD time, not read at runtime. Set them
+# before `pnpm build`. In CI they come from repo Variables (vars.*); see .github/workflows/deploy.yml.
-# Base URL of the civfix API. The dashboard is served at admin.civfix.org and calls this API for all
-# /admin/* data and auth; the API's WEB_ORIGINS must include https://admin.civfix.org. Defaults to the
-# local backend when unset.
-# Example (production): https://api.civfix.example
-NEXT_PUBLIC_API_URL=http://localhost:8080
+# Base URL of the civfix API. Leave it unset for staging and production: there the dashboard calls the
+# API same-origin, because the edge serves the SPA and proxies /v1/admin/* to the backend behind the
+# same Cloudflare Access app. When unset, a development build (`pnpm dev`) calls http://localhost:8080.
+# NEXT_PUBLIC_API_URL=http://localhost:8080
# Publishable CARTO basemap api key, appended to the Leaflet raster tile URLs. Optional: unset, the maps
# still render the same CARTO tiles, just with CARTO's watermark. Public by nature (it is inlined into
diff --git a/apps/admin/.eslintrc.json b/apps/admin/.eslintrc.json
deleted file mode 100644
index b79b149..0000000
--- a/apps/admin/.eslintrc.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "extends": ["next/core-web-vitals", "next/typescript"],
- "rules": {
- "@typescript-eslint/consistent-type-imports": [
- "warn",
- { "prefer": "type-imports", "fixStyle": "separate-type-imports" }
- ],
- "@typescript-eslint/no-unused-vars": [
- "warn",
- { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
- ]
- }
-}
diff --git a/apps/admin/.jscpd.json b/apps/admin/.jscpd.json
new file mode 100644
index 0000000..3ac1b8b
--- /dev/null
+++ b/apps/admin/.jscpd.json
@@ -0,0 +1,7 @@
+{
+ "path": ["src"],
+ "format": ["typescript", "tsx", "css"],
+ "ignore": ["**/*.test.ts", "**/*.test.tsx", "src/test/**"],
+ "reporters": ["console"],
+ "threshold": 1
+}
diff --git a/apps/admin/.jscpd.tests.json b/apps/admin/.jscpd.tests.json
new file mode 100644
index 0000000..93a35e6
--- /dev/null
+++ b/apps/admin/.jscpd.tests.json
@@ -0,0 +1,7 @@
+{
+ "path": ["src"],
+ "format": ["typescript", "tsx"],
+ "pattern": "{**/*.test.ts,**/*.test.tsx,src/test/**}",
+ "reporters": ["console"],
+ "threshold": 6
+}
diff --git a/apps/admin/eslint.config.mjs b/apps/admin/eslint.config.mjs
new file mode 100644
index 0000000..404c6a6
--- /dev/null
+++ b/apps/admin/eslint.config.mjs
@@ -0,0 +1,49 @@
+import { dirname } from "node:path"
+import { fileURLToPath } from "node:url"
+
+import { FlatCompat } from "@eslint/eslintrc"
+
+const baseDirectory = dirname(fileURLToPath(import.meta.url))
+
+// eslint-config-next 15.x ships only eslintrc presets; FlatCompat is the supported bridge until 16.
+const compat = new FlatCompat({ baseDirectory })
+
+const config = [
+ {
+ ignores: [".next/**", "out/**", "coverage/**", "next-env.d.ts"],
+ },
+ ...compat.extends("next/core-web-vitals", "next/typescript"),
+ {
+ rules: {
+ // images.unoptimized is set for the static export, so next/image would render a plain rows rows anyway.
+ "@next/next/no-img-element": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
+ ],
+ },
+ },
+ {
+ files: ["**/*.ts", "**/*.tsx"],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ tsconfigRootDir: baseDirectory,
+ },
+ },
+ rules: {
+ "@typescript-eslint/consistent-type-imports": [
+ "error",
+ { prefer: "type-imports", fixStyle: "separate-type-imports" },
+ ],
+ "@typescript-eslint/no-floating-promises": "error",
+ "@typescript-eslint/no-misused-promises": [
+ "error",
+ { checksVoidReturn: { attributes: false } },
+ ],
+ "@typescript-eslint/switch-exhaustiveness-check": "error",
+ },
+ },
+]
+
+export default config
diff --git a/apps/admin/knip.config.ts b/apps/admin/knip.config.ts
new file mode 100644
index 0000000..cfe22bc
--- /dev/null
+++ b/apps/admin/knip.config.ts
@@ -0,0 +1,28 @@
+import type { KnipConfig } from "knip"
+
+const CSS_IMPORT = /@import\s+(?:url\(\s*)?["']([^"']+)["']/g
+
+const config: KnipConfig = {
+ project: ["src/**/*.{ts,tsx,css}", "*.{ts,mjs}"],
+ next: {
+ config: ["next.config.mjs"],
+ entry: ["src/app/**/{layout,page,not-found,error,loading}.tsx"],
+ },
+ vitest: {
+ config: ["vitest.config.ts"],
+ entry: ["src/**/*.test.{ts,tsx}", "vitest.setup.ts"],
+ },
+ // Knip skips any extension it cannot compile, so without this an orphaned stylesheet is never
+ // reported; following @import is what keeps colors-and-type.css (reached only from admin.css) used.
+ compilers: {
+ css: (text: string) =>
+ [...text.matchAll(CSS_IMPORT)].map(([, specifier]) => `import "${specifier}";`).join("\n"),
+ },
+ ignoreDependencies: [
+ // Loaded by name through FlatCompat.extends("next/...") in eslint.config.mjs, which knip's ESLint
+ // plugin cannot see into.
+ "eslint-config-next",
+ ],
+}
+
+export default config
diff --git a/apps/admin/next.config.mjs b/apps/admin/next.config.mjs
index fc01b45..fdfcb68 100644
--- a/apps/admin/next.config.mjs
+++ b/apps/admin/next.config.mjs
@@ -34,7 +34,6 @@ const nextConfig = {
env: {
NEXT_PUBLIC_COMMIT_SHA: resolveCommitSha(),
},
- // The @civfix/shared package ships ESM + CJS from the workspace; let Next transpile it.
transpilePackages: ["@civfix/shared"],
}
diff --git a/apps/admin/package.json b/apps/admin/package.json
index a94950c..4ccdc45 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -8,25 +8,24 @@
"scripts": {
"dev": "next dev",
"build": "next build",
- "start": "next start",
- "lint": "next lint --max-warnings=0",
+ "lint": "eslint . --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest run",
- "clean": "rimraf .next out *.tsbuildinfo"
+ "clean": "rimraf .next out *.tsbuildinfo",
+ "knip": "knip",
+ "dup": "jscpd -c .jscpd.json && jscpd -c .jscpd.tests.json"
},
"dependencies": {
"@civfix/shared": "^0.57.0",
"@tanstack/react-query": "^5.62.7",
- "clsx": "^2.1.1",
"leaflet": "^1.9.4",
- "lucide-react": "^0.469.0",
"next": "15.5.25",
"react": "19.0.0",
"react-dom": "19.0.0",
- "tailwind-merge": "^2.6.0",
"zustand": "^5.0.2"
},
"devDependencies": {
+ "@eslint/eslintrc": "^3.3.5",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
@@ -38,11 +37,12 @@
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "15.5.25",
+ "jscpd": "^5.3.2",
"jsdom": "^26.1.0",
+ "knip": "^6.38.0",
"postcss": "^8.5.28",
"rimraf": "^6.0.1",
"tailwindcss": "^3.4.17",
- "tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"vitest": "^3.2.7"
}
diff --git a/apps/admin/postcss.config.mjs b/apps/admin/postcss.config.mjs
index d0c615b..393a10f 100644
--- a/apps/admin/postcss.config.mjs
+++ b/apps/admin/postcss.config.mjs
@@ -1,4 +1,3 @@
-/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
diff --git a/apps/admin/public/ds/pin-cleanup.svg b/apps/admin/public/ds/pin-cleanup.svg
deleted file mode 100644
index 1b519be..0000000
--- a/apps/admin/public/ds/pin-cleanup.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
\ No newline at end of file
diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css
index 48648ef..348e072 100644
--- a/apps/admin/src/app/globals.css
+++ b/apps/admin/src/app/globals.css
@@ -5,14 +5,13 @@
* 1. Leaflet's stylesheet (the dashboard maps use Leaflet + CARTO raster tiles, matching the
* design prototype exactly - a deliberate design-fidelity choice for this internal tool; it does
* NOT use MapLibre like community-web).
- * 2. Tailwind's base/components/utilities. Tailwind here provides only incidental LAYOUT utilities;
- * the dashboard's full visual system is the ported design CSS imported in the root layout AFTER
- * this file (src/styles/admin.css, which @imports src/styles/colors-and-type.css). Because the
- * design CSS is imported after globals.css, its component classes win over Tailwind's base reset
- * wherever they overlap on the shell.
+ * 2. Tailwind, which contributes only its base reset; the markup uses no intended Tailwind
+ * utilities. The dashboard's visual system is the ported design CSS imported in the root layout
+ * AFTER this file (src/styles/admin.css, which @imports src/styles/colors-and-type.css), so its
+ * component classes win over the reset wherever they overlap.
*
* Fonts: next/font assigns --font-display-next / --font-body-next / --font-mono-next on
- * (layout.tsx) — exactly the names the ported design stylesheet (colors-and-type.css) aliases into the
+ * (layout.tsx), exactly the names the ported design stylesheet (colors-and-type.css) aliases into the
* final --font-display / --font-body / --font-mono stacks. No bridge is needed (and a
* `--font-*-next: var(--font-*)` bridge would be CIRCULAR, invalidating font-family -> serif fallback).
*/
@@ -43,27 +42,6 @@
}
}
-@layer utilities {
- /* Hide scrollbars on scroll areas while keeping them scrollable. */
- .no-scrollbar {
- -ms-overflow-style: none;
- scrollbar-width: none;
- }
- .no-scrollbar::-webkit-scrollbar {
- display: none;
- }
-
- /* Small inline spinner for loading buttons / hooks (lucide Loader2 + this class). */
- .cf-spin {
- animation: cf-spin 0.8s linear infinite;
- }
- @keyframes cf-spin {
- to {
- transform: rotate(360deg);
- }
- }
-}
-
/* Leaflet canvas should inherit the body font so attributions match the design. */
.leaflet-container {
font-family: var(--font-body);
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index 512bd02..e4fa734 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -37,6 +37,7 @@ export const metadata: Metadata = {
title: "civfix Operations",
description: "civfix admin and operator dashboard: discovery, reports, events, mail, users, and analytics.",
applicationName: "civfix Operations",
+ icons: { icon: "/favicon.svg" },
robots: { index: false, follow: false },
}
diff --git a/apps/admin/src/components/map/boundary-map.tsx b/apps/admin/src/components/map/boundary-map.tsx
index 9035e06..d6ffc0b 100644
--- a/apps/admin/src/components/map/boundary-map.tsx
+++ b/apps/admin/src/components/map/boundary-map.tsx
@@ -3,21 +3,13 @@
import * as React from "react"
import L from "leaflet"
-import { withCartoKey } from "@/lib/carto"
-import { MAP_SETTLE_MS } from "@/lib/timing"
+import { addCartoBasemap, useLeafletMap } from "@/components/map/leaflet-base"
// Derived from Leaflet's own signature, so no direct @types/geojson dependency is needed.
-type LeafletGeoJson = Parameters
}
diff --git a/apps/admin/src/components/shared/dialog.tsx b/apps/admin/src/components/shared/dialog.tsx
index 96f32b4..a6a5c63 100644
--- a/apps/admin/src/components/shared/dialog.tsx
+++ b/apps/admin/src/components/shared/dialog.tsx
@@ -69,6 +69,16 @@ export function promptDialog(
})
}
+const REASON_LABEL = "Reason (required)"
+
+export async function promptReason(
+ opts: Omit
undefined }
+
+function nextPage(overrides: Partial<{ hasNextPage: boolean; isFetchingNextPage: boolean }> = {}) {
+ return { hasNextPage: true, isFetchingNextPage: false, fetchNextPage: vi.fn(), ...overrides }
+}
+
+describe("ListStates", () => {
+ it("shows the loading label while loading", () => {
+ render(
+
rows
+ , + ) + expect(screen.getByText("nothing")).toBeInTheDocument() + expect(screen.queryByText("rows")).toBeNull() + + rerender( +rows
+rows
+rows
rows
+