Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c7eef0f9f | ||
|
|
2b6569f33b | ||
|
|
657510542c | ||
|
|
e33cca670e | ||
|
|
44fff10964 | ||
|
|
24d573cb10 | ||
|
|
2c5eb2d0bf | ||
|
|
8712543266 | ||
|
|
9d57547042 | ||
|
|
ff15d7eee4 | ||
|
|
cdb9549efd | ||
|
|
983e8d8230 | ||
|
|
c799b98bdf | ||
|
|
d9ddd1609d | ||
|
|
e9ce3f1931 | ||
|
|
947300b734 | ||
|
|
8484d7e4c6 | ||
|
|
a7962d1ba6 | ||
|
|
850f180e5e | ||
|
|
f856d926ba | ||
|
|
523da4ca49 | ||
|
|
50f4e3fe4d | ||
|
|
76105121cb | ||
|
|
0ba5fe447e | ||
|
|
82df289ba7 | ||
|
|
1714c6a2c3 | ||
|
|
3ddb1311f8 | ||
|
|
1a649a5458 | ||
|
|
57b80eb1fd | ||
|
|
eca667af73 | ||
|
|
e9eb14f4f0 | ||
|
|
301bf3b31b | ||
|
|
a98aa8f089 | ||
|
|
eff2d11254 | ||
|
|
730b38930a | ||
|
|
ce7a5a690a | ||
|
|
23a855297d | ||
|
|
818935ec23 | ||
|
|
cc58e1dcbb | ||
|
|
1d28e4608a | ||
|
|
8cc626d61f | ||
|
|
439349ddeb | ||
|
|
b10ce42f64 | ||
|
|
350be92d6d | ||
|
|
04e738671a | ||
|
|
d72143e7d1 | ||
|
|
93afb572f8 | ||
|
|
0d477151f8 | ||
|
|
cc0f91c3e1 | ||
|
|
6b0e305a9c | ||
|
|
f180a469c5 | ||
|
|
a78af68929 | ||
|
|
4ead47f053 | ||
|
|
7142274d76 | ||
|
|
6c78038ced | ||
|
|
5647e3c8e3 | ||
|
|
33c2335c94 | ||
|
|
14d5b9a831 | ||
|
|
6359846bba | ||
|
|
f276ea841a | ||
|
|
5b5af2ce18 | ||
|
|
d4c2ecc084 | ||
|
|
fec12b630f | ||
|
|
68f850aa1c | ||
|
|
a9c547f706 | ||
|
|
3040d20b45 | ||
|
|
2d84445645 | ||
|
|
a8b4b65373 | ||
|
|
41cd6d2034 | ||
|
|
8b8e1c7c03 | ||
|
|
12edc4cf1e | ||
|
|
511c005cad | ||
|
|
bca0cc7d6d | ||
|
|
e4b6742912 | ||
|
|
04118ae948 | ||
|
|
ab6f1ef3b8 | ||
|
|
da909ec1f4 | ||
|
|
81cd5346cb | ||
|
|
da72f89eb9 | ||
|
|
1bb154dee9 | ||
|
|
f075784933 | ||
|
|
01bd6254f9 | ||
|
|
c60a13facd | ||
|
|
b413535cb5 | ||
|
|
62b557345c | ||
|
|
806678e7f9 | ||
|
|
021a08cec5 | ||
|
|
8dbfddf631 | ||
|
|
66c8e1fe48 | ||
|
|
b1a65216f6 | ||
|
|
a0f1165459 | ||
|
|
c8bcf70c05 | ||
|
|
65f77c8f46 | ||
|
|
768e6ca7f8 | ||
|
|
55de69a6f5 | ||
|
|
13070e3b48 | ||
|
|
2f820518c7 | ||
|
|
76a76ac0c7 | ||
|
|
47f7707482 | ||
|
|
3affdbaa12 | ||
|
|
9c312c6b0a | ||
|
|
80abe29fe5 | ||
|
|
0d53307396 | ||
|
|
9eee90a8df | ||
|
|
a468612e34 | ||
|
|
15d8d1835d | ||
|
|
0bdde715e4 | ||
|
|
75e111a5f0 | ||
|
|
1ebc2cd437 | ||
|
|
7182332294 | ||
|
|
bc2a06c917 |
No files matched your search
@@ -0,0 +1,85 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6
|
||||
|
||||
# ─── Jobs ────────────────────────────────────────────────────────────────────
|
||||
# The dashboard jar (static export under next-dashboard/) is published to Maven
|
||||
# Central on release tags, with the same orb commands and credentials as the
|
||||
# Material Dashboard. QApplicationJavalinServer serves it at / when it is present.
|
||||
|
||||
jobs:
|
||||
publish_dashboard_jar:
|
||||
executor: qqq-orb/java
|
||||
steps:
|
||||
- checkout
|
||||
- qqq-orb/install_java:
|
||||
install_node: true
|
||||
node_version: "22"
|
||||
- run:
|
||||
name: Build the static export
|
||||
command: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build:export
|
||||
- qqq-orb/mvn_manage_version
|
||||
- qqq-orb/mvn_jar_deploy
|
||||
|
||||
# ─── Workflows ───────────────────────────────────────────────────────────────
|
||||
# Branch strategy mirrors the QRun-IO orb convention:
|
||||
# test_only → all feature / non-protected branches
|
||||
# publish_snapshot → develop
|
||||
# publish_release_candidate → release/*
|
||||
# publish_release → main / version tags
|
||||
# publish_hotfix_release → hotfix/*
|
||||
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/node_app_test_only:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ develop ]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ /release\/.*/ ]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ main ]
|
||||
tags:
|
||||
only: [ /v.*/ ]
|
||||
- publish_dashboard_jar:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: /^v[0-9]+\.[0-9]+\.[0-9]+(-RC\.[0-9]+)?$/
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ /hotfix\/.*/ ]
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(which pnpm:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(pnpm create:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(pnpm approve-builds:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(pnpm tsc:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(git:*)",
|
||||
"Bash(pnpm dlx:*)",
|
||||
"Bash(echo killed:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(sleep 1:*)",
|
||||
"Bash(pnpm list:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(pnpm lint:*)",
|
||||
"Bash(pnpm test:e2e:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(NEXT_PUBLIC_MOCK_API=true pnpm test:e2e:*)",
|
||||
"Bash(pnpm test:coverage:*)",
|
||||
"Bash(pnpm audit:*)",
|
||||
"Bash(pnpm up:*)",
|
||||
"Bash(pnpm remove:*)",
|
||||
"Bash(./node_modules/.bin/next lint:*)",
|
||||
"Bash(NEXT_PUBLIC_MOCK_API=true pnpm dev:*)",
|
||||
"mcp__circleci-mcp-server__list_followed_projects",
|
||||
"Bash(echo circleci CLI not found:*)",
|
||||
"Bash(circleci config:*)",
|
||||
"Bash(tail:*)",
|
||||
"mcp__circleci-mcp-server__get_latest_pipeline_status",
|
||||
"Bash(circleci orb:*)",
|
||||
"Bash(circleci diagnostic:*)",
|
||||
"mcp__circleci-mcp-server__run_pipeline",
|
||||
"mcp__circleci-mcp-server__get_build_failure_logs",
|
||||
"Bash(cd /Users/james.maes/Git.Local/QRun-IO/qqq-orb && make validate 2>&1)",
|
||||
"mcp__circleci-mcp-server__get_job_test_results",
|
||||
"Bash(pnpm typecheck:*)",
|
||||
"Bash(pnpm dev:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(echo Exit: $?:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(pnpm why:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(chmod +x:*)",
|
||||
"Bash(done)",
|
||||
"Bash(pnpm exec:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
test-results
|
||||
playwright-report
|
||||
coverage
|
||||
.env*
|
||||
*.log
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# .githooks/pre-commit
|
||||
#
|
||||
# Enforces the Apache 2.0 license header on all staged TypeScript/TSX files.
|
||||
# Registered automatically via "prepare" in package.json.
|
||||
#
|
||||
# To install hooks manually:
|
||||
# git config core.hooksPath .githooks
|
||||
|
||||
pnpm lint:license
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Real-backend acceptance
|
||||
|
||||
# Full QRun-IO/qqq#649 acceptance: the QRun-owned sample application built from QQQ source,
|
||||
# serving this repository's production static export, driven by Playwright in each browser.
|
||||
# The matrix gate fails on any required row without a first-attempt pass.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
qqq_ref:
|
||||
description: QRun-IO/qqq branch or tag to build the sample backend from
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: acceptance-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# The released QQQ version this dashboard is accepted against; override per run.
|
||||
QQQ_REF: ${{ inputs.qqq_ref || vars.QQQ_ACCEPTANCE_REF || 'develop' }}
|
||||
NEXT_TELEMETRY_DISABLED: '1'
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# The phone project runs only @mobile tests, so it shares the chromium job's gate.
|
||||
browser: ['chromium,mobile', firefox, webkit]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: QRun-IO/qqq
|
||||
ref: ${{ env.QQQ_REF }}
|
||||
path: qqq-backend
|
||||
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
cache: maven
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
- name: Build QQQ from source
|
||||
run: mvn -B -q -f qqq-backend/pom.xml install -DskipTests -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true
|
||||
- name: Build and install the dashboard jar (static export)
|
||||
run: |
|
||||
pnpm build:jar
|
||||
mvn -B -q install
|
||||
echo "NEXT_JAR_VERSION=$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)" >> "$GITHUB_ENV"
|
||||
- name: Build the sample application jar
|
||||
run: |
|
||||
# Run from the QQQ checkout: its checkstyle/pmd configuration paths are relative to it.
|
||||
(cd qqq-backend && mvn -B -q -f qqq-sample-project/pom.xml package -DskipTests -Djacoco.skip=true \
|
||||
-Dqqq.frontend.next.version="$NEXT_JAR_VERSION")
|
||||
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/qqq-backend/qqq-sample-project/target/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
|
||||
- name: Acceptance (${{ matrix.browser }})
|
||||
env:
|
||||
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
|
||||
run: node scripts/acceptance.mjs --skip-build
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: acceptance-${{ matrix.browser == 'chromium,mobile' && 'chromium-mobile' || matrix.browser }}
|
||||
path: test-results/acceptance/
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Dashboard image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/qrun-io/qqq-frontend-next
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
if: github.event_name == 'push'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/quickstart/Dockerfile
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
load: ${{ github.event_name == 'pull_request' }}
|
||||
tags: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=dashboard-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=dashboard-${{ matrix.arch }}
|
||||
- name: Verify runnable dashboard
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
TAG: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
run: |
|
||||
container=$(docker run -d --rm -p 127.0.0.1:3000:3000 "$TAG")
|
||||
trap 'docker rm -f "$container"' EXIT
|
||||
curl -fsS --max-time 3 --retry 30 --retry-delay 1 --retry-all-errors \
|
||||
--retry-max-time 90 http://127.0.0.1:3000/app/person >/dev/null \
|
||||
|| { docker logs "$container"; exit 1; }
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push'
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish versioned multi-architecture image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag "$IMAGE:${GITHUB_REF_NAME#v}" \
|
||||
"$IMAGE:$GITHUB_SHA-amd64" "$IMAGE:$GITHUB_SHA-arm64"
|
||||
@@ -34,3 +34,9 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.claude/
|
||||
target/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/log/
|
||||
/.playwright-mcp/
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-docs',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/nextjs',
|
||||
options: {},
|
||||
},
|
||||
webpackFinal: async (config) => {
|
||||
if (config.resolve) {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@': fileURLToPath(new URL('../src', import.meta.url)),
|
||||
}
|
||||
}
|
||||
return config
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Preview } from '@storybook/react'
|
||||
import '../src/styles/globals.css'
|
||||
|
||||
const preview: Preview = {
|
||||
tags: ['autodocs'],
|
||||
initialGlobals: { backgrounds: { value: 'light' } },
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
options: {
|
||||
light: { name: 'light', value: '#ffffff' },
|
||||
dark: { name: 'dark', value: '#0f172a' },
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default preview
|
||||
@@ -169,3 +169,16 @@ The current frontend being replaced lives in the sibling `qqq-frontend-material-
|
||||
|
||||
Set via `NEXT_PUBLIC_API_BASE_URL` environment variable. Defaults to `/qqq/v1`.
|
||||
The API uses cookie-based session auth (`sessionUUID` cookie).
|
||||
|
||||
## Knowledge base
|
||||
|
||||
Durable, cross-session knowledge about this repo and the wider QQQ platform lives in
|
||||
the second-brain vault (`R:/Git.Local/KofTwentyTwo/second-brain`):
|
||||
|
||||
- Platform hub: `knowledge/qqq/qqq-hub.md`
|
||||
- This repo's dossier: `knowledge/qqq/repos/qqq-frontend-next.md`
|
||||
(reviewed at commit `41cd6d2034a8` on branch `main`, 2026-07-04)
|
||||
|
||||
Consult the dossier before deep-diving the code — it covers the API contract this
|
||||
frontend targets (including versioned-middleware endpoint gaps), maturity assessment,
|
||||
known parity gaps, and its relationship to `qqq-frontend-material-dashboard`.
|
||||
@@ -1 +1,123 @@
|
||||
# qqq-frontend-next
|
||||
# QQQ Frontend Next
|
||||
|
||||
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
|
||||
|
||||
## Use it in a QQQ application
|
||||
|
||||
The dashboard is the default admin UI for QQQ 4.1+. Add the jar (managed by `qqq-bom-pom`) and `QApplicationJavalinServer` serves it at `/`, on the same port and origin as the API:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
The jar holds the static export (`pnpm build:export`) under `next-dashboard/`. Deep links such as `/app/person/1` are served from placeholder pages and read their route from the browser path, so no Node.js server is involved. The Material Dashboard remains available: select it with `withServeFrontendMaterialDashboard(true)` or `-Dqqq.javalin.frontend=material`. Material routes are `/<app>/<table>/<id>`; Next routes are `/app/<table>/<id>`.
|
||||
|
||||
Feature coverage is certified by the real-backend acceptance matrix in [`docs/acceptance/feature-matrix.md`](docs/acceptance/feature-matrix.md) ([QRun-IO/qqq#649](https://github.com/QRun-IO/qqq/issues/649)). Run it with `pnpm test:acceptance` (see [`tests/acceptance/README.md`](tests/acceptance/README.md)).
|
||||
|
||||
## Run the local QQQ sample
|
||||
|
||||
Use the [QQQ quickstart](https://github.com/QRun-IO/qqq/blob/main/qqq-sample-project/README.md#quickstart-with-next): it clones editable Java sample source, compiles it against published QQQ, and serves this dashboard from the sample on port 8000. It needs JDK 21+, Git, curl and unzip; no Node.js, Maven or Docker installation.
|
||||
|
||||
A container image (`ghcr.io/qrun-io/qqq-frontend-next`, built from `docker/quickstart/Dockerfile`) remains available for running the dashboard as a separate Node server in front of a backend on host port 8000. The backend origin is fixed at image build time (`QQQ_BACKEND_URL`).
|
||||
|
||||
## Frontend development prerequisites
|
||||
|
||||
- **Node.js** 20.19+ or 22.12+ (required by the Storybook developer tooling)
|
||||
- **pnpm** (install with `npm install -g pnpm` if not already present)
|
||||
|
||||
## Frontend development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QRun-IO/qqq-frontend-next.git
|
||||
cd qqq-frontend-next
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The dev server starts at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | `/qqq/v1` | Base URL for all QQQ backend API calls |
|
||||
| `QQQ_BACKEND_URL` | _(unset)_ | Standalone build only: backend origin for same-origin API rewrites; the container image uses `http://host.docker.internal:8000` |
|
||||
| `QQQ_NEXT_OUTPUT` | _(unset)_ | `export` produces the static export for QQQ hosting (`pnpm build:export`) |
|
||||
| `NEXT_PUBLIC_MOCK_API` | _(unset)_ | Set to `true` to activate MSW browser mocks for dev/demo without a live backend |
|
||||
|
||||
Copy `.env.example` to `.env.local` and adjust as needed.
|
||||
|
||||
## Available Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pnpm dev` | Start the Next.js development server (Turbopack) |
|
||||
| `pnpm build` | Compile the standalone (Node server) production build |
|
||||
| `pnpm build:export` | Compile the static export served by QQQ (`out/`) |
|
||||
| `pnpm build:jar` | Static export packaged as the `qqq-frontend-next` Maven jar |
|
||||
| `pnpm test:acceptance` | Real-backend acceptance suite and matrix gate (needs `QQQ_SAMPLE_JAR`) |
|
||||
| `pnpm start` | Serve the production build |
|
||||
| `pnpm test` | Run Vitest unit tests (single pass) |
|
||||
| `pnpm test:watch` | Run Vitest in watch mode |
|
||||
| `pnpm test:coverage` | Run Vitest with V8 coverage report |
|
||||
| `pnpm test:e2e` | Run Playwright end-to-end tests |
|
||||
| `pnpm typecheck` | Run `tsc --noEmit` (type check without emitting files) |
|
||||
| `pnpm lint` | Run ESLint + Apache license header check |
|
||||
| `pnpm format` | Run Prettier over all source files |
|
||||
| `pnpm storybook` | Start Storybook component explorer on port 6006 |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ ├── (auth)/ # Login and OAuth callback routes
|
||||
│ ├── (dashboard)/ # Authenticated shell (sidebar + nav)
|
||||
│ │ └── app/ # All app routes (tables, records, processes)
|
||||
│ ├── layout.tsx # Root layout — providers
|
||||
│ ├── error.tsx # Global error boundary
|
||||
│ └── not-found.tsx # 404 page
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives (Button, Input, Dialog, …)
|
||||
│ ├── layout/ # Sidebar, Header, Breadcrumbs, Banner
|
||||
│ ├── records/ # RecordView, FieldValue
|
||||
│ ├── forms/ # DynamicForm, EntityForm, field components
|
||||
│ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination
|
||||
│ ├── process/ # StepWizard, ValidationReview, BulkLoad
|
||||
│ ├── widgets/ # Widget containers, charts, stat blocks, grids
|
||||
│ └── feedback/ # Alerts, Toasts, Modals, CommandMenu
|
||||
├── lib/
|
||||
│ ├── api/ # Typed API client modules (client.ts, tables.ts, …)
|
||||
│ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
|
||||
│ ├── hooks/ # Shared React hooks (useLocalStorage, useMetadata, …)
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ ├── context/ # React contexts (QContext)
|
||||
│ └── theme/ # Theme provider and CSS custom property tokens
|
||||
├── types/ # TypeScript type definitions
|
||||
└── styles/ # Global styles and theme tokens
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|---|---|
|
||||
| [`CLAUDE.md`](./CLAUDE.md) | Full architecture reference — conventions, patterns, key decisions |
|
||||
| [`docs/STATE-MANAGEMENT.md`](./docs/STATE-MANAGEMENT.md) | When to use each state container (TanStack Query, RHF, URL params, localStorage, QContext) |
|
||||
| [`docs/API-ERROR-HANDLING.md`](./docs/API-ERROR-HANDLING.md) | Global error handler, 401 interceptor, per-status guidance, code examples |
|
||||
| [`docs/implementation-plans/`](./docs/implementation-plans/) | Work-package implementation plans (6 packages) |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router) with React 19
|
||||
- **Language:** TypeScript 5.x (`strict: true`)
|
||||
- **Styling:** Tailwind CSS 4.x with CSS custom properties
|
||||
- **Components:** shadcn/ui (Radix primitives + Tailwind)
|
||||
- **Data Grid:** TanStack Table v8
|
||||
- **Forms:** React Hook Form v7 + Zod
|
||||
- **Data Fetching:** TanStack Query v5
|
||||
- **Charts:** Recharts
|
||||
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
|
||||
- **Package Manager:** pnpm
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM registry-1.docker.io/library/node:22-bookworm-slim AS frontend
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
ENV QQQ_BACKEND_URL=http://host.docker.internal:8000
|
||||
ENV NEXT_PUBLIC_MOCK_API=false
|
||||
RUN pnpm build
|
||||
|
||||
FROM registry-1.docker.io/library/node:22-bookworm-slim AS runtime
|
||||
LABEL org.opencontainers.image.source="https://github.com/QRun-IO/qqq-frontend-next"
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
|
||||
COPY --from=frontend --chown=node:node /app/.next/standalone ./
|
||||
COPY --from=frontend --chown=node:node /app/.next/static ./.next/static
|
||||
COPY --from=frontend --chown=node:node /app/public ./public
|
||||
COPY LICENSE ./LICENSE
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,338 @@
|
||||
# Accessibility Reference — QQQ Frontend Next
|
||||
|
||||
**Standard:** WCAG 2.1 Level AA
|
||||
**Testing tools:** axe-core (automated), Playwright E2E assertions, manual keyboard testing
|
||||
|
||||
---
|
||||
|
||||
## WCAG 2.1 AA Compliance Status
|
||||
|
||||
The sections below summarise what has been implemented and what remains outstanding,
|
||||
based on the design review conducted 2026-03-01 (`docs/DESIGN-REVIEW-2026-03-01.md`).
|
||||
|
||||
---
|
||||
|
||||
## Key Implementations (Resolved Items)
|
||||
|
||||
### Skip-to-Content Link (D-L-1)
|
||||
|
||||
A visually hidden skip link is rendered as the first element in the dashboard layout
|
||||
(`src/app/(dashboard)/layout.tsx`). It becomes visible on keyboard focus and links to
|
||||
`#main-content`, allowing keyboard users to bypass the sidebar on every page navigation.
|
||||
|
||||
```tsx
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 ..."
|
||||
data-qqq-id="skip-to-content"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
```
|
||||
|
||||
The target `<main id="main-content" data-qqq-id="main-content">` is present in the same layout.
|
||||
|
||||
---
|
||||
|
||||
### `aria-sort` on DataGrid Column Headers (D-Q-1)
|
||||
|
||||
`src/components/query/DataGrid.tsx` computes an `aria-sort` value for every `<th>` based on
|
||||
the current TanStack Table sort state:
|
||||
|
||||
- Sorted ascending → `aria-sort="ascending"`
|
||||
- Sorted descending → `aria-sort="descending"`
|
||||
- Not sorted → `aria-sort="none"` (only on sortable columns; unsortable columns omit the attribute)
|
||||
|
||||
Screen readers announce the sort direction when the user focuses or activates a column header.
|
||||
|
||||
---
|
||||
|
||||
### Row Selection `aria-label` (D-Q-2)
|
||||
|
||||
Each row's selection checkbox carries a descriptive label:
|
||||
|
||||
```tsx
|
||||
aria-label={`Select ${row.original.recordLabel ?? 'record'}`}
|
||||
```
|
||||
|
||||
The "select all" header checkbox uses `aria-label="Select all rows on this page"`.
|
||||
|
||||
---
|
||||
|
||||
### Keyboard-Accessible Column Resize Handles (D-Q-3)
|
||||
|
||||
The resize handle on each column header responds to `ArrowLeft` and `ArrowRight` keyboard
|
||||
events, adjusting the column width by ±10 px per keypress. The handler is implemented in
|
||||
`handleResizeKeyDown` inside `DataGrid.tsx`:
|
||||
|
||||
```typescript
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
const delta = e.key === 'ArrowRight' ? 10 : -10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `aria-live` Region for Fetch-in-Progress (D-Q-6)
|
||||
|
||||
The DataGrid wraps the loading progress bar and result count in an `aria-live="polite"` region
|
||||
so that background re-fetches triggered by sort or filter changes are announced to screen readers
|
||||
without interrupting the current reading flow.
|
||||
|
||||
---
|
||||
|
||||
### Focus Management in ProcessRun (D-P-1)
|
||||
|
||||
`src/components/process/ProcessRun.tsx` uses a `stepHeadingRef` and a `useEffect` keyed on the
|
||||
active step name to move focus to the step heading whenever the wizard advances or retreats.
|
||||
This satisfies WCAG 2.4.3 (Focus Order) by ensuring screen reader users are positioned at the
|
||||
start of each new step:
|
||||
|
||||
```typescript
|
||||
const stepHeadingRef = useRef<HTMLHeadingElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRenderedFirstStep.current) {
|
||||
stepHeadingRef.current?.focus()
|
||||
}
|
||||
hasRenderedFirstStep.current = true
|
||||
}, [activeStepName])
|
||||
```
|
||||
|
||||
The target `<h2>` carries `tabIndex={-1}` and is referenced by `ref={stepHeadingRef}`.
|
||||
|
||||
---
|
||||
|
||||
### Screen-Reader Text for Trend Indicators (D-W-1)
|
||||
|
||||
`src/components/widgets/StatisticsWidget.tsx` renders a visually hidden `<span>` next to each
|
||||
trend arrow icon so the direction is announced by screen readers:
|
||||
|
||||
```tsx
|
||||
<span className="sr-only">{config.srLabel}</span>
|
||||
// srLabel values: 'trending up,' | 'trending down,' | 'no change,'
|
||||
```
|
||||
|
||||
The icon itself is `aria-hidden="true"` so it is not double-announced.
|
||||
|
||||
---
|
||||
|
||||
### Submit Button Disabled While Submitting (D-F-1)
|
||||
|
||||
`src/components/forms/EntityForm.tsx` reads `isSubmitting` from React Hook Form's `formState`
|
||||
and applies it to all action buttons, preventing duplicate submissions:
|
||||
|
||||
```tsx
|
||||
disabled={disabled || isSubmitting}
|
||||
// Label also changes: isSubmitting ? 'Saving...' : saveButtonLabel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Baseline Form Accessibility
|
||||
|
||||
All form inputs across `DynamicForm`, `EntityForm`, and individual field components have:
|
||||
|
||||
- `<label>` with `htmlFor` wired to the input `id`
|
||||
- `aria-required={field.isRequired}`
|
||||
- `aria-invalid={!!errors[field.name]}`
|
||||
- `aria-describedby` pointing to the error message element
|
||||
- Required asterisk marked `aria-hidden="true"` to avoid screen-reader repetition
|
||||
- Help tooltips use Radix Tooltip, which is keyboard-accessible via `Tab` / `Escape`
|
||||
|
||||
---
|
||||
|
||||
### CSS Media Queries
|
||||
|
||||
#### `prefers-reduced-motion` (D-L-2 / D-T-1)
|
||||
|
||||
`src/styles/globals.css` collapses all animation and transition durations to near-zero
|
||||
when the user's OS has reduced motion enabled. This resolves skeleton pulse, sidebar
|
||||
slide-in, toast fade, and all other CSS transitions:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `prefers-contrast: more` (D-T-2)
|
||||
|
||||
A high-contrast media block increases border weights and forces text colors to fully opaque
|
||||
black, targeting users who have enabled "Increase Contrast" in their OS settings:
|
||||
|
||||
```css
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--color-border: #000000;
|
||||
--color-input: #000000;
|
||||
--color-muted-foreground: #000000;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
border-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid #000000 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Mobile 16px Font-Size Fix (D-R-1)
|
||||
|
||||
iOS Safari auto-zooms any focused input whose `font-size` is below 16px. A mobile-scoped rule
|
||||
prevents this without affecting desktop appearance:
|
||||
|
||||
```css
|
||||
@media screen and (max-width: 767px) {
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Dark-Mode Focus Ring Fix (D-T-5)
|
||||
|
||||
The default `color-mix`-based focus ring can disappear on dark backgrounds. A dark-mode
|
||||
override forces the ring to a solid, fully opaque color:
|
||||
|
||||
```css
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:focus-visible {
|
||||
outline-color: var(--color-ring);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Additional Resolved Items (from `What's Working Well`)
|
||||
|
||||
| Item | Implementation |
|
||||
|------|----------------|
|
||||
| `aria-required`, `aria-invalid`, `aria-describedby` on all form inputs | `DynamicFormField.tsx`, `EntityForm.tsx` |
|
||||
| `role="status"` + `aria-busy="true"` on skeleton components | All skeleton components |
|
||||
| `role="status"` + `aria-live="polite"` on `EmptyState` | `EmptyState.tsx` |
|
||||
| Full keyboard nav in SearchDialog (ArrowUp/Down/Enter/Escape) | `SearchDialog.tsx` |
|
||||
| Breadcrumbs: `aria-label="Breadcrumb"` + `aria-current="page"` | `Breadcrumbs.tsx` |
|
||||
| `aria-expanded` on mobile drawer | `Sidebar.tsx` |
|
||||
| DOMPurify sanitization on all user-controlled HTML output | `FieldValue.tsx`, `BlockWidget.tsx`, `ProcessHtmlStep.tsx` |
|
||||
| Unsaved-changes guard: `beforeunload` + confirm modal | `EntityForm.tsx` |
|
||||
| Focus rings on all interactive elements via `:focus-visible` | `globals.css` |
|
||||
| Muted text darkened to `#5a6270` for WCAG AA contrast margin | `globals.css` |
|
||||
|
||||
---
|
||||
|
||||
## Automated Testing
|
||||
|
||||
### Running axe-core Scans
|
||||
|
||||
The helper in `tests/e2e/a11y-helpers.ts` wraps `@axe-core/playwright` and asserts zero
|
||||
violations against the `wcag2a`, `wcag2aa`, and `wcag21aa` rule sets:
|
||||
|
||||
```typescript
|
||||
import { checkA11y } from './a11y-helpers'
|
||||
|
||||
test('page is accessible', async ({ page }) => {
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
await checkA11y(page)
|
||||
})
|
||||
```
|
||||
|
||||
`checkA11y` runs on every navigation E2E test in `navigation.spec.ts`. To run all E2E tests
|
||||
including accessibility assertions:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
To run a single spec with axe output in the terminal:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e -- tests/e2e/navigation.spec.ts
|
||||
```
|
||||
|
||||
### Known Suppressed Violations (with TODOs)
|
||||
|
||||
Three rule violations are currently disabled in `checkA11y` because the underlying issues
|
||||
are not yet fixed. Each has a TODO comment in `a11y-helpers.ts`:
|
||||
|
||||
| Rule | Reason suppressed | TODO |
|
||||
|------|-------------------|------|
|
||||
| `color-contrast` | Amber banner background (#f59e0b) and indigo sidebar/button states yield contrast ratios just below 4.5:1 | Audit all Tailwind color pairs in Banner, Sidebar, and Button and replace with WCAG AA-compliant pairs |
|
||||
| `aria-input-field-name` | Some form inputs rendered by `DynamicForm` may have broken `<label htmlFor>` associations when IDs are generated dynamically | Ensure every `<input>` has either a matching `htmlFor` or an `aria-label` |
|
||||
| `scrollable-region-focusable` | The TanStack Table scrollable wrapper `<div>` has no `tabIndex`, blocking keyboard scrolling | Add `tabIndex={0}` to the scrollable DataGrid wrapper |
|
||||
|
||||
---
|
||||
|
||||
## Outstanding Work
|
||||
|
||||
The following items from the design review are **not yet resolved** and require future work.
|
||||
|
||||
### High Priority (WCAG 2.1 AA gaps)
|
||||
|
||||
| ID | Area | Description |
|
||||
|----|------|-------------|
|
||||
| D-V-1 | Record View | Edit/Delete buttons render regardless of user permissions; they fail with 403 on action instead of being hidden/disabled based on `editPermission`/`deletePermission`. |
|
||||
| D-L-3 | Layout | Global search is hidden on mobile (`hidden md:block`); mobile users have no access to search. |
|
||||
|
||||
### Medium Priority (UX gaps)
|
||||
|
||||
| ID | Area | Description |
|
||||
|----|------|-------------|
|
||||
| D-L-5 | Layout | Mobile sidebar `aria-expanded` state may not be consistently set on the nav landmark. |
|
||||
| D-Q-4 | FilterBuilder | Async combobox shows no loading indicator while possible-value options are fetching. |
|
||||
| D-Q-5 | Pagination | "Go to page" input silently ignores out-of-range values with no user feedback. |
|
||||
| D-Q-7 | DataGrid | Column visibility toggle changes are not announced via a `role="status"` region. |
|
||||
| D-P-2 | Process | Completed step indicators in the wizard are not keyboard-navigable for back-navigation. |
|
||||
| D-P-3 | Process | No timeout message if backend polling runs beyond ~60 seconds. |
|
||||
| D-P-4 | Process | Validation review result list has no severity filter or field-name search. |
|
||||
| D-P-5 | Process | File upload step shows no byte-level progress during multipart upload. |
|
||||
| D-W-2 | Widgets | Widget API call failures show blank space with no error boundary or fallback UI. |
|
||||
| D-FB-1 | Feedback | Keyboard Shortcuts dialog is not discoverable (no visible `?` hint in the header). |
|
||||
| D-FB-2 | Feedback | Toasts have no dismiss button; users must wait for auto-timeout. |
|
||||
| D-FB-3 | Feedback | CommandMenu icon buttons may be below the 44px minimum touch target size. |
|
||||
| D-R-2 | Responsive | Multiple interactive elements (pagination controls, density buttons) below 44px touch target. |
|
||||
|
||||
### Items Deferred Pending Backend Support
|
||||
|
||||
| ID | Area | Description |
|
||||
|----|------|-------------|
|
||||
| D-V-6 | Record View | "Last modified by / at" audit footer requires backend to expose this data on the record endpoint. |
|
||||
| D-T-3 | Theme | Dual theming systems (`--qqq-primary-color` and `--color-primary`) need consolidation; depends on coordinating with backend-supplied theme values. |
|
||||
|
||||
---
|
||||
|
||||
## Checklist for New Component Authors
|
||||
|
||||
When adding a new interactive component, verify the following before opening a pull request:
|
||||
|
||||
- [ ] Every `<input>`, `<select>`, and `<textarea>` has a `<label htmlFor>` that matches its `id`, or an `aria-label` / `aria-labelledby` attribute.
|
||||
- [ ] Required inputs have `aria-required={true}`.
|
||||
- [ ] Invalid inputs have `aria-invalid={true}` and an `aria-describedby` pointing to the error message element.
|
||||
- [ ] All icon-only buttons have a descriptive `aria-label` (e.g. `aria-label="Close dialog"`).
|
||||
- [ ] Icon elements that are purely decorative are marked `aria-hidden="true"`.
|
||||
- [ ] Modals/dialogs trap focus while open and restore it to the triggering element on close (use Radix Dialog for this automatically).
|
||||
- [ ] All interactive elements are reachable and operable via `Tab` / `Shift+Tab` / `Enter` / `Space` / `Escape`.
|
||||
- [ ] Interactive elements have a visible `:focus-visible` ring (do not add `outline: none` without a replacement).
|
||||
- [ ] Any element that announces dynamic state changes uses `aria-live="polite"` (or `role="status"`).
|
||||
- [ ] Touch targets are at least 44x44px (`min-h-[44px] min-w-[44px]` or equivalent padding).
|
||||
- [ ] Color is not the only means of conveying information (pair color with text or icon).
|
||||
- [ ] Text color meets a minimum 4.5:1 contrast ratio against its background for normal-weight text at normal size.
|
||||
- [ ] The component does not break at 320px viewport width.
|
||||
- [ ] The component respects `prefers-reduced-motion` (animations driven by CSS transitions inherit the global rule in `globals.css`; JS-driven animations need a manual check).
|
||||
- [ ] Any rendered HTML that originates from user input or the backend is sanitized with DOMPurify before being set via `dangerouslySetInnerHTML`.
|
||||
- [ ] Run `pnpm test:e2e` and confirm `checkA11y` passes for any page that includes the new component.
|
||||
@@ -0,0 +1,310 @@
|
||||
# API Error Handling Guide
|
||||
|
||||
This document describes how API errors flow through the QQQ Frontend Next application and what, if anything, individual components need to do about them.
|
||||
|
||||
The short answer is: **most errors require no per-call handling**. The global error handler in the query client toasts an appropriate message automatically, and the 401 interceptor in the Axios client redirects to login. Individual components only need to add their own handling when the generic behavior is insufficient for a specific UX requirement.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
There are two independent error interception layers:
|
||||
|
||||
1. **Axios response interceptor** (`src/lib/api/client.ts`) — fires synchronously on every HTTP response before the promise settles. Handles 401 (unauthorized) only.
|
||||
2. **TanStack Query global handlers** (`src/lib/query-client.ts`) — `QueryCache.onError` for reads, `MutationCache.onError` for writes. Fire after the interceptor. Handle all other errors by toasting a message.
|
||||
|
||||
```
|
||||
API response
|
||||
│
|
||||
▼
|
||||
Axios interceptor
|
||||
│ 401 → call unauthorizedCallback() → redirect to login
|
||||
│ all others → pass through as rejected promise
|
||||
▼
|
||||
TanStack Query cache (onError)
|
||||
│ 401 → silently ignored (already handled)
|
||||
│ 403 → toast "Permission denied" / "You do not have permission…"
|
||||
│ 404 → toast "Resource not found"
|
||||
│ 5xx → toast "Server error — retrying…" / "Server error — please try again"
|
||||
│ network → toast "Something went wrong"
|
||||
▼
|
||||
Component (optional per-query onError)
|
||||
│ Only needed when generic toast is wrong for the specific UX
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The 401 Interceptor
|
||||
|
||||
**Location:** `src/lib/api/client.ts`
|
||||
|
||||
The singleton `APIClient` registers a response interceptor that fires on every request:
|
||||
|
||||
```typescript
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401 && this.unauthorizedCallback) {
|
||||
this.unauthorizedCallback()
|
||||
}
|
||||
return Promise.reject(error) // always re-reject so TanStack Query also sees it
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
The `unauthorizedCallback` is wired to the auth provider's logout/redirect function during initialization. When a 401 arrives:
|
||||
|
||||
1. The callback fires → clears auth state → redirects to the login page.
|
||||
2. The error is still re-rejected, so TanStack Query's `onError` also fires.
|
||||
3. The global handler in `query-client.ts` explicitly silences 401s to avoid a redundant toast on top of the redirect.
|
||||
|
||||
**Implication for components:** Never handle 401 in a component. The interceptor + auth provider handle it globally. Writing per-component 401 handling will race with the redirect and confuse users.
|
||||
|
||||
---
|
||||
|
||||
## The Global Query Error Handler
|
||||
|
||||
**Location:** `src/lib/query-client.ts` — `handleQueryError` function
|
||||
|
||||
```typescript
|
||||
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
|
||||
const status = getErrorStatusCode(error)
|
||||
|
||||
if (status === 401) return // handled by axios interceptor
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(
|
||||
context === 'mutation'
|
||||
? 'You do not have permission to perform this action'
|
||||
: 'Permission denied'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
toast.error('Resource not found')
|
||||
return
|
||||
}
|
||||
|
||||
if (status !== undefined && status >= 500) {
|
||||
toast.error(
|
||||
context === 'mutation'
|
||||
? 'Server error — please try again'
|
||||
: 'Server error — retrying…'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Network failure or unexpected status
|
||||
toast.error('Something went wrong')
|
||||
}
|
||||
```
|
||||
|
||||
This handler is registered on both `QueryCache` and `MutationCache`:
|
||||
|
||||
```typescript
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => handleQueryError(error, 'query'),
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => handleQueryError(error, 'mutation'),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
**Implication for components:** For most queries and mutations, you do not need `onError` at all. The global handler covers toasting. Add per-query handling only when you need to do something the global handler cannot — such as mapping a 400 error's field messages back to form fields.
|
||||
|
||||
---
|
||||
|
||||
## Extracting the Status Code
|
||||
|
||||
**Location:** `src/lib/utils/error-utils.ts`
|
||||
|
||||
```typescript
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
|
||||
const status = getErrorStatusCode(error)
|
||||
```
|
||||
|
||||
`getErrorStatusCode` handles two shapes:
|
||||
- `AxiosError` — reads `error.response?.status`
|
||||
- Plain objects with a numeric `status` property (e.g. fetch-based wrappers)
|
||||
|
||||
Returns `undefined` for anything else (non-object errors, null, network failures with no response).
|
||||
|
||||
---
|
||||
|
||||
## Status-by-Status Guide
|
||||
|
||||
### 400 — Validation Error
|
||||
|
||||
The global handler does NOT toast on 400 because 400s typically carry field-level validation messages that must be mapped back to individual form fields. Handle 400 explicitly wherever you submit a form.
|
||||
|
||||
```typescript
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FieldValues) => saveRecord('order', data),
|
||||
onError: (error) => {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
// Map backend validation messages to React Hook Form fields
|
||||
const fieldErrors = extractFieldErrors(error) // your helper
|
||||
for (const [field, message] of Object.entries(fieldErrors)) {
|
||||
form.setError(field, { type: 'server', message })
|
||||
}
|
||||
}
|
||||
// Non-400 errors are already toasted by the global handler
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
If a 400 arrives outside of a form context, add a toast yourself:
|
||||
|
||||
```typescript
|
||||
onError: (error) => {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
toast.error('Invalid request — please check your input')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 403 — Permission Denied
|
||||
|
||||
The global handler toasts a generic permission-denied message. If the 403 should instead silently hide an action (e.g. a button that should not appear for users without permission), suppress the default behavior by checking permissions from metadata before rendering rather than relying on the API to reject:
|
||||
|
||||
```typescript
|
||||
// Preferred: hide the action before the user tries it
|
||||
{tableMetaData.capabilities.includes('insert') && (
|
||||
<Button onClick={openCreateModal}>New Record</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
If you need custom handling on a 403, use `onError` and call `toast` yourself — or do nothing if the generic toast is acceptable.
|
||||
|
||||
### 404 — Not Found
|
||||
|
||||
The global handler toasts "Resource not found". For record-view pages where 404 means the record was deleted, render an empty state instead:
|
||||
|
||||
```typescript
|
||||
const { data: record, error } = useQuery({
|
||||
queryKey: queryKeys.tableRecord(tableName, recordId),
|
||||
queryFn: () => getRecord(tableName, recordId),
|
||||
})
|
||||
|
||||
if (getErrorStatusCode(error) === 404) {
|
||||
return <EmptyState message="This record no longer exists" />
|
||||
}
|
||||
```
|
||||
|
||||
The global handler will also toast, which is acceptable — the empty state just provides additional UI context.
|
||||
|
||||
### 5xx — Server Error
|
||||
|
||||
The global handler toasts "Server error — retrying…" for queries and "Server error — please try again" for mutations. Queries are automatically retried up to 3 times with exponential back-off (1 s → 2 s → 4 s, max 30 s) before the error surfaces.
|
||||
|
||||
No additional per-component handling is needed for 5xx unless you want a custom retry UI or a structured error state for a specific page.
|
||||
|
||||
### Network Failure (no response)
|
||||
|
||||
Treated as non-4xx by the retry logic — queries retry up to 3 times. The global handler toasts "Something went wrong" after all retries are exhausted. No per-component handling needed for the common case.
|
||||
|
||||
---
|
||||
|
||||
## When to Add Per-Query Error Handling
|
||||
|
||||
Add `onError` to a specific `useQuery` or `useMutation` call when:
|
||||
|
||||
| Scenario | What to do |
|
||||
|---|---|
|
||||
| 400 with field-level validation messages | Map errors to form fields with `form.setError()` |
|
||||
| 403 should render empty state instead of toast | Catch 403, render empty UI, call `toast` yourself if the global one also fires |
|
||||
| 404 should render a structured empty state page | Check `getErrorStatusCode(error) === 404` and render `<EmptyState>` |
|
||||
| Error should navigate away (e.g. deleted parent record) | `useRouter().push('/...')` in `onError` |
|
||||
| The generic toast message is wrong for this specific action | Supply a custom `onError` that calls `toast.error(customMessage)` |
|
||||
|
||||
For all other cases, rely on the global handler and write no error-handling code in the component.
|
||||
|
||||
---
|
||||
|
||||
## Retry Behavior
|
||||
|
||||
The `smartRetry` function in `src/lib/query-client.ts` controls when TanStack Query retries:
|
||||
|
||||
```typescript
|
||||
function smartRetry(failureCount: number, error: unknown): boolean {
|
||||
const status = getErrorStatusCode(error)
|
||||
// Never retry client errors — they are deterministic
|
||||
if (status !== undefined && status >= 400 && status < 500) return false
|
||||
// Retry server errors and network failures up to 3 times
|
||||
return failureCount < 3
|
||||
}
|
||||
```
|
||||
|
||||
Back-off schedule: 1 s → 2 s → 4 s, capped at 30 s.
|
||||
|
||||
4xx errors are never retried. There is no per-component way to opt into retrying 4xx errors — that would be intentional since a 400 with bad field data will not resolve itself on retry.
|
||||
|
||||
---
|
||||
|
||||
## Complete Example — Form with 400 Handling
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { saveRecord } from '@/lib/api/tables'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
|
||||
export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const form = useForm({ resolver: zodResolver(schema), defaultValues })
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FieldValues) => saveRecord('order', orderId, data),
|
||||
onSuccess: () => {
|
||||
// Invalidate so the record view refreshes
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord('order', orderId) })
|
||||
toast.success('Order saved')
|
||||
},
|
||||
onError: (error) => {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
// Map field errors from the server response body to form fields.
|
||||
// The global handler does NOT toast on 400, so we must do it here or
|
||||
// rely on inline field error messages in the form UI.
|
||||
const body = (error as AxiosError<{ errors: Record<string, string> }>).response?.data
|
||||
if (body?.errors) {
|
||||
for (const [field, message] of Object.entries(body.errors)) {
|
||||
form.setError(field, { type: 'server', message })
|
||||
}
|
||||
} else {
|
||||
toast.error('Please correct the highlighted fields')
|
||||
}
|
||||
return
|
||||
}
|
||||
// 401, 403, 404, 5xx, network — already toasted by the global handler
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))}>
|
||||
{/* field components */}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Layer | File | Handles |
|
||||
|---|---|---|
|
||||
| Axios interceptor | `src/lib/api/client.ts` | 401 → redirect to login |
|
||||
| Global query handler | `src/lib/query-client.ts` | 403, 404, 5xx, network → toast |
|
||||
| Per-component `onError` | Your component | 400 field errors, custom empty states, navigation |
|
||||
| `getErrorStatusCode` | `src/lib/utils/error-utils.ts` | Normalize `AxiosError` and plain-object errors to HTTP status code |
|
||||
@@ -0,0 +1,316 @@
|
||||
# Code Quality Review — QQQ Frontend Next
|
||||
|
||||
**Date:** 2026-02-27
|
||||
**Scope:** DRY, KISS, component design, hooks, TypeScript quality, naming, structure
|
||||
**Note:** Security/runtime bugs are documented separately in `CODE-REVIEW-2026-02-27.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The codebase demonstrates **strong architectural foundations** with excellent separation of concerns and metadata-driven rendering patterns. However, there are **significant code quality issues** across multiple dimensions: substantial DRY violations in filter/combobox logic, an overengineered god-hook, inconsistent error handling approaches, and TypeScript practices that miss opportunities for stronger typing. The `use-record-query` hook is an anti-pattern — a 540-line hook mixing data fetching, URL synchronization, localStorage persistence, and UI state. FilterBuilder has two combobox implementations that are ~95% identical. Several utilities and constants are defined inline or scattered across files. While the component structure is clean and the metadata-driven approach is correct, the implementation needs targeted refactoring to reduce coupling, eliminate duplication, and simplify abstractions.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL Quality Issues (blocks maintainability)
|
||||
|
||||
### CQ-CRIT-1: `use-record-query` — God Hook Anti-Pattern (540 lines)
|
||||
**File:** `src/lib/hooks/use-record-query.ts`
|
||||
|
||||
This hook violates SRP catastrophically. It manages:
|
||||
- Pagination state (pageNum, pageSize)
|
||||
- Filter state (userFilter, quickSearchTerm, filterMode)
|
||||
- Column configuration (columnVisibility, columnOrder, columnWidths)
|
||||
- Row selection state
|
||||
- UI panel toggles (filterPanelOpen, columnConfigOpen)
|
||||
- localStorage persistence (5 separate useEffect blocks)
|
||||
- URL params synchronization (1 useEffect block)
|
||||
- TanStack Query integration (2 queries)
|
||||
- Saved views (saveView, loadView, deleteView callbacks)
|
||||
- 18+ action dispatchers
|
||||
|
||||
The hook returns 40+ properties. Components must destructure dozens of items they don't need. The reducer pattern for 24+ action types is overkill for this state complexity.
|
||||
|
||||
**Why it's Critical:**
|
||||
- Testing requires mocking localStorage, URL manipulation, TanStack Query, and multiple state branches simultaneously
|
||||
- Any change to one concern risks breaking another
|
||||
- Components using this hook are tightly coupled to its entire API shape
|
||||
- The localStorage sync fires 5 separate effects independently
|
||||
|
||||
**Recommended Fix:**
|
||||
Split into 3-4 focused hooks:
|
||||
- `useRecordPagination()` — pageNum, pageSize, totalPages, setPage, setPageSize
|
||||
- `useRecordFilter()` — userFilter, quickSearchTerm, setFilter, resetFilter
|
||||
- `useRecordSelection()` — rowSelection, setRowSelection, clearSelection
|
||||
- `useRecordUIState()` — columnConfigOpen, filterPanelOpen, toggles
|
||||
|
||||
Let RecordQuery coordinate these together.
|
||||
|
||||
---
|
||||
|
||||
### CQ-CRIT-2: FilterBuilder — Two Comboboxes That Are 95% Identical
|
||||
**File:** `src/components/query/FilterBuilder.tsx`, lines 508-927
|
||||
|
||||
`PossibleValueSingleSelect` (lines 508-698) and `PossibleValueMultiSelect` (lines 713-927) are nearly identical. Both:
|
||||
- Fetch options via `fetchTablePossibleValues()`
|
||||
- Debounce search input (300ms) — copied at lines 557-563 and 771-777
|
||||
- Manage AbortController for stale request cancellation — copied at lines 585-590 and 799-804
|
||||
- Implement click-outside-to-close
|
||||
- Render search input, loading state, option list
|
||||
|
||||
Differences: only selection model (single vs. multi) and tag display.
|
||||
|
||||
**Why it's Critical:**
|
||||
- Bug fix in one requires manual sync to the other
|
||||
- 400+ lines of duplicated code
|
||||
- Missing memoization: callbacks re-created on every render
|
||||
|
||||
**Recommended Fix:**
|
||||
Extract a `useAsyncCombobox` hook:
|
||||
```typescript
|
||||
function useAsyncCombobox(tableName: string, fieldName: string) {
|
||||
// Shared debounce, abort, cleanup logic
|
||||
return { options, isLoading, debouncedFetch, abortRef }
|
||||
}
|
||||
```
|
||||
Then SingleSelect and MultiSelect become thin wrappers managing only the selection model.
|
||||
|
||||
---
|
||||
|
||||
### CQ-CRIT-3: `zod-from-metadata` — Repeated Required/Optional Pattern x10
|
||||
**File:** `src/lib/utils/zod-from-metadata.ts`, lines 11-114
|
||||
|
||||
STRING, TEXT, HTML, and PASSWORD cases (lines 19-60, 99-110) all repeat:
|
||||
```typescript
|
||||
let schema = z.string()
|
||||
if (isRequired) schema = schema.min(1, `${label} is required`)
|
||||
if (maxLength) schema = schema.max(maxLength, ...)
|
||||
return isRequired ? schema : schema.optional()
|
||||
```
|
||||
|
||||
The required/optional wrapping pattern is written identically across all 10+ field types.
|
||||
|
||||
**Recommended Fix:**
|
||||
```typescript
|
||||
function buildStringSchema(isRequired: boolean, maxLength?: number, label?: string) {
|
||||
let schema = z.string()
|
||||
if (isRequired) schema = schema.min(1, `${label} is required`)
|
||||
if (maxLength) schema = schema.max(maxLength, `Must be ${maxLength} characters or fewer`)
|
||||
return isRequired ? schema : schema.optional()
|
||||
}
|
||||
|
||||
case 'STRING':
|
||||
case 'TEXT':
|
||||
case 'HTML':
|
||||
case 'PASSWORD':
|
||||
return buildStringSchema(isRequired, field.maxLength, field.label)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-CRIT-4: Inconsistent Error Status Detection Across Components
|
||||
**Files:**
|
||||
- `src/components/records/RecordView.tsx` lines 68-83 — custom `getErrorStatusCode()`, handles 403/404/500
|
||||
- `src/components/query/RecordQuery.tsx` lines 493-512 — generic "Failed to load records" for all errors
|
||||
|
||||
Error handling is implemented differently in the two main page components. If the API changes, fixes are needed in multiple places.
|
||||
|
||||
**Recommended Fix:**
|
||||
```typescript
|
||||
// src/lib/utils/error-utils.ts
|
||||
export function getErrorStatusCode(error: unknown): number | undefined { ... }
|
||||
export function getErrorMessage(error: unknown, context: string): string { ... }
|
||||
```
|
||||
Use this in both RecordView and RecordQuery.
|
||||
|
||||
---
|
||||
|
||||
## HIGH Quality Issues (significant tech debt)
|
||||
|
||||
### CQ-HIGH-1: RecordQuery — Too Many Responsibilities (586 lines)
|
||||
**File:** `src/components/query/RecordQuery.tsx`
|
||||
|
||||
This component manages: quick search debounce, view mode toggle, mobile filter sheet state, density selector, and renders: toolbar (150+ lines inline), filter panel (desktop + mobile variants), bulk action bar, error/empty states, DataGrid, RecordCardView, and process launcher coordination.
|
||||
|
||||
**Extractable subcomponents:**
|
||||
- `<RecordQueryToolbar />` — all toolbar state and rendering
|
||||
- `<DensitySelector />` — the dropdown (lines 291-339)
|
||||
- `<ViewModeToggle />` — button group (lines 258-288)
|
||||
- `<QuickSearchInput />` — search field with debounce
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-2: FilterBuilder — No Memoization on Recursive Components
|
||||
**File:** `src/components/query/FilterBuilder.tsx`
|
||||
|
||||
The recursive chain FilterBuilder → FilterGroup → CriteriaRow → FilterValueInput doesn't memoize callbacks or components. Every filter state change re-renders all sibling rows, and PossibleValueSingleSelect's useEffect (line 566) re-fires unnecessarily — potentially resetting combobox search input during user input.
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
export const CriteriaRow = React.memo(function CriteriaRow({ ... }) { ... })
|
||||
export const FilterValueInput = React.memo(function FilterValueInput({ ... }) { ... })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-3: Field Adornments Typed as `Record<string, unknown>`
|
||||
**File:** `src/components/records/FieldValue.tsx`, lines 65-66, 119-120
|
||||
|
||||
```typescript
|
||||
const href = (linkAdornment?.values?.['linkURL'] ?? String(value)) as string
|
||||
const colorMap = (chipAdornment?.values?.['colorMap'] ?? {}) as Record<string, string>
|
||||
```
|
||||
|
||||
These `as` casts are necessary because adornment `values` is `Record<string, unknown>`. The type information exists in the backend but isn't modeled.
|
||||
|
||||
**Recommended Fix:** Discriminated union types for adornments:
|
||||
```typescript
|
||||
type LinkAdornment = { type: 'LINK'; values: { linkURL: string } }
|
||||
type ChipAdornment = { type: 'CHIP'; values: { colorMap: Record<string, string> } }
|
||||
export type FieldAdornment = LinkAdornment | ChipAdornment | IconAdornment | ...
|
||||
```
|
||||
Eliminates all `as` casts in FieldValue.
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-4: Magic Numbers and Strings Scattered Throughout
|
||||
- `use-record-query.ts:246` — `[10, 25, 50, 100].includes(n)` — hardcoded page sizes
|
||||
- `RecordQuery.tsx:75` — `400` ms debounce
|
||||
- `FilterBuilder.tsx:560` — `300` ms debounce
|
||||
- `DataGrid.tsx:41-51` — density class maps inline
|
||||
- `FieldValue.tsx:1024` — bytes-per-KB hardcoded
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
// src/lib/constants.ts
|
||||
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const
|
||||
export const SEARCH_DEBOUNCE_MS = 400
|
||||
export const COMBOBOX_DEBOUNCE_MS = 300
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-5: `use-process` — Stale Closure via Manual `stateRef`
|
||||
**File:** `src/lib/hooks/use-process.ts`, lines 105-128
|
||||
|
||||
```typescript
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
// later: stateRef.current.stepValues
|
||||
```
|
||||
|
||||
Manually syncing a ref to shadow state is a sign that the polling subscription (lines 249-268) is too coupled to mutable state. This pattern is fragile and indicates the state management design needs simplification.
|
||||
|
||||
---
|
||||
|
||||
### CQ-HIGH-6: Dead Toolbar Button
|
||||
**File:** `src/components/query/RecordQuery.tsx`, lines 394-401
|
||||
|
||||
A "Settings / more" button has an aria-label but no onClick handler — it does nothing. Either wire it up or remove it.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM Quality Issues
|
||||
|
||||
### CQ-MED-1: `use-record-query` — `effectiveFilter` Depends on Unrelated State
|
||||
**File:** `src/lib/hooks/use-record-query.ts`, lines 317-332
|
||||
|
||||
`effectiveFilter` is recomputed when `state.columnVisibility` changes, but column visibility has nothing to do with filters. The dependency array is over-inclusive.
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-2: Boolean Operator Type is Raw String Cast
|
||||
**File:** `src/components/query/FilterBuilder.tsx`, lines 196-198
|
||||
|
||||
```typescript
|
||||
onChange({ ...filter, booleanOperator: e.target.value as 'AND' | 'OR' })
|
||||
```
|
||||
|
||||
Should use a const assertion or enum to avoid typo risk:
|
||||
```typescript
|
||||
const BOOLEAN_OPERATORS = ['AND', 'OR'] as const
|
||||
type BooleanOperator = typeof BOOLEAN_OPERATORS[number]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-3: FieldValue — Repeated URL/Email Pattern Matching
|
||||
**File:** `src/components/records/FieldValue.tsx`, lines 294, 339, 358
|
||||
|
||||
Three separate URL/email regex checks scattered across the file. Extract to:
|
||||
```typescript
|
||||
// src/lib/utils/string-utils.ts
|
||||
export const isUrl = (s: string) => /^https?:\/\//i.test(s) || s.startsWith('/')
|
||||
export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-4: RecordView — Redundant Section Fallback Logic
|
||||
**File:** `src/components/records/RecordView.tsx`, lines 234-289
|
||||
|
||||
```typescript
|
||||
const primarySections = visibleSections.filter(s => !s.tier || s.tier === 'T1' || s.tier === 'basic')
|
||||
// ...
|
||||
const t1Sections = primarySections.length > 0 ? primarySections : visibleSections
|
||||
```
|
||||
|
||||
The fallback `? primarySections : visibleSections` is redundant if all sections without explicit tiers are already included in `primarySections`. Simplify or add a comment explaining when this fallback fires.
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-5: Missing Error Boundaries Below Route Level
|
||||
Only `src/app/error.tsx` provides an error boundary. Forms and record views should have their own boundaries to prevent full-page crashes from single component errors.
|
||||
|
||||
---
|
||||
|
||||
### CQ-MED-6: `FilterBuilder` — `subs` Variable Name
|
||||
**File:** `src/components/query/FilterBuilder.tsx`, line 178
|
||||
|
||||
```typescript
|
||||
const subs = filter.subFilters ?? []
|
||||
```
|
||||
|
||||
Should be `subFilters` for clarity.
|
||||
|
||||
---
|
||||
|
||||
## LOW / Style Issues
|
||||
|
||||
- **`data-qqq-id` inconsistency** — Mix of `grid-select-row-${row.index}` (underscores), `filter-field-${depth}-${index}` (hyphens). Standardize to kebab-case throughout.
|
||||
- **Abbreviations:** `initVals` → `initialValues` (`use-record-query.ts:257`), `ej` → `exposedJoin` (`use-record-query.ts:348`), `pk` → `primaryKey` (DataGrid, RecordView), `subs` → `subFilters`
|
||||
- **Import ordering:** `FilterBuilder.tsx` imports lucide-react after custom hooks. Project convention is: React → External → Types → Lib → Components.
|
||||
- **Missing JSDoc:** `buildQuickFilterFromState()` (use-record-query.ts), `getErrorStatusCode()` (RecordView.tsx), `zodFieldFromMetadata()` (zod-from-metadata.ts) — all complex enough to warrant a one-liner doc comment.
|
||||
- **Unnecessary `useMemo`:** `joins` calculation in `use-record-query.ts:345-354` is a simple object map — too cheap to justify memoization overhead.
|
||||
- **Unused import:** `SelectField` in `DynamicFormField.tsx` (identified in prior review).
|
||||
- **`window.innerWidth` in callback** — `RecordQuery.tsx:127-134` — not reactive to resize events.
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Worth Preserving
|
||||
|
||||
1. **Metadata-Driven Rendering** — entire UI derived from backend metadata, zero hardcoded field/table names.
|
||||
2. **Query Key Factory Pattern** — `queryKeys.tableRecords(tableName)` prevents string-based cache bugs.
|
||||
3. **TanStack Query Usage** — proper staleTime, placeholderData, manual pagination, invalidation. No over-fetching.
|
||||
4. **Typed API Layer** — all `src/lib/api/` functions properly typed, good use of discriminated unions for responses.
|
||||
5. **Filter Serialization** — `serializeFilter()` / `deserializeFilter()` correctly handle shareable URLs.
|
||||
6. **Debouncing** — SearchInput and FilterBuilder correctly debounce and clean up.
|
||||
7. **Tailwind + CSS Custom Properties** — consistent theming without inline styles.
|
||||
8. **Accessibility Fundamentals** — ARIA labels, roles, keyboard handling are present throughout.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Fix Priority
|
||||
|
||||
| Priority | Item | Effort |
|
||||
|----------|------|--------|
|
||||
| 1 | CQ-CRIT-2: Extract `useAsyncCombobox` hook (FilterBuilder) | Medium |
|
||||
| 2 | CQ-CRIT-3: `buildStringSchema()` helper in zod-from-metadata | Small |
|
||||
| 3 | CQ-CRIT-4: Centralize `getErrorStatusCode()` utility | Small |
|
||||
| 4 | CQ-HIGH-3: Discriminated union types for adornments | Medium |
|
||||
| 5 | CQ-HIGH-4: Extract constants to `src/lib/constants.ts` | Small |
|
||||
| 6 | CQ-HIGH-6: Remove or wire dead toolbar button | Trivial |
|
||||
| 7 | CQ-HIGH-2: `React.memo` on FilterBuilder subcomponents | Small |
|
||||
| 8 | CQ-MED-3: Extract URL/email utils | Small |
|
||||
| 9 | CQ-CRIT-1: Split `use-record-query` into focused hooks | Large |
|
||||
| 10 | CQ-HIGH-1: Extract RecordQuery toolbar subcomponents | Medium |
|
||||
@@ -0,0 +1,390 @@
|
||||
# QQQ Frontend Next — Code Review
|
||||
**Date:** 2026-02-27
|
||||
**Reviewer:** Claude (automated)
|
||||
**Scope:** Full `src/` codebase audit
|
||||
**Commit:** df8e2e6
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL (7 findings)
|
||||
|
||||
---
|
||||
|
||||
### CRIT-1: XSS — unsanitized `dangerouslySetInnerHTML` in `FieldValue`
|
||||
|
||||
**File:** `src/components/records/FieldValue.tsx:136–143`
|
||||
|
||||
Raw backend HTML value injected into DOM with zero sanitization:
|
||||
```tsx
|
||||
dangerouslySetInnerHTML={{ __html: String(value) }}
|
||||
```
|
||||
|
||||
**Fix:** Use DOMPurify:
|
||||
```tsx
|
||||
import DOMPurify from 'dompurify'
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(value)) }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CRIT-2: XSS — unsanitized `dangerouslySetInnerHTML` in `BlockWidget` (two locations)
|
||||
|
||||
**File:** `src/components/widgets/BlockWidget.tsx:40–48, 278–285`
|
||||
|
||||
Both the legacy `data.html` path and the `case 'html'` block renderer pass backend HTML directly to the DOM.
|
||||
|
||||
**Fix:** Apply `DOMPurify.sanitize()` to both HTML rendering paths.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-3: XSS — regex script-stripping in `ProcessHtmlStep` is not real sanitization
|
||||
|
||||
**File:** `src/components/process/ProcessHtmlStep.tsx:14–17, 100`
|
||||
|
||||
```ts
|
||||
// TODO: Replace with DOMPurify for full sanitization
|
||||
function stripScripts(html: string): string {
|
||||
return html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
}
|
||||
```
|
||||
|
||||
Bypassed by: `<img onerror="...">`, `<svg onload="...">`, `<a href="javascript:...">`, and dozens of other vectors.
|
||||
|
||||
**Fix:** Replace `stripScripts` with `DOMPurify.sanitize()`.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-4: Auth bypass — `handleOAuthCallback` sets `isAuthenticated=true` without token exchange
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:164–176`
|
||||
|
||||
```tsx
|
||||
async function handleOAuthCallback(code: string, state: string) {
|
||||
// For now, just mark as authenticated
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
```
|
||||
|
||||
`code` and `state` params are accepted but unused. No CSRF state validation, no token exchange, no session creation.
|
||||
|
||||
**Fix:** Implement PKCE: validate `state` against `sessionStorage`, exchange `code`+`code_verifier` for tokens via backend `/manageSession`, then set `isAuthenticated` only on success.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-5: Auth bypass — Auth0/OAuth2 sessions authenticate without server validation
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:123–138`
|
||||
|
||||
Both `setupAuth0Session` and `setupOAuth2Session` call no protected endpoint. Any app configured with AUTH_0 or OAUTH2 shows as authenticated for any page load. Default user is hardcoded: `{ name: 'User', email: 'user@example.com' }`.
|
||||
|
||||
**Fix:** Call a protected endpoint (e.g., `GET /session`) to validate existing session cookie. Redirect to provider if 401.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-6: Auth state corruption — 401 interceptor doesn't clear auth metadata cache
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:65–70`
|
||||
|
||||
The 401 interceptor redirects to login but does NOT call `clearAuthMetadataCache()`, leaving stale auth metadata. On next page load the cached auth type governs initialization without a fresh server check.
|
||||
|
||||
**Fix:** Call `clearAuthMetadataCache()` inside the 401 callback before redirect.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-7: Silent export failure — `ExportButton` swallows errors with no user feedback
|
||||
|
||||
**File:** `src/components/query/ExportButton.tsx:88–89`
|
||||
|
||||
```tsx
|
||||
} catch (err) {
|
||||
console.error('[ExportButton] Export failed:', err)
|
||||
}
|
||||
```
|
||||
|
||||
Users believe export succeeded when it silently failed.
|
||||
|
||||
**Fix:** Add `toast.error('Export failed. Please try again.')` in the catch block.
|
||||
|
||||
---
|
||||
|
||||
## HIGH (8 findings)
|
||||
|
||||
---
|
||||
|
||||
### HIGH-1: Stale closures — missing deps in URL sync + column persistence effects
|
||||
|
||||
**File:** `src/lib/hooks/use-record-query.ts:280–311`
|
||||
|
||||
Four `useEffect` hooks suppress `react-hooks/exhaustive-deps`. The URL-sync effect is missing `router` and `pathname` — stale closures can fire URL updates on the wrong route in concurrent mode.
|
||||
|
||||
**Fix:** Add `router` and `pathname` to the URL-sync effect deps. Use `useRef` for stable callback references if needed.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-2: Performance — `JSON.stringify` key comparison on every query cache event in `useProcess`
|
||||
|
||||
**File:** `src/lib/hooks/use-process.ts:251–268`
|
||||
|
||||
```ts
|
||||
if (JSON.stringify(key) !== JSON.stringify(expectedKey)) return
|
||||
```
|
||||
|
||||
This serializes two arrays on every cache event during rapid polling intervals.
|
||||
|
||||
**Fix:** Use `queryClient.getQueryData(expectedKey)` or shallow key comparison utility.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-3: Logic bug — `processUUID` unsafe cast falls back to empty string
|
||||
|
||||
**File:** `src/lib/hooks/use-process.ts:274–278`
|
||||
|
||||
```ts
|
||||
const pUUID = (response as QJobResponse & { processUUID?: string }).processUUID ?? ''
|
||||
```
|
||||
|
||||
If `processUUID` is absent, all subsequent step calls use `''` as the UUID, producing 404 API calls.
|
||||
|
||||
**Fix:** Add `processUUID` to `QJobResponse` type. Validate it is non-empty before proceeding.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-4: Logic bug — type guard fallthrough on unexpected `QJobResponse` shapes
|
||||
|
||||
**File:** `src/lib/hooks/use-process.ts:76–82`
|
||||
|
||||
A response with both `error` and `values` fields, or with both `jobUUID` and `values`, falls through all type guards silently. Process state freezes indefinitely.
|
||||
|
||||
**Fix:** Use a discriminated union with an explicit `type` field. Add a default catch-all error case.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-5: Stale auth — metadata cached globally without API base URL scoping
|
||||
|
||||
**File:** `src/lib/api/auth.ts:29–37`
|
||||
|
||||
Auth metadata cached for 1 hour under a single key `qqqAuthMetadata` regardless of API base URL. Backend auth type changes are ignored for up to 1 hour. Multi-instance deployments cross-contaminate caches.
|
||||
|
||||
**Fix:** Include API base URL in cache key. Consider shorter TTL (5–10 min) or TanStack Query instead.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-6: Auth bypass — `globalSearch` silently swallows 401/403 errors
|
||||
|
||||
**File:** `src/lib/api/tables.ts:132–136`
|
||||
|
||||
```ts
|
||||
} catch {
|
||||
// If /search endpoint is not available (404), return empty results
|
||||
return []
|
||||
}
|
||||
```
|
||||
|
||||
Catches all errors including 401 (session expired). A search 401 bypasses the global logout interceptor.
|
||||
|
||||
**Fix:**
|
||||
```ts
|
||||
} catch (err) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) return []
|
||||
throw err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### HIGH-7: Wrong record selection — `selectedRecordIds` computed from array index, not stable PK
|
||||
|
||||
**File:** `src/lib/hooks/use-record-query.ts:411–421`
|
||||
|
||||
`rowSelection` is keyed by array index but records can reorder after sort/filter/pagination. Index 5 after a filter may point to a completely different record than before.
|
||||
|
||||
**Fix:** Key `rowSelection` by primary key string (same as `getRowId` in DataGrid). Update the `selectedRecordIds` computation to use PK-based lookup.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-8: Dead retry button — `setPage(rq.pageNum)` is a no-op
|
||||
|
||||
**File:** `src/components/query/RecordQuery.tsx:503–508`
|
||||
|
||||
The error-state retry button dispatches `SET_PAGE` with the current page number — TanStack Query sees the same query key and does not re-fetch.
|
||||
|
||||
**Fix:** Use `queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableName) })`.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM (14 findings)
|
||||
|
||||
---
|
||||
|
||||
### MED-1: `AuthProvider` effect suppresses `onAuthError` dep without documentation
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:121`
|
||||
|
||||
**Fix:** Document intent: `// intentional: runs once on mount — auth initialization is not repeatable`
|
||||
|
||||
---
|
||||
|
||||
### MED-2: CSS injection — raw `customCss` injected into `<style>` tag without validation
|
||||
|
||||
**File:** `src/app/(dashboard)/layout.tsx:108–119`
|
||||
|
||||
Arbitrary CSS from the backend can overlay phishing forms, exfiltrate keystrokes via CSS selectors, or perform UI redressing.
|
||||
|
||||
**Fix:** Use a CSS sanitizer or restrict to CSS custom property overrides only.
|
||||
|
||||
---
|
||||
|
||||
### MED-3: CSS injection — `accentColor` set on CSS custom properties without format validation
|
||||
|
||||
**File:** `src/app/(dashboard)/layout.tsx:71–89`
|
||||
|
||||
`accentColor: 'red; --qqq-primary: url(https://evil.com)'` would be applied as-is.
|
||||
|
||||
**Fix:** Validate `accentColor` matches `/^#[0-9a-fA-F]{3,8}$|^rgb|^hsl/` before applying.
|
||||
|
||||
---
|
||||
|
||||
### MED-4: Stale `initialValue` reference in `useLocalStorage.removeValue`
|
||||
|
||||
**File:** `src/lib/hooks/use-local-storage.ts:36–43`
|
||||
|
||||
`initialValue` is an object passed inline at call sites — new reference on every render. Causes `removeValue` re-creation and downstream `useEffect` churn.
|
||||
|
||||
**Fix:** Stabilize with `useRef`: `const initialValueRef = useRef(initialValue)`, use `initialValueRef.current` in `removeValue`, remove `initialValue` from deps.
|
||||
|
||||
---
|
||||
|
||||
### MED-5: `filterUtils` `btoa`/`atob` may produce overly long base64 for Unicode filter values
|
||||
|
||||
**File:** `src/lib/utils/filter-utils.ts:224–256`
|
||||
|
||||
Double-encoding (`encodeURIComponent` + `btoa`) produces unnecessarily long strings that may exceed URL limits.
|
||||
|
||||
**Fix:** Use `btoa(unescape(encodeURIComponent(json)))` for true Unicode-safe base64.
|
||||
|
||||
---
|
||||
|
||||
### MED-6: Open redirect — `fromPath` URL param used as `href` without origin validation
|
||||
|
||||
**File:** `src/components/records/RecordView.tsx:409–416`
|
||||
|
||||
`/app/person/1?from=javascript:alert(1)` creates a clickable XSS link.
|
||||
|
||||
**Fix:**
|
||||
```ts
|
||||
const safeFromPath = fromPath?.startsWith('/') ? fromPath : null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MED-7: Hydration mismatch — `window.innerWidth` in `useState` initializer
|
||||
|
||||
**File:** `src/components/query/RecordQuery.tsx:92–97`
|
||||
|
||||
Server renders one view mode, client may initialize another, causing React hydration warning.
|
||||
|
||||
**Fix:** Initialize from `preferences.tableDefaultViewMode`, apply mobile override in `useEffect`.
|
||||
|
||||
---
|
||||
|
||||
### MED-8: `goBack` in `useProcess` is client-only, not a server step submission
|
||||
|
||||
**File:** `src/lib/hooks/use-process.ts:341–355`
|
||||
|
||||
Server-side state is not rolled back. If the server expects a `_goBack` step, the process goes out of sync.
|
||||
|
||||
**Fix:** Submit `processStep(processName, processUUID, '_goBack', {})` and let server dictate `nextStep`.
|
||||
|
||||
---
|
||||
|
||||
### MED-9: Missing `useCallback` on `EntityForm.onSubmit`
|
||||
|
||||
**File:** `src/components/forms/EntityForm.tsx:195–197`
|
||||
|
||||
`onSubmit` re-created on every render, causing unnecessary re-renders of all form fields.
|
||||
|
||||
**Fix:** `const onSubmit = useCallback(async (values) => { await activeMutation.mutateAsync(values) }, [activeMutation])`
|
||||
|
||||
---
|
||||
|
||||
### MED-10: Unvalidated `JSON.parse` from localStorage in `getStoredUser`
|
||||
|
||||
**File:** `src/lib/auth/auth-provider.tsx:150–161`
|
||||
|
||||
**Fix:** Add type guard or schema validation.
|
||||
|
||||
---
|
||||
|
||||
### MED-11: `TOGGLE_COLUMN` initializes from `undefined` for never-toggled columns
|
||||
|
||||
**File:** `src/lib/hooks/use-record-query.ts:119–126`
|
||||
|
||||
`!undefined === true` means first toggle always shows column, losing the "default" state distinction.
|
||||
|
||||
**Fix:**
|
||||
```ts
|
||||
const currentVal = state.columnVisibility[action.fieldName] ?? true
|
||||
return { ...state, columnVisibility: { ...state.columnVisibility, [action.fieldName]: !currentVal } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MED-12: Memory leak — column resize `mousemove`/`mouseup` listeners not removed on unmount
|
||||
|
||||
**File:** `src/components/query/DataGrid.tsx:231–253`
|
||||
|
||||
If `DataGrid` unmounts during a resize drag, the document event listeners remain.
|
||||
|
||||
**Fix:** Track active listeners in a `useRef`; clean them up in a `useEffect` cleanup.
|
||||
|
||||
---
|
||||
|
||||
### MED-13: Premature error state when `record` is undefined but `isError` is false
|
||||
|
||||
**File:** `src/components/records/RecordView.tsx:120–121`
|
||||
|
||||
```ts
|
||||
if (isError || !record) { /* renders error UI */ }
|
||||
```
|
||||
|
||||
Shows error flash before loading state.
|
||||
|
||||
**Fix:** Split: `if (isError) { ... }` then `if (!record && !isLoading) { ... }`.
|
||||
|
||||
---
|
||||
|
||||
### MED-14: Stateful regex in `HighlightedText` — `gi` flag causes incorrect `test()` after `split()`
|
||||
|
||||
**File:** `src/components/feedback/SearchDialog.tsx:31–47`
|
||||
|
||||
`split(regex)` advances `lastIndex`, making subsequent `regex.test(part)` calls unreliable.
|
||||
|
||||
**Fix:** Create a new regex for `test()`, or use `part.toLowerCase() === query.toLowerCase()`.
|
||||
|
||||
---
|
||||
|
||||
## LOW (8 findings)
|
||||
|
||||
- **LOW-1** `auth.ts:64` — Duplicate `clearAuthMetadataCache()` call (harmless)
|
||||
- **LOW-2** `query-client.ts:55` — Widget cache key unstable with unordered `params` objects
|
||||
- **LOW-3** `Sidebar.tsx:128` — Missing comment explaining intentional dep omission
|
||||
- **LOW-4** `RecordView.tsx:37–44` — `getInitials('')` throws TypeError (empty label)
|
||||
- **LOW-5** `DynamicFormField.tsx` — Unused `SelectField` import (dead code)
|
||||
- **LOW-6** `RecordQuery.tsx:127–134` — `window.innerWidth` in callback not reactive to resize
|
||||
- **LOW-7** `ExportButton.tsx:81–86` — Legacy DOM insertion pattern for file download trigger
|
||||
- **LOW-8** `process-utils.ts`, `use-routes.ts` — Verify `hasPermission`/`readPermission` filtering for nav items
|
||||
|
||||
---
|
||||
|
||||
## Priority Order for Fixes
|
||||
|
||||
1. **CRIT-1, CRIT-2, CRIT-3** — Install DOMPurify, apply to all 4 HTML rendering sites
|
||||
2. **CRIT-4, CRIT-5** — Auth stub implementations need proper server validation
|
||||
3. **CRIT-6** — One-line fix: add `clearAuthMetadataCache()` to 401 callback
|
||||
4. **HIGH-6** — One-line fix: re-throw non-404 errors in `globalSearch`
|
||||
5. **HIGH-7** — Fix record selection to use stable PKs
|
||||
6. **HIGH-8** — Fix retry button to call `invalidateQueries`
|
||||
7. **CRIT-7** — Add error toast to `ExportButton`
|
||||
8. **MED-6** — Validate `fromPath` starts with `/`
|
||||
9. **HIGH-1** — Fix stale closures in `useRecordQuery` effects
|
||||
10. Remaining HIGH/MED items by team priority
|
||||
@@ -0,0 +1,398 @@
|
||||
# Component Patterns
|
||||
|
||||
This guide describes the recurring patterns used throughout the QQQ Frontend Next codebase. Understanding these patterns is essential before adding new components or modifying existing ones.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Metadata-Driven Rendering
|
||||
|
||||
QQQ is a metadata-driven framework. The backend declares all structure — table names, field lists, widget layouts, and navigation items — and the frontend renders from it dynamically. No table names, field names, or navigation entries are hardcoded anywhere in the component tree.
|
||||
|
||||
### How it works
|
||||
|
||||
**Server-side (or near-server) layers** fetch metadata and pass it down as props. **Client Components** never fetch metadata themselves — they receive it through their props and render from it.
|
||||
|
||||
The routing entry point is `src/app/(dashboard)/app/[slug]/page.tsx`. It is a Client Component that fetches the full QQQ instance metadata from the server, resolves the slug against the metadata, and dispatches to the appropriate page component:
|
||||
|
||||
```tsx
|
||||
// src/app/(dashboard)/app/[slug]/page.tsx (simplified)
|
||||
export default function SlugPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const slug = params.slug
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
})
|
||||
|
||||
// Resolve slug against apps, tables, processes, reports in priority order
|
||||
const isTable = Boolean(metaData?.tables?.[slug])
|
||||
const table = metaData?.tables?.[slug]
|
||||
|
||||
// Pass resolved metadata as props — the child component never fetches
|
||||
if (isTable && table) {
|
||||
const tableProcesses = getProcessesForTable(metaData, slug)
|
||||
return <RecordQuery tableName={slug} tableMetaData={table} processes={tableProcesses} />
|
||||
}
|
||||
// ... other cases
|
||||
}
|
||||
```
|
||||
|
||||
`RecordQuery` receives `tableMetaData` as a prop. It does not know or care how that data arrived — it renders from it:
|
||||
|
||||
```tsx
|
||||
// src/components/query/RecordQuery.tsx
|
||||
interface RecordQueryProps {
|
||||
tableName: string
|
||||
tableMetaData: QTableMetaData // always passed in, never fetched internally
|
||||
processes?: QProcessMetaData[]
|
||||
}
|
||||
|
||||
export function RecordQuery({ tableName, tableMetaData, processes }: RecordQueryProps) {
|
||||
// tableMetaData is already resolved — no fetching here
|
||||
const canCreate = tableMetaData.insertPermission
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
- `tableMetaData` (and any other metadata object) is `undefined` until TanStack Query resolves it. Every component must handle the loading state. The slug page renders a spinner until metadata is ready; it never passes `undefined` to its child components.
|
||||
- User-facing text always uses `label` from metadata, never `name`. For example, the page header is set to `table?.label ?? slug`, not the raw slug.
|
||||
- The `resolveSlugTarget` function in `SlugPage` is extracted as a pure function precisely so it can be tested in isolation without any rendering.
|
||||
|
||||
### Resolution priority
|
||||
|
||||
The slug page resolves in this order: **app** → **table** → **process** → **report**. The first match wins.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: data-qqq-id Attributes
|
||||
|
||||
Every significant or interactive element carries a `data-qqq-id` attribute. This attribute serves two purposes:
|
||||
|
||||
1. **CSS customization hook** — deployers can write `[data-qqq-id="button-save"] { ... }` in their own stylesheets to override the default styling without touching source code.
|
||||
2. **Test selector** — Playwright E2E tests and unit tests target elements by `data-qqq-id` rather than by class name (which is an implementation detail subject to change).
|
||||
|
||||
### Naming convention
|
||||
|
||||
The format is `{component-type}-{identifier}`:
|
||||
|
||||
| Pattern | Example |
|
||||
|---|---|
|
||||
| `record-query-{tableName}` | `record-query-orders` |
|
||||
| `widget-{name}` | `widget-ordersSummary` |
|
||||
| `widget-label-{name}` | `widget-label-ordersSummary` |
|
||||
| `widget-content-{name}` | `widget-content-ordersSummary` |
|
||||
| `button-widget-reload-{name}` | `button-widget-reload-ordersSummary` |
|
||||
| `button-widget-export-{name}` | `button-widget-export-ordersSummary` |
|
||||
| `button-widget-help-{name}` | `button-widget-help-ordersSummary` |
|
||||
| `button-save` | `button-save` |
|
||||
| `button-cancel` | `button-cancel` |
|
||||
| `field-value-{fieldName}` | `field-value-status` |
|
||||
| `filter-panel-close` | `filter-panel-close` |
|
||||
| `link-back-to-source` | `link-back-to-source` |
|
||||
| `unknown-slug-{slug}` | `unknown-slug-foo` |
|
||||
|
||||
### Examples from the actual code
|
||||
|
||||
`WidgetBlock.tsx` — the outer card, its label, and its icon buttons:
|
||||
```tsx
|
||||
<section
|
||||
data-qqq-id={`widget-${name}`}
|
||||
aria-label={label}
|
||||
>
|
||||
<h3 data-qqq-id={`widget-label-${name}`}>{label}</h3>
|
||||
<button data-qqq-id={`button-widget-help-${name}`}>...</button>
|
||||
<button data-qqq-id={`button-widget-export-${name}`}>...</button>
|
||||
<button data-qqq-id={`button-widget-reload-${name}`}>...</button>
|
||||
<div data-qqq-id={`widget-content-${name}`}>...</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
`RecordQuery.tsx` — the outer container, the filter close button, and the back link:
|
||||
```tsx
|
||||
<div data-qqq-id={`record-query-${tableName}`}>
|
||||
<Link data-qqq-id="link-back-to-source">...</Link>
|
||||
<button data-qqq-id="filter-panel-close">...</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
`EntityForm.tsx` — the form itself and its action buttons:
|
||||
```tsx
|
||||
<form data-qqq-id={`entity-form-${tableMetaData.name}`}>
|
||||
<button data-qqq-id="button-cancel">Cancel</button>
|
||||
<button data-qqq-id="button-save">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
`FieldValue.tsx` — every field value element is tagged with its field name:
|
||||
```tsx
|
||||
<span data-qqq-id={`field-value-${field.name}`}>...</span>
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Do not use Tailwind class names as test selectors. If a test needs to click a button, it should use `page.getByTestId` or `[data-qqq-id="..."]` selectors, not `.bg-primary` or `.rounded-md`.
|
||||
- Identifier segments use the backend `name` field (e.g., `orders`), not the `label` (e.g., `"Customer Orders"`). Labels can contain spaces and special characters; names are safe identifiers.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Async Combobox (`useAsyncCombobox`)
|
||||
|
||||
Any dropdown that loads its options from the API with user-typed search uses the `useAsyncCombobox` hook (`src/lib/hooks/use-async-combobox.ts`). The hook encapsulates fetch, debounce, AbortController cancellation, and click-outside-to-close logic that would otherwise be duplicated in every combobox component.
|
||||
|
||||
### When to use it
|
||||
|
||||
Use `useAsyncCombobox` when:
|
||||
- The options come from a QQQ possible-value source (table-backed lookup).
|
||||
- The user can type to search and the list is fetched on demand.
|
||||
- The option list is too large to load all at once (more than ~50 items).
|
||||
|
||||
Use a plain `<select>` or a static shadcn/ui `<Select>` when:
|
||||
- The options are a small static list (e.g., sort direction: "Ascending / Descending").
|
||||
- The options are already available in memory (e.g., column names from `tableMetaData.fields`).
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import { useAsyncCombobox } from '@/lib/hooks/use-async-combobox'
|
||||
|
||||
function MyPossibleValueInput({ tableName, fieldName, onChange }) {
|
||||
const {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
options, // QPossibleValue[] — the fetched options to display
|
||||
isLoading,
|
||||
containerRef, // attach to outermost wrapper for click-outside close
|
||||
inputRef, // attach to the <input> for focus management
|
||||
debouncedFetch, // call in onChange handler to trigger debounced fetch
|
||||
} = useAsyncCombobox({
|
||||
tableName,
|
||||
fieldName,
|
||||
onOptionsFetched: (results) => {
|
||||
// optional: update a label map or other derived state
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
debouncedFetch(e.target.value)
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
/>
|
||||
{isOpen && (
|
||||
<ul role="listbox">
|
||||
{isLoading && <li>Loading...</li>}
|
||||
{options.map((opt) => (
|
||||
<li
|
||||
key={opt.id}
|
||||
role="option"
|
||||
onClick={() => {
|
||||
onChange(opt.id)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### What the hook manages internally
|
||||
|
||||
| Concern | Mechanism |
|
||||
|---|---|
|
||||
| Debouncing | `setTimeout` cleared on each call; fires after `COMBOBOX_DEBOUNCE_MS` (200 ms) |
|
||||
| Request cancellation | `AbortController` — aborts the previous fetch before starting a new one |
|
||||
| Click-outside close | `document.addEventListener('mousedown', handler)` attached to `containerRef` |
|
||||
| Auto-fetch on open | `useEffect` on `isOpen` — fetches the full list when the dropdown opens |
|
||||
| Cleanup on unmount | Clears pending timer and aborts any in-flight request |
|
||||
|
||||
### Return values
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `isOpen` | `boolean` | Drive conditional rendering of the dropdown list |
|
||||
| `setIsOpen` | `Dispatch` | Programmatically open/close (e.g., on focus, on select) |
|
||||
| `searchTerm` | `string` | Controlled value for the text input |
|
||||
| `setSearchTerm` | `Dispatch` | Update the text input value |
|
||||
| `options` | `QPossibleValue[]` | The fetched options — map these to `<li>` elements |
|
||||
| `isLoading` | `boolean` | Show a spinner or loading state while the fetch is in-flight |
|
||||
| `containerRef` | `RefObject<HTMLDivElement>` | Attach to the outermost wrapper element |
|
||||
| `inputRef` | `RefObject<HTMLInputElement>` | Attach to the text `<input>` element |
|
||||
| `fetchOptions` | `(term: string) => Promise<void>` | Immediate fetch (bypasses debounce) |
|
||||
| `debouncedFetch` | `(term: string) => void` | Debounced fetch — use this in `onChange` |
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Widget Rendering Pipeline
|
||||
|
||||
All dashboard widgets flow through a three-layer pipeline:
|
||||
|
||||
```
|
||||
ConnectedWidget
|
||||
└─ WidgetBlock (card chrome, loading, error, dropdown selects)
|
||||
└─ WidgetRenderer (type dispatcher)
|
||||
└─ StatisticsWidget | BarChartWidget | BlockWidget | ...
|
||||
```
|
||||
|
||||
### Layer 1: ConnectedWidget
|
||||
|
||||
`src/components/widgets/ConnectedWidget.tsx` is the primary entrypoint. It:
|
||||
|
||||
1. Reads `widgetMetaData.dropdowns` and initializes dropdown state.
|
||||
2. Fetches dropdown option lists from the possible-value API.
|
||||
3. Calls `useWidget(widgetMetaData.name, mergedParams)` to fetch the widget's data payload.
|
||||
4. Returns `null` immediately when `widgetMetaData.hasPermission === false`.
|
||||
5. Renders `<WidgetBlock>` with loading/error props and passes the fetched `data` to `<WidgetRenderer>`.
|
||||
|
||||
```tsx
|
||||
// ConnectedWidget orchestrates everything
|
||||
export function ConnectedWidget({ widgetMetaData, params, className }) {
|
||||
const { data, isLoading, isError, error, refetch } = useWidget(widgetMetaData.name, mergedParams)
|
||||
|
||||
return (
|
||||
<WidgetBlock
|
||||
widgetMetaData={widgetMetaData}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onReload={refetch}
|
||||
// ... dropdown props
|
||||
>
|
||||
{data && <WidgetRenderer widgetMetaData={widgetMetaData} data={data} />}
|
||||
</WidgetBlock>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 2: WidgetBlock
|
||||
|
||||
`src/components/widgets/WidgetBlock.tsx` provides the standard card shell:
|
||||
- Header with `label`, optional help tooltip, optional export button, optional reload button, and optional dropdown `<select>` elements.
|
||||
- Body area that renders a skeleton, an error state, or its `children`.
|
||||
- A `bare` prop that strips all chrome for use inside composite widgets.
|
||||
|
||||
The `WidgetBlock` does not know what type of widget it wraps. It only knows about loading/error state.
|
||||
|
||||
### Layer 3: WidgetRenderer
|
||||
|
||||
`src/components/widgets/WidgetRenderer.tsx` is the type dispatcher. It receives `widgetMetaData` and the raw `data` payload, reads `widgetMetaData.type`, and renders the appropriate typed component via a `switch` statement.
|
||||
|
||||
Supported types:
|
||||
|
||||
| `widgetMetaData.type` | Rendered component |
|
||||
|---|---|
|
||||
| `'statistics'` | `StatisticsWidget` |
|
||||
| `'barChart'` | `BarChartWidget` (lazy) |
|
||||
| `'lineChart'` | `LineChartWidget` (lazy) |
|
||||
| `'pieChart'` | `PieChartWidget` (lazy) |
|
||||
| `'recordGrid'` | `RecordGridWidget` |
|
||||
| `'html'` / `'block'` | `BlockWidget` |
|
||||
| `'divider'` | `DividerWidget` |
|
||||
| `'quickLinks'` | `QuickLinksWidget` |
|
||||
| `'alert'` | `AlertWidget` |
|
||||
| `'processSummary'` | `ProcessSummaryWidget` |
|
||||
| `'composite'` / `'parent'` | `CompositeWidget` |
|
||||
| `'chart'` | `ChartTypeDispatcher` (secondary `chartType` dispatch) |
|
||||
|
||||
Chart widgets (`barChart`, `lineChart`, `pieChart`) are loaded with `React.lazy` + `Suspense` to split the Recharts library into a separate bundle chunk. This keeps the initial JS bundle small on pages that contain no charts.
|
||||
|
||||
### How to add a new widget type
|
||||
|
||||
1. Create a new component file, e.g., `src/components/widgets/HeatmapWidget.tsx`. Export a typed payload interface and the component:
|
||||
|
||||
```tsx
|
||||
// src/components/widgets/HeatmapWidget.tsx
|
||||
export interface HeatmapWidgetPayload {
|
||||
rows: Array<{ label: string; cells: number[] }>
|
||||
columnLabels: string[]
|
||||
}
|
||||
|
||||
export function HeatmapWidget({ data, widgetName }: { data: HeatmapWidgetPayload; widgetName: string }) {
|
||||
// render the heatmap
|
||||
}
|
||||
```
|
||||
|
||||
2. Add a `case` to the `switch` in `WidgetRenderer.tsx`:
|
||||
|
||||
```tsx
|
||||
import { HeatmapWidget } from './HeatmapWidget'
|
||||
import type { HeatmapWidgetPayload } from './HeatmapWidget'
|
||||
|
||||
// Inside the switch:
|
||||
case 'heatmap':
|
||||
return <HeatmapWidget data={data as HeatmapWidgetPayload} widgetName={name} />
|
||||
```
|
||||
|
||||
3. The backend returns `widgetMetaData.type = 'heatmap'` for this widget. No other changes are needed — `ConnectedWidget` picks it up automatically.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5: Field Adornment Priority
|
||||
|
||||
`FieldValue` (`src/components/records/FieldValue.tsx`) renders a single QQQ field value in read-only mode. When a field has adornments (decorations declared in `field.adornments[]`), `FieldValue` applies them in strict priority order. The first matching adornment wins; subsequent ones are skipped.
|
||||
|
||||
### Priority order
|
||||
|
||||
1. **LINK** — renders the value as an `<a>` tag with an external link icon.
|
||||
2. **FILE_DOWNLOAD** — renders a download anchor using the display value as the URL.
|
||||
3. **SIZE** — formats the raw number as a human-readable byte size (e.g., `1.5 MB`).
|
||||
4. **CHIP** — renders the value as a colored badge using a `colorMap` from adornment values.
|
||||
5. **RENDER_HTML** (or field type `HTML`) — renders sanitized HTML via `DOMPurify.sanitize`.
|
||||
6. **CODE_EDITOR** — renders the value in a monospace `<pre><code>` block.
|
||||
7. **TOOLTIP** — wraps the value in a Radix tooltip. Falls through to type-based rendering when no tooltip text is resolved.
|
||||
8. **ERROR** — renders the value with a destructive icon and styling.
|
||||
9. Record reference link — when `field.possibleValueSourceName` matches a known table, renders as a Next.js `<Link>` with a `RecordHoverCard` preview.
|
||||
10. Type-based fallbacks — `BOOLEAN`, `PASSWORD`, `BLOB`, `TEXT` each have dedicated renderers.
|
||||
11. Default — auto-links bare `http(s)://` URLs and `mailto:` email addresses.
|
||||
|
||||
### How adornment detection works
|
||||
|
||||
Each adornment is checked with `Array.some()` before its rendering block. The discriminated union type `FieldAdornment` is narrowed inside each block using a type predicate:
|
||||
|
||||
```tsx
|
||||
// Detection
|
||||
const hasLink = field.adornments?.some((a) => a.type === 'LINK')
|
||||
|
||||
// Narrowing within the block
|
||||
if (hasLink) {
|
||||
const linkAdornment = field.adornments?.find(
|
||||
(a): a is Extract<FieldAdornment, { type: 'LINK' }> => a.type === 'LINK'
|
||||
)
|
||||
const href = linkAdornment?.values?.linkURL ?? String(value)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The `Extract<FieldAdornment, { type: 'LINK' }>` predicate gives TypeScript full knowledge of which `values` keys are available for that specific adornment type without a cast.
|
||||
|
||||
### TOOLTIP fall-through
|
||||
|
||||
The TOOLTIP case has one deliberate exception to the "first match wins" rule: if the `TOOLTIP` adornment is present but none of the `values` keys (`tooltipText`, `text`, `tooltip`) resolve to a non-empty string, the block falls through to type-based rendering. The value is still displayed — only the tooltip wrapper is omitted because there is nothing to show. This is documented with a comment in the source.
|
||||
|
||||
### DOMPurify sanitization
|
||||
|
||||
HTML values are sanitized once via `useMemo` that only re-runs when `value` changes:
|
||||
|
||||
```tsx
|
||||
const sanitizedHtml = useMemo(
|
||||
() => DOMPurify.sanitize(String(value)),
|
||||
[value]
|
||||
)
|
||||
```
|
||||
|
||||
This prevents unnecessary sanitization work on every render while also preventing XSS from backend-provided HTML content.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Design & UX Review — QQQ Frontend Next
|
||||
**Date:** 2026-03-01
|
||||
**Reviewer:** Claude Code (automated static analysis)
|
||||
**Scope:** Full codebase UX/design audit — layout, pages, forms, process, widgets, feedback, theme, responsive, and a11y
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The new frontend has strong bones: metadata-driven rendering is correctly implemented, component composition is clean, TanStack Query manages server state well, and the base accessibility implementation (labels, aria-required, focus rings, skip nav on breadcrumbs) is solid. The primary gaps fall into three categories:
|
||||
|
||||
1. **Accessibility** — several WCAG 2.1 AA gaps in the data grid, animations, and motion preferences
|
||||
2. **Mobile/responsive polish** — search hidden on mobile, touch targets below 44px minimum in several places
|
||||
3. **Loading/error state coverage** — a handful of async operations show no spinner or fallback
|
||||
|
||||
No showstopper UX regressions from the Material UI frontend were found. The issues below are incremental improvements.
|
||||
|
||||
---
|
||||
|
||||
## Severity Legend
|
||||
|
||||
| Severity | Meaning |
|
||||
|----------|---------|
|
||||
| **CRIT** | WCAG 2.1 Level A violation or severe usability break |
|
||||
| **HIGH** | WCAG 2.1 AA violation or significant UX regression |
|
||||
| **MED** | Noticeable UX gap, inconsistency, or missing polish |
|
||||
| **LOW** | Minor polish, nice-to-have, or future consideration |
|
||||
|
||||
---
|
||||
|
||||
## 1. Layout Shell
|
||||
|
||||
**Files:** `src/components/layout/Sidebar.tsx`, `Header.tsx`, `Breadcrumbs.tsx`, `Banner.tsx`, `src/app/(dashboard)/layout.tsx`
|
||||
|
||||
### Strengths
|
||||
- Desktop sidebar + mobile drawer correctly split at `md` breakpoint
|
||||
- Breadcrumbs have `aria-label="Breadcrumb"` and `aria-current="page"`
|
||||
- Escape key closes mobile drawer; sidebar items have focus rings
|
||||
- Banner severity states (info/warning/error) correctly scoped with icons
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-L-1 | **HIGH** | **No skip-to-content link.** Keyboard users must tab through the entire sidebar before reaching page content. Add `<a href="#main-content" className="sr-only focus:not-sr-only">Skip to main content</a>` as the first child of `<body>`. |
|
||||
| D-L-2 | **HIGH** | **Animations ignore `prefers-reduced-motion`.** Sidebar slide-in, banner fade-in, and skeleton `animate-pulse` all run unconditionally. Add `@media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }` to `globals.css`. |
|
||||
| D-L-3 | **MED** | **Header search hidden on mobile** (`hidden md:block`). Mobile users lose global search entirely. Add a search icon button on mobile that opens `SearchDialog`. |
|
||||
| D-L-4 | **MED** | **Dismissed banners not persisted.** Banners dismissed via local state reappear on reload. Save dismissed banner keys to `localStorage` under `qqq:dismissed-banners`. |
|
||||
| D-L-5 | **MED** | **Mobile sidebar `aria-expanded` not set.** The mobile drawer open/closed state isn't announced to screen readers. Add `aria-expanded={mobileOpen}` to the nav landmark element. |
|
||||
| D-L-6 | **LOW** | **Breadcrumb overflow on very narrow viewports** (<320px). Long breadcrumb chains don't truncate; last segment can overflow. Add `truncate` + `max-w-[8rem]` on intermediate segments. |
|
||||
| D-L-7 | **LOW** | **Sidebar nav has no loading skeleton.** During the initial metadata fetch the sidebar is empty with no placeholder. Add skeleton nav items matching the expected structure. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Record Query (List View)
|
||||
|
||||
**Files:** `src/components/query/DataGrid.tsx`, `FilterBuilder.tsx`, `Pagination.tsx`, `RecordQueryToolbar.tsx`, `RecordCardView.tsx`, `ColumnConfig.tsx`
|
||||
|
||||
### Strengths
|
||||
- Skeleton rows during fetch match final layout (no layout shift)
|
||||
- Three row density variants (compact/standard/comfortable) with persistence
|
||||
- Empty state with icon, title, and "Clear filters" CTA
|
||||
- Quick-search debounced at 300ms
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-Q-1 | **CRIT** | **DataGrid missing `aria-sort` on column headers.** When a column is sorted, screen readers don't announce direction. Add `aria-sort="ascending" \| "descending" \| "none"` to each `<th>` based on current sort state. |
|
||||
| D-Q-2 | **HIGH** | **Row selection checkboxes have no label.** Each row checkbox announces "checkbox" with no context. Add `aria-label={\`Select ${recordLabel}\`}` to each. |
|
||||
| D-Q-3 | **HIGH** | **Column resize handles not keyboard accessible.** Resize is mouse-drag only. Keyboard users cannot adjust column widths. Add `onKeyDown` handler supporting `ArrowLeft`/`ArrowRight` to the resize handle element. |
|
||||
| D-Q-4 | **MED** | **FilterBuilder async combobox has no loading indicator.** While possible-value options are fetching, the dropdown appears empty with no spinner. Add a `Loader2` icon while `isLoading` is true. |
|
||||
| D-Q-5 | **MED** | **"Go to page" input silently ignores out-of-range values.** Typing a page number beyond the last page does nothing with no feedback. Show an inline validation message like "Page must be between 1 and N". |
|
||||
| D-Q-6 | **MED** | **No `aria-live` region for fetch-in-progress.** Background re-fetches (sort/filter changes) are visually indicated by the progress bar but not announced. Add `aria-live="polite"` region announcing "Loading results". |
|
||||
| D-Q-7 | **MED** | **Column visibility toggle state not announced.** Toggling a column in ColumnConfig doesn't notify screen readers. Add a `role="status"` region that announces "Column X hidden" / "Column X visible". |
|
||||
| D-Q-8 | **LOW** | **No frozen/pinned columns.** The primary key / record label column scrolls out of view on wide tables. Consider pinning the first column on horizontal scroll. |
|
||||
| D-Q-9 | **LOW** | **RecordCardView touch targets may be too small.** Cards are clickable but no explicit 44px minimum hit area. Ensure card tap targets meet the 44px minimum on mobile. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Record View (Detail Page)
|
||||
|
||||
**Files:** `src/components/records/RecordView.tsx`, `RecordViewHeader.tsx`, `FieldValue.tsx`, `RecordViewAssociated.tsx`
|
||||
|
||||
### Strengths
|
||||
- DOMPurify sanitization applied before rendering HTML field values
|
||||
- 403/404/500 errors shown with contextual messages
|
||||
- Tab layout for T2/T3 sections with proper semantic structure
|
||||
- Field adornment priority correctly handled (LINK → FILE_DOWNLOAD → SIZE → CHIP)
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-V-1 | **HIGH** | **Edit/Delete buttons shown regardless of user permissions.** If table metadata indicates the user lacks UPDATE/DELETE rights, buttons should be hidden (or disabled with tooltip). Currently they render and fail with a 403 on action. |
|
||||
| D-V-2 | **MED** | **Associated records list not paginated.** Many-to-many joins can return thousands of records, all rendered to DOM. Add pagination (or a "Show more" pattern) capped at 25 by default. |
|
||||
| D-V-3 | **MED** | **Avatar initials fail for non-Latin scripts.** `getInitials()` splits on whitespace which produces empty strings for CJK/Arabic names. Add a fallback to a generic user icon when initials can't be derived. |
|
||||
| D-V-4 | **MED** | **No delete confirmation dialog.** The delete action currently triggers immediately. Add a confirm dialog ("Delete this record? This cannot be undone.") before the API call. |
|
||||
| D-V-5 | **LOW** | **No "Copy record ID" quick action.** Admin users frequently need the primary key for support/debugging. Add a small copy-to-clipboard icon next to the record ID. |
|
||||
| D-V-6 | **LOW** | **No audit trail footer.** No "Last modified by / at" metadata shown. If the backend provides this, surface it at the bottom of the record view. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Create / Edit Forms
|
||||
|
||||
**Files:** `src/components/forms/DynamicForm.tsx`, `EntityForm.tsx`, `DynamicFormField.tsx`, `src/components/forms/field-types/`
|
||||
|
||||
### Strengths
|
||||
- All inputs have `aria-required`, `aria-invalid`, `aria-describedby` wired correctly
|
||||
- Unsaved-changes guard (`beforeunload` + modal) prevents accidental data loss
|
||||
- Help tooltips use Radix Tooltip with keyboard support
|
||||
- Required asterisk is `aria-hidden="true"` to avoid screen-reader repetition
|
||||
- Disabled state styling consistent across field types
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-F-1 | **HIGH** | **Submit button stays enabled during mutation.** User can click Save multiple times before the first request completes, creating duplicate records. Add `disabled={isSubmitting}` and a loading spinner to the submit button. |
|
||||
| D-F-2 | **MED** | **Number fields don't display min/max constraints.** If field metadata defines `minValue`/`maxValue`, the user doesn't see them until a validation error fires. Show the range as hint text below the field (`0 – 100`). |
|
||||
| D-F-3 | **MED** | **DateTime fields don't indicate time zone.** The input accepts a time but doesn't show which time zone is assumed. Show the user's local TZ abbreviation next to the field. |
|
||||
| D-F-4 | **MED** | **Help tooltips not touch-friendly on mobile.** The `?` icon is hover-only and < 44px. On mobile, show the help text inline beneath the label instead. |
|
||||
| D-F-5 | **MED** | **File upload field has no drag-and-drop visual feedback.** The drop zone changes border on `dragover` but has no text like "Drop file here". Add clear drop-zone copy and a highlight color change. |
|
||||
| D-F-6 | **LOW** | **No "which fields changed" diff in edit mode.** Users editing a long form can't tell what they've modified at a glance. Consider highlighting changed fields with a left border accent. |
|
||||
| D-F-7 | **LOW** | **Large forms show all fields at once.** Sections with 20+ fields have no collapse/expand. Consider optional `<details>` sections for secondary field groups. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Process Execution
|
||||
|
||||
**Files:** `src/components/process/ProcessRun.tsx`, `StepWizard.tsx`, `ProcessFormStep.tsx`, `ValidationReviewStep.tsx`, `ProcessUploadFormStep.tsx`, `BulkLoadStep.tsx`
|
||||
|
||||
### Strengths
|
||||
- Step wizard gives clear numbered/checkmark visual progress
|
||||
- Cancellation confirm dialog prevents accidental process abort
|
||||
- Back navigation available when not on the first step
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-P-1 | **HIGH** | **Current step not auto-focused after "Next".** When advancing to a new step, focus remains on the Next button (now gone), leaving screen reader users disoriented. Move focus to the step heading or first field on step change. |
|
||||
| D-P-2 | **MED** | **Step wizard steps not keyboard-navigable (back).** Completed steps have no click handler to jump back. Add click-to-navigate on completed step indicators. |
|
||||
| D-P-3 | **MED** | **No process timeout message.** If the backend hangs, the polling spinner runs indefinitely. After ~60s show "This is taking longer than expected" with an option to wait or cancel. |
|
||||
| D-P-4 | **MED** | **Validation review results not filterable.** With 1000+ errors, the flat list is unmanageable. Add severity filter buttons (All / Errors / Warnings) and a field-name search. |
|
||||
| D-P-5 | **MED** | **File upload step has no progress bar.** Large uploads show no byte-level progress. Add a `<progress>` element or Radix Progress during multipart upload. |
|
||||
| D-P-6 | **MED** | **Step label truncation has no mobile tooltip.** Long step names are truncated at `max-w-[6rem]` with only a `title` attribute, which doesn't appear on touch. Add a Radix Tooltip wrapper. |
|
||||
| D-P-7 | **LOW** | **No "retry" on process step failure.** Users must restart the entire process from step 1 if one step fails. Add a "Retry this step" button where the backend supports re-running a step. |
|
||||
| D-P-8 | **LOW** | **Bulk load column mappings not saveable.** Users re-map the same CSV columns every run. Add a "Save this mapping" option persisted to localStorage. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Widgets & Dashboard
|
||||
|
||||
**Files:** `src/components/widgets/WidgetRenderer.tsx`, `StatisticsWidget.tsx`, `BlockWidget.tsx`, `BarChartWidget.tsx`, `LineChartWidget.tsx`, `PieChartWidget.tsx`, `RecordGridWidget.tsx`
|
||||
|
||||
### Strengths
|
||||
- Charts lazy-loaded via dynamic import (non-chart pages don't load Recharts)
|
||||
- Suspense skeleton placeholder shown while chart chunk loads
|
||||
- Exhaustive `switch` on widget type with unknown fallback
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-W-1 | **HIGH** | **Statistics trend arrows not accessible.** `TrendingUp`/`TrendingDown`/`Minus` icons are `aria-hidden` with no text alternative. Screen readers don't announce whether a metric is up or down. Add `<span className="sr-only">trending up</span>` next to each icon. |
|
||||
| D-W-2 | **MED** | **Widget data fetch errors show blank space.** If a widget's API call fails there is no error boundary or fallback UI. Add a per-widget error state (icon + "Could not load widget" message + retry). |
|
||||
| D-W-3 | **MED** | **Chart legends overflow horizontally on mobile.** Recharts legends don't reflow on narrow screens. Use `layout="vertical"` or hide the legend on mobile with a `<ResponsiveContainer>` width check. |
|
||||
| D-W-4 | **MED** | **RecordGrid widget not paginated.** Renders the full result set to DOM. Cap at 25 rows and add a "View all" link to the full record query page. |
|
||||
| D-W-5 | **MED** | **No manual widget refresh.** Stale widget data can't be refreshed without a full page reload. Add a small refresh icon button on each widget card header. |
|
||||
| D-W-6 | **LOW** | **Only 5 chart color tokens defined.** Dashboards with 6+ data series reuse colors, making series indistinguishable. Extend `--color-chart-*` to at least 10 distinct values. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Feedback Components
|
||||
|
||||
**Files:** `src/components/feedback/GlobalSearch.tsx`, `SearchDialog.tsx`, `CommandMenu.tsx`, `EmptyState.tsx`, `ErrorBoundary.tsx`, `KeyboardShortcutsDialog.tsx`
|
||||
|
||||
### Strengths
|
||||
- `EmptyState` uses `role="status"` + `aria-live="polite"` correctly
|
||||
- Skeleton components have `role="status"` + `aria-busy="true"`
|
||||
- SearchDialog has full ArrowUp/ArrowDown/Enter/Escape keyboard nav
|
||||
- `HighlightedText` now correctly uses `i % 2 !== 0` (recently fixed)
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-FB-1 | **MED** | **Keyboard Shortcuts dialog not discoverable.** `?` opens it but there is no visible hint in the UI. Add a `?` icon button in the header, or a footer note "Press ? for keyboard shortcuts". |
|
||||
| D-FB-2 | **MED** | **Toasts have no visible close/dismiss button.** Users must wait for auto-dismiss. Add an × button to each toast (sonner supports `closeButton` prop). |
|
||||
| D-FB-3 | **MED** | **CommandMenu icon buttons below 44px touch target.** Small icon-only buttons in the command menu are hard to tap on mobile. Ensure `min-h-[44px] min-w-[44px]` on interactive icons. |
|
||||
| D-FB-4 | **LOW** | **Search results missing table/app context in header.** Results are grouped by table in logic but group labels may not be visually prominent enough. Ensure group headers have sufficient weight/spacing. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Theme & Global Styles
|
||||
|
||||
**Files:** `src/styles/globals.css`, `src/styles/qqq-theme.css`, `src/lib/theme/theme-provider.tsx`, `src/lib/theme/tokens.ts`
|
||||
|
||||
### Strengths
|
||||
- 60+ CSS custom property tokens for colors, spacing, shadows
|
||||
- Complete dark mode override section
|
||||
- `:focus-visible` with proper outline and offset
|
||||
- Muted text color darkened to meet WCAG AA contrast
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-T-1 | **HIGH** | **No `prefers-reduced-motion` override.** All animations (skeleton pulse, slide-in transitions, toast fades) run unconditionally. See D-L-2 above — one global CSS rule resolves this. |
|
||||
| D-T-2 | **HIGH** | **No `prefers-contrast: more` support.** Users with OS high-contrast mode enabled get no enhanced styling. Add a `@media (prefers-contrast: more)` block that increases border weights and ensures 7:1 contrast ratios. |
|
||||
| D-T-3 | **MED** | **Dual theming systems create token confusion.** `--qqq-primary-color` and `--color-primary` both define the primary color. Components reference both inconsistently. Consolidate to one system (Tailwind v4 `--color-*` preferred) and alias the `--qqq-*` tokens to the canonical values for backwards compatibility. |
|
||||
| D-T-4 | **MED** | **Z-index tokens defined but not used consistently.** `--qqq-z-sidebar: 100`, `--qqq-z-modal: 1000` exist in the token file but some components use hardcoded values. Use the tokens everywhere. |
|
||||
| D-T-5 | **LOW** | **Focus ring may be invisible in dark mode.** The `color-mix`-based focus outline color may not contrast sufficiently on dark backgrounds. Test with browser dark mode enabled. |
|
||||
| D-T-6 | **LOW** | **No disabled-state semantic token.** All disabled states use `opacity-50`. A dedicated `--color-disabled` token would allow more nuanced disabled styling (e.g., different for text vs background). |
|
||||
|
||||
---
|
||||
|
||||
## 9. Responsive / Mobile
|
||||
|
||||
### Strengths
|
||||
- Sidebar correctly switches between desktop rail and mobile drawer at `md`
|
||||
- RecordQuery shows bottom-sheet filter panel on mobile
|
||||
- RecordCardView provides card layout alternative to data grid on mobile
|
||||
- Pagination uses `flex-wrap` to reflow on narrow screens
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding |
|
||||
|----|----------|---------|
|
||||
| D-R-1 | **HIGH** | **Mobile `<input>` elements trigger browser zoom.** Inputs with `font-size < 16px` cause iOS Safari to auto-zoom on focus, breaking the layout. Ensure all form inputs have `text-base` (16px) as minimum font size. |
|
||||
| D-R-2 | **MED** | **Touch targets below 44px in multiple places.** Pagination size selector, density buttons, column config toggles. Audit all interactive elements and ensure `min-h-[44px]` or adequate padding. |
|
||||
| D-R-3 | **MED** | **No 320px (iPhone SE) layout testing.** Several components assume ≥375px. Run a responsive check at 320px and fix any overflows. |
|
||||
| D-R-4 | **LOW** | **Landscape tablet layout untested.** At ~768px landscape some components may show desktop layout with insufficient room. Test iPad Mini landscape. |
|
||||
|
||||
---
|
||||
|
||||
## Priority Matrix
|
||||
|
||||
### Must Fix (WCAG 2.1 AA / CRIT / HIGH)
|
||||
|
||||
| ID | Area | Fix |
|
||||
|----|------|-----|
|
||||
| D-L-1 | Layout | Skip-to-content link |
|
||||
| D-L-2 / D-T-1 | Layout / Theme | `prefers-reduced-motion` CSS rule |
|
||||
| D-T-2 | Theme | `prefers-contrast: more` support |
|
||||
| D-Q-1 | Record Query | `aria-sort` on DataGrid column headers |
|
||||
| D-Q-2 | Record Query | Row checkbox `aria-label` |
|
||||
| D-Q-3 | Record Query | Keyboard-accessible column resize |
|
||||
| D-V-1 | Record View | Permission-gated Edit/Delete buttons |
|
||||
| D-F-1 | Forms | Disable submit button while submitting |
|
||||
| D-P-1 | Process | Auto-focus step heading on step change |
|
||||
| D-W-1 | Widgets | Screen-reader text for trend arrows |
|
||||
| D-R-1 | Responsive | 16px minimum font size on inputs |
|
||||
|
||||
### Should Fix (MED — noticeable UX gaps)
|
||||
|
||||
D-L-3 (mobile search), D-L-4 (banner persistence), D-L-5 (sidebar aria-expanded), D-Q-4 (FilterBuilder spinner), D-Q-5 (pagination range feedback), D-Q-6 (aria-live for fetch), D-V-2 (associated records pagination), D-V-4 (delete confirmation), D-F-2 (number field min/max hint), D-F-3 (DateTime TZ indicator), D-F-4 (mobile tooltip → inline help), D-P-2 (step back navigation), D-P-3 (process timeout message), D-P-4 (validation review filter), D-P-5 (upload progress bar), D-W-2 (widget error boundary), D-W-3 (chart legend mobile), D-W-5 (widget refresh button), D-FB-1 (keyboard shortcuts discoverability), D-FB-2 (toast close button), D-T-3 (dual theming consolidation), D-T-4 (z-index token usage), D-R-2 (touch targets), D-R-3 (320px layout)
|
||||
|
||||
### Nice to Have (LOW — polish)
|
||||
|
||||
D-L-6 (breadcrumb truncation), D-L-7 (sidebar skeleton), D-Q-8 (frozen columns), D-V-5 (copy record ID), D-V-6 (audit trail), D-F-6 (edit diff), D-F-7 (collapsible form sections), D-P-7 (retry step), D-P-8 (save bulk load mapping), D-W-6 (chart color tokens), D-FB-4 (search result context), D-T-5 (dark mode focus ring), D-T-6 (disabled token), D-R-4 (landscape tablet)
|
||||
|
||||
---
|
||||
|
||||
## What's Working Well
|
||||
|
||||
The following areas are solid and don't require significant rework:
|
||||
|
||||
- **Metadata-driven rendering** — no hardcoded table/field names anywhere
|
||||
- **API layer** — all calls through typed `src/lib/api/` functions, consistent error handling
|
||||
- **Form accessibility baseline** — `aria-required`, `aria-invalid`, `aria-describedby` correctly wired
|
||||
- **Unsaved changes guard** — `beforeunload` + confirm modal works correctly
|
||||
- **TanStack Query** — caching, deduplication, and revalidation correctly configured
|
||||
- **Dark mode** — complete token coverage, no visual regressions found
|
||||
- **Skeleton loading** — components have `role="status"` and `aria-busy="true"`
|
||||
- **DOMPurify** — applied consistently on all user-controlled HTML output
|
||||
- **URL state** — filters, pagination, and sort are shareable via query params
|
||||
@@ -0,0 +1,194 @@
|
||||
<!--
|
||||
Copyright 2026 QRun.IO, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file 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.
|
||||
-->
|
||||
|
||||
# QQQ Frontend Next — Documentation Review
|
||||
**Date:** 2026-03-01
|
||||
**Scope:** Full `src/` + `docs/` + project root
|
||||
**Commit:** 12edc4c
|
||||
|
||||
---
|
||||
|
||||
## Overall Score: 7.5 / 10
|
||||
|
||||
**Strengths:** JSDoc coverage is high across the API client, utilities, and most hooks. Type definitions are well-documented. `CLAUDE.md` and the implementation plans give a strong architectural overview.
|
||||
|
||||
**Key gaps:** `README.md` is essentially empty, `useRecordQuery` has no return-type documentation, and there are no architecture guides for state management or component composition patterns — the two things a new contributor needs most.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL — Must Address
|
||||
|
||||
### DOC-CRIT-1: README.md is empty
|
||||
|
||||
**File:** `README.md`
|
||||
**Issue:** The file contains only a title. No setup instructions, command list, project overview, or links to docs.
|
||||
**Impact:** A new developer cannot start contributing without reading `CLAUDE.md` and inferring the rest.
|
||||
|
||||
**Fix:** Add the following sections at minimum:
|
||||
- What this project is (2–3 sentences)
|
||||
- Prerequisites (Node.js version, pnpm)
|
||||
- Quick start (`git clone` → `pnpm install` → `pnpm dev`)
|
||||
- Available commands (dev, build, test, test:e2e, lint, tsc)
|
||||
- Pointer to `CLAUDE.md` for architecture and conventions
|
||||
- Pointer to `docs/` for implementation plans
|
||||
|
||||
---
|
||||
|
||||
### DOC-CRIT-2: `useRecordQuery` return type is undocumented
|
||||
|
||||
**File:** `src/lib/hooks/use-record-query.ts` (~line 60, main hook export)
|
||||
**Issue:** The hook returns a large composite object (pagination, filter, selection, sort, query state, plus delegated `columns` and `views` namespaces from sub-hooks). None of the return properties are documented. Only two components consume this hook today, but that will grow.
|
||||
**Impact:** Callers must trace through 685 lines of source to discover which properties exist.
|
||||
|
||||
**Fix:** Add a `@returns` block enumerating the top-level namespaces and key properties, or define an explicit `UseRecordQueryResult` interface with JSDoc on each field.
|
||||
|
||||
---
|
||||
|
||||
### DOC-CRIT-3: No state-management decision guide
|
||||
|
||||
**Issue:** The project uses five different state containers (React Context, TanStack Query, React Hook Form, URL search params, localStorage), each for different purposes. There is a brief table in `CLAUDE.md` but no guidance on the decision criteria.
|
||||
**Impact:** New contributors pick the wrong state container; inconsistent patterns accumulate over time.
|
||||
|
||||
**Fix:** Create `docs/STATE-MANAGEMENT.md` covering:
|
||||
- TanStack Query: all server state (records, metadata, widgets)
|
||||
- React Hook Form: form field state only
|
||||
- URL search params: filters, pagination, sort (shareable links)
|
||||
- localStorage: user preferences, column config, saved views
|
||||
- QContext: application-level UI state (modals, toast queue)
|
||||
- When NOT to use useState for server-derived state
|
||||
|
||||
---
|
||||
|
||||
## SIGNIFICANT — Should Address
|
||||
|
||||
### DOC-SIG-1: No component composition patterns guide
|
||||
|
||||
**Issue:** The codebase uses several non-obvious patterns not documented anywhere outside of scattered code comments:
|
||||
- Metadata-driven rendering (metadata always flows down; children never fetch)
|
||||
- `data-qqq-id` naming convention (`{component-type}-{identifier}`)
|
||||
- Async combobox pattern (`useAsyncCombobox` wrapping)
|
||||
- Field adornment priority chain (documented in `FieldValue.tsx` but not shared)
|
||||
|
||||
**Fix:** Create `docs/COMPONENT-PATTERNS.md` with each pattern described, the rationale, and a code example.
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-2: No type glossary
|
||||
|
||||
**Issue:** The `src/types/` directory exports ~40 interfaces and type aliases. There is no index or glossary. Finding the right type requires `grep`-ing.
|
||||
**Impact:** Types are duplicated or approximated when developers can't find the canonical definition.
|
||||
|
||||
**Fix:** Create `docs/TYPE-GLOSSARY.md` with a table: Type | File | Purpose | Example usage. Focus on the ~15 most-used types (`QInstance`, `QTableMetaData`, `QFieldMetaData`, `SavedView`, `QProcessMetaData`, `StatTile`, `FilterModel`, etc.).
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-3: API error handling patterns undocumented
|
||||
|
||||
**File:** `src/lib/api/client.ts` (referenced)
|
||||
**Issue:** The 401 interceptor is documented, but there is no guide to how callers should handle 400 (validation), 403 (permission), 404 (not found), and 500 (server error) responses. Components handle these inconsistently today.
|
||||
|
||||
**Fix:** Add a section to the API client file header or create `docs/API-ERROR-HANDLING.md` with the standard response patterns per HTTP status code.
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-4: Zod schema generation lacks examples
|
||||
|
||||
**File:** `src/lib/utils/zod-from-metadata.ts`
|
||||
**Issue:** The function and helpers are well-documented, but there is no `@example` showing how a field metadata object maps to a generated Zod schema. This is the most opaque part of the form validation pipeline.
|
||||
|
||||
**Fix:** Add a 6-line `@example` to `zodFieldFromMetadata` showing an input `QFieldMetaData` object and the corresponding schema.
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-5: Form validation strategy not centralized
|
||||
|
||||
**Issue:** Zod schema generation is in `zod-from-metadata.ts`, field-level validation rules are in individual field components, and server-side validation errors are mapped in `EntityForm.tsx`. No single document explains the end-to-end validation strategy.
|
||||
|
||||
**Fix:** Create `docs/FORM-VALIDATION.md` covering schema generation, custom refinements, and mapping server errors to form fields.
|
||||
|
||||
---
|
||||
|
||||
### DOC-SIG-6: TanStack Query key factory undocumented
|
||||
|
||||
**File:** `src/lib/query-client.ts`
|
||||
**Issue:** The query key factory pattern is used throughout the codebase but the naming convention and invalidation patterns are not documented. Developers risk creating overlapping keys.
|
||||
|
||||
**Fix:** Document the key structure and add `@example` tags showing how to invalidate a table's record list after a mutation.
|
||||
|
||||
---
|
||||
|
||||
## MINOR — Nice to Have
|
||||
|
||||
### DOC-MIN-1: Test file headers are sparse
|
||||
|
||||
Many test files (`*.test.tsx`) have no JSDoc file header explaining what behavior is under test and what is explicitly out of scope. This makes it harder to assess test coverage gaps at a glance.
|
||||
|
||||
**Files:** Most files in `src/components/**/*.test.tsx`, `src/lib/hooks/*.test.ts`
|
||||
|
||||
---
|
||||
|
||||
### DOC-MIN-2: Performance decisions lack inline rationale
|
||||
|
||||
Files like `use-record-query.ts` have several `useMemo` / `useCallback` / `useRef` optimizations with comments referencing ticket numbers but no explanation of the underlying performance problem being solved.
|
||||
|
||||
---
|
||||
|
||||
### DOC-MIN-3: Accessibility decisions not consolidated
|
||||
|
||||
WCAG compliance work is scattered across code comments and the design review document. A single `docs/ACCESSIBILITY.md` checklist would make it easy to see what has been done and what remains.
|
||||
|
||||
---
|
||||
|
||||
### DOC-MIN-4: Widget creation guide missing
|
||||
|
||||
The widget rendering pipeline (`WidgetBlock` → `WidgetRenderer` → specific widget component) is non-obvious. A short guide in `docs/` or a code comment in `WidgetRenderer` explaining how to add a new widget type would help.
|
||||
|
||||
---
|
||||
|
||||
## Already Excellent
|
||||
|
||||
| Area | Quality | Notes |
|
||||
|------|---------|-------|
|
||||
| API client (`src/lib/api/`) | 9/10 | 100% JSDoc; error conditions noted; param types clear |
|
||||
| Utilities (`src/lib/utils/`) | 8.5/10 | `filter-utils`, `process-utils`, `zod-from-metadata` all thorough |
|
||||
| Sub-hooks (`use-column-config`, `use-saved-views`, `use-async-combobox`) | 9/10 | Return shapes documented; edge cases noted |
|
||||
| Type definitions (`src/types/metadata.ts`) | 8.5/10 | All major interfaces have field-level JSDoc |
|
||||
| Core query components (`DataGrid`, `FilterBuilder`, `Pagination`, `ColumnConfig`) | 8/10 | Props and behavior well-described |
|
||||
| Records components (`FieldValue`, `RecordView`, `EntityForm`) | 7.5/10 | Good headers; adornment priority documented |
|
||||
| `CLAUDE.md` | 9/10 | Architecture, conventions, file structure all clear |
|
||||
| Implementation plans (`docs/implementation-plans/`) | 8/10 | Phased clearly; shared context well-organized |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Work Order
|
||||
|
||||
### Phase 1 — Unblock contributors (half day)
|
||||
1. **DOC-CRIT-1** — Expand README.md
|
||||
2. **DOC-CRIT-2** — Document `useRecordQuery` return type
|
||||
|
||||
### Phase 2 — Improve onboarding (1 day)
|
||||
3. **DOC-CRIT-3** — Create `docs/STATE-MANAGEMENT.md`
|
||||
4. **DOC-SIG-1** — Create `docs/COMPONENT-PATTERNS.md`
|
||||
5. **DOC-SIG-2** — Create `docs/TYPE-GLOSSARY.md`
|
||||
|
||||
### Phase 3 — Fill API/validation gaps (half day)
|
||||
6. **DOC-SIG-3** — API error handling guide
|
||||
7. **DOC-SIG-4** — Add `@example` to `zodFieldFromMetadata`
|
||||
8. **DOC-SIG-5** — Create `docs/FORM-VALIDATION.md`
|
||||
9. **DOC-SIG-6** — Document query key factory
|
||||
|
||||
### Phase 4 — Polish (as time allows)
|
||||
10–13. **DOC-MIN-1 through DOC-MIN-4** — Test headers, perf rationale, a11y checklist, widget guide
|
||||
@@ -0,0 +1,333 @@
|
||||
# Form Validation
|
||||
|
||||
This guide covers how forms are validated in QQQ Frontend Next. Validation is schema-driven: Zod schemas are generated automatically from the same backend field metadata that drives rendering, so there is no need to maintain a separate validation definition alongside the form.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The validation stack is:
|
||||
|
||||
- **Zod** — runtime schema definition and validation.
|
||||
- **React Hook Form** + `@hookform/resolvers/zod` — drives form state; the Zod schema is passed as the resolver.
|
||||
- **`zodFieldFromMetadata`** — the core function that maps a single `QFieldMetaData` to a Zod validator.
|
||||
- **`zodSchemaFromTableMetadata`** — builds a full `z.ZodObject` from all editable fields in a table.
|
||||
- **`zodSchemaFromFields`** — builds a `z.ZodObject` from a flat array of fields (used for process steps).
|
||||
|
||||
The source for the schema generation utilities is `src/lib/utils/zod-from-metadata.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Schema generation
|
||||
|
||||
### For table-based forms
|
||||
|
||||
`EntityForm` (`src/components/forms/EntityForm.tsx`) builds its schema on mount:
|
||||
|
||||
```tsx
|
||||
const schema = useMemo(
|
||||
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude),
|
||||
[tableMetaData, fieldNamesToInclude]
|
||||
)
|
||||
|
||||
const { register, control, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: mergedDefaults,
|
||||
})
|
||||
```
|
||||
|
||||
`zodSchemaFromTableMetadata` iterates the table's fields, skips hidden (`isHidden`) and non-editable (`!isEditable`) fields, and calls `zodFieldFromMetadata` for each remaining field:
|
||||
|
||||
```tsx
|
||||
export function zodSchemaFromTableMetadata(
|
||||
tableMetaData: QTableMetaData,
|
||||
fieldNamesToInclude?: string[]
|
||||
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {}
|
||||
|
||||
for (const fieldName of fieldsToProcess) {
|
||||
const field = tableMetaData.fields[fieldName]
|
||||
if (!field) continue
|
||||
if (field.isHidden) continue
|
||||
if (!field.isEditable) continue
|
||||
shape[fieldName] = zodFieldFromMetadata(field)
|
||||
}
|
||||
|
||||
return z.object(shape)
|
||||
}
|
||||
```
|
||||
|
||||
When `fieldNamesToInclude` is provided, only those fields are included. This is useful for partial-edit dialogs or multi-step wizards where only a subset of fields are shown per step.
|
||||
|
||||
### For process step forms
|
||||
|
||||
Process steps receive an ordered array of fields rather than a table metadata object. Use `zodSchemaFromFields`:
|
||||
|
||||
```tsx
|
||||
import { zodSchemaFromFields } from '@/lib/utils/zod-from-metadata'
|
||||
|
||||
const schema = zodSchemaFromFields(stepFields) // QFieldMetaData[]
|
||||
```
|
||||
|
||||
Hidden fields are excluded automatically. The resulting schema shape is identical to the table-based variant.
|
||||
|
||||
### For single fields
|
||||
|
||||
Call `zodFieldFromMetadata` directly when you need the Zod type for a single field:
|
||||
|
||||
```tsx
|
||||
import { zodFieldFromMetadata } from '@/lib/utils/zod-from-metadata'
|
||||
|
||||
const fieldSchema = zodFieldFromMetadata(tableMetaData.fields['email'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field type to Zod type mapping
|
||||
|
||||
The table below shows exactly how each QQQ field type is converted. All mappings are implemented in `zodFieldFromMetadata`.
|
||||
|
||||
| QQQ field type | Zod type (required) | Zod type (optional) | Notes |
|
||||
|---|---|---|---|
|
||||
| `STRING` | `z.string().min(1, ...)` | `z.string().optional()` | `maxLength` adds `.max()` when set |
|
||||
| `TEXT` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
|
||||
| `HTML` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
|
||||
| `PASSWORD` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
|
||||
| `INTEGER` | `z.coerce.number().int(...)` | `z.union([z.literal(''), z.coerce.number().int(...)]).optional()` | Empty `<input type="number">` submits `""` |
|
||||
| `LONG` | `z.coerce.number().int(...)` | `z.union([z.literal(''), z.coerce.number().int(...)]).optional()` | Same as INTEGER |
|
||||
| `DECIMAL` | `z.coerce.number(...)` | `z.union([z.literal(''), z.coerce.number(...)]).optional()` | No `.int()` — allows fractional values |
|
||||
| `BOOLEAN` | `z.boolean().optional()` | `z.boolean().optional()` | Always optional — checkboxes are never "required" |
|
||||
| `DATE` | `z.string().min(1, ...)` | `z.string().optional()` | HTML date inputs submit ISO-8601 strings |
|
||||
| `TIME` | `z.string().min(1, ...)` | `z.string().optional()` | Same as DATE |
|
||||
| `DATE_TIME` | `z.string().min(1, ...)` | `z.string().optional()` | Same as DATE |
|
||||
| `BLOB` | `z.union([z.instanceof(File), z.string()]).optional()` | same | Accepts File objects or existing URL strings |
|
||||
|
||||
### Why numbers use `z.literal('')` in the optional case
|
||||
|
||||
An `<input type="number">` submits an empty string (`""`) when left blank, not `null` or `undefined`. A plain `z.coerce.number()` would coerce `""` to `0`, silently making an empty field appear as zero. The `z.union([z.literal(''), schema])` pattern allows the empty string to pass through as-is so the form can distinguish "nothing entered" from "zero entered".
|
||||
|
||||
### Why dates stay as strings
|
||||
|
||||
Date and time values come from `<input type="date">` and `<input type="datetime-local">` as ISO-8601 strings (`"2026-03-01"`, `"2026-03-01T14:30"`). The schema validates them as strings. Any coercion to JavaScript `Date` objects — if required by the API — happens in the submit handler, not in the Zod schema.
|
||||
|
||||
---
|
||||
|
||||
## Required vs optional
|
||||
|
||||
The `isRequired` flag on `QFieldMetaData` is the single source of truth for whether a field is required:
|
||||
|
||||
- **`isRequired: true`** → `.min(1, "${label} is required")` for string types; plain `z.coerce.number()` for numeric types (empty string fails coercion).
|
||||
- **`isRequired: false` or absent** → `.optional()` for string types; `z.union([z.literal(''), ...]).optional()` for numeric types.
|
||||
|
||||
All error messages use the human-readable `field.label` (not `field.name`), so they read naturally in the UI: `"First Name is required"`, not `"firstName is required"`.
|
||||
|
||||
---
|
||||
|
||||
## Number bounds
|
||||
|
||||
When a field's metadata declares `minValue` or `maxValue`, those bounds are applied to the Zod schema:
|
||||
|
||||
```tsx
|
||||
function buildNumberSchema(isRequired, label, isInteger, minValue, maxValue) {
|
||||
let schema = isInteger
|
||||
? z.coerce.number().int(`${label} must be a whole number`)
|
||||
: z.coerce.number({ message: `${label} must be a number` })
|
||||
|
||||
if (minValue !== undefined && minValue !== null) {
|
||||
schema = schema.min(Number(minValue), `${label} must be at least ${minValue}`)
|
||||
}
|
||||
if (maxValue !== undefined && maxValue !== null) {
|
||||
schema = schema.max(Number(maxValue), `${label} must be at most ${maxValue}`)
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The bounds come directly from metadata — no hardcoded ranges exist in the frontend. `DynamicFormField` also forwards `minValue` and `maxValue` as HTML attributes (`min`, `max`) on `<NumberField>` so the browser provides native range feedback, but Zod is the authoritative validator on submit.
|
||||
|
||||
---
|
||||
|
||||
## String length constraints
|
||||
|
||||
`maxLength` from field metadata maps to Zod's `.max()`:
|
||||
|
||||
```tsx
|
||||
function buildStringSchema(isRequired, label, maxLength) {
|
||||
let schema = z.string()
|
||||
if (isRequired) schema = schema.min(1, `${label} is required`)
|
||||
if (maxLength) schema = schema.max(maxLength, `${label} must be at most ${maxLength} characters`)
|
||||
return isRequired ? schema : schema.optional()
|
||||
}
|
||||
```
|
||||
|
||||
`DynamicFormField` also passes `maxLength` to `<TextField>` as an HTML `maxlength` attribute, providing in-browser character limiting, but the Zod schema validates the constraint on submit as well.
|
||||
|
||||
---
|
||||
|
||||
## Default values
|
||||
|
||||
`defaultValuesFromRecord` (`src/lib/utils/zod-from-metadata.ts`) builds the `defaultValues` object for `useForm`:
|
||||
|
||||
```tsx
|
||||
export function defaultValuesFromRecord(
|
||||
tableMetaData: QTableMetaData,
|
||||
recordValues: Record<string, unknown>
|
||||
): Record<string, unknown>
|
||||
```
|
||||
|
||||
For each editable, visible field it applies this priority:
|
||||
1. Use the record value (from the API response), with type coercion:
|
||||
- `DATE`, `TIME`, `DATE_TIME` → `String(value)` (ISO-8601 string for the HTML input).
|
||||
- `BOOLEAN` → `Boolean(value)`.
|
||||
- All other types → value as-is.
|
||||
2. Fall back to `field.defaultValue` from metadata.
|
||||
3. Fall back to `false` for `BOOLEAN` fields.
|
||||
4. Fall back to `''` (empty string) for everything else.
|
||||
|
||||
`EntityForm` calls this inside `useMemo` and passes the result to `useForm({ defaultValues })`. When the record changes (e.g., navigating between records), the form is reset via `reset(defaultValuesFromRecord(...))` in a `useEffect`.
|
||||
|
||||
---
|
||||
|
||||
## Client vs server validation
|
||||
|
||||
### Client-side (Zod)
|
||||
|
||||
Zod runs on the client when the user submits the form. React Hook Form calls the Zod resolver, collects all field errors, and populates `formState.errors`. Fields display their error messages inline — `DynamicFormField` renders a `<p role="alert">` with `id="${fieldId}-error"` below each failing input, and the input gains `aria-invalid="true"` and `aria-describedby` pointing at the error element.
|
||||
|
||||
```tsx
|
||||
{fieldError && (
|
||||
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{fieldError.message}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
### Server-side (API)
|
||||
|
||||
The backend can return validation errors after a save attempt. `EntityForm` handles this via TanStack Query mutation `onError` callbacks. Currently, API errors are shown as a top-level error banner rather than per-field:
|
||||
|
||||
```tsx
|
||||
const insertMutation = useMutation({
|
||||
mutationFn: (values) => insertRecord(tableMetaData.name, values),
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Failed to create ${tableMetaData.label}: ${err.message}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
When the API returns structured per-field errors (a map of `{ fieldName: errorMessage }`), map them to React Hook Form using `form.setError`:
|
||||
|
||||
```tsx
|
||||
onError: (err) => {
|
||||
const fieldErrors = parseApiFieldErrors(err)
|
||||
if (fieldErrors) {
|
||||
for (const [fieldName, message] of Object.entries(fieldErrors)) {
|
||||
form.setError(fieldName, { type: 'server', message })
|
||||
}
|
||||
} else {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This makes server validation errors appear in the same inline error elements as client-side Zod errors, giving the user a consistent experience.
|
||||
|
||||
---
|
||||
|
||||
## DynamicFormField dispatch
|
||||
|
||||
`DynamicFormField` (`src/components/forms/DynamicFormField.tsx`) is the component that renders a single field inside a metadata-driven form. It dispatches to a typed input component based on field properties in this priority order:
|
||||
|
||||
1. **`field.possibleValueSourceName` is set** → `<PossibleValueSelect>` (async combobox).
|
||||
2. **`FILE_UPLOAD` adornment or `BLOB` type** → `<FileUploadField>`.
|
||||
3. **`field.type` switch** → one of `TextField`, `NumberField`, `BooleanField`, `DateField`, `DateTimeField`, `PasswordField`, or inline `<textarea>` (TEXT), `<RichTextField>` (HTML), `<input type="time">` (TIME).
|
||||
4. **Default case** → `<TextField>` (treats unknown types as plain text).
|
||||
|
||||
Hidden fields (`isHidden: true`) and non-editable fields return `null` immediately.
|
||||
|
||||
### Dirty field indicator
|
||||
|
||||
When a field's value has changed from its default, `DynamicFormField` wraps the input in a left-border accent:
|
||||
|
||||
```tsx
|
||||
function DirtyWrapper({ isDirty, children }) {
|
||||
if (!isDirty) return <>{children}</>
|
||||
return (
|
||||
<div className="border-l-2 border-primary pl-2">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `isDirty` prop comes from `formState.dirtyFields[fieldName]` in `EntityForm`, which passes the full `dirtyFields` map to `DynamicForm`, which passes each field's individual dirty state to `DynamicFormField`.
|
||||
|
||||
---
|
||||
|
||||
## Adding custom Zod refinements
|
||||
|
||||
The generated schemas cover all constraints that can be expressed in QQQ field metadata. When you need a validation rule that metadata cannot express (for example, cross-field validation or a format check), add a `.superRefine()` or `.refine()` on the schema returned by `zodSchemaFromTableMetadata`:
|
||||
|
||||
```tsx
|
||||
// In the component that builds the form
|
||||
const baseSchema = zodSchemaFromTableMetadata(tableMetaData)
|
||||
|
||||
const schema = baseSchema.superRefine((values, ctx) => {
|
||||
// Cross-field: end date must be after start date
|
||||
if (values.startDate && values.endDate && values.endDate < values.startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['endDate'],
|
||||
message: 'End date must be after start date',
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Prefer `.superRefine()` over `.refine()` when you need to report the error on a specific field path rather than at the root of the object, so React Hook Form can display it inline next to the correct input.
|
||||
|
||||
---
|
||||
|
||||
## Complete example
|
||||
|
||||
### Field metadata (from the API)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "quantity",
|
||||
"label": "Quantity",
|
||||
"type": "INTEGER",
|
||||
"isRequired": true,
|
||||
"isEditable": true,
|
||||
"isHidden": false,
|
||||
"minValue": 1,
|
||||
"maxValue": 9999
|
||||
}
|
||||
```
|
||||
|
||||
### Generated Zod schema for this field
|
||||
|
||||
`zodFieldFromMetadata` produces:
|
||||
|
||||
```ts
|
||||
z.coerce.number()
|
||||
.int("Quantity must be a whole number")
|
||||
.min(1, "Quantity must be at least 1")
|
||||
.max(9999, "Quantity must be at most 9999")
|
||||
```
|
||||
|
||||
Because `isRequired` is `true`, this is the final schema (no `z.literal('')` union wrapping). An empty `<input type="number">` submits `""`, `z.coerce.number()` coerces that to `NaN`, and `NaN` fails the `.int()` check — so the required constraint is enforced implicitly.
|
||||
|
||||
### Full table schema (example)
|
||||
|
||||
```ts
|
||||
const schema = zodSchemaFromTableMetadata(orderTableMetaData)
|
||||
// Produces:
|
||||
z.object({
|
||||
customerId: z.string().min(1, 'Customer is required'), // STRING, required
|
||||
quantity: z.coerce.number().int(...).min(1).max(9999), // INTEGER, required
|
||||
notes: z.string().optional(), // TEXT, optional
|
||||
shipDate: z.string().optional(), // DATE, optional
|
||||
isRush: z.boolean().optional(), // BOOLEAN, always optional
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
# PLAN: Next UI complete feature acceptance and default adoption (QRun-IO/qqq#649)
|
||||
|
||||
## Goal
|
||||
Make Next the fully tested, working default QQQ admin UI: every inventoried supported
|
||||
feature has executable acceptance coverage that passes against the real sample backend and
|
||||
a production Next build, and every documented default launch path starts Next.
|
||||
|
||||
## Approach
|
||||
1. Inventory UI-facing features from source (sample metadata, backend enums/routes, Material
|
||||
behavior) into `docs/acceptance/feature-matrix.md`; each row names its fixture, scenarios,
|
||||
test IDs, result and issue links.
|
||||
2. Add a Playwright acceptance project (`tests/acceptance/`) that boots the QRun-owned sample
|
||||
JAR on an ephemeral loopback port and serves `next build` output with `next start`, using
|
||||
same-origin forwarding. Tests carry matrix IDs; `scripts/acceptance-gate.mjs` fails when a
|
||||
required row has no passing test or a test is skipped/flaky.
|
||||
3. Fix demonstrated defects with regression tests (#550, #645, #538, #539, #541, others found),
|
||||
filing or reusing a QQQ issue for each.
|
||||
4. Update default entry points (quickstart, sample README, root README, website, wiki,
|
||||
starter/template) while keeping Material selectable.
|
||||
5. Run all gates, publish versioned artifacts through normal workflows, record evidence.
|
||||
|
||||
## Branches
|
||||
- `qqq-frontend-next`: `feature/GH-649-next-acceptance` (from `main`)
|
||||
- `qqq`: worktree `../qqq-649`, branch `feature/GH-649-next-default` (from `origin/develop`)
|
||||
|
||||
## Open questions (for James)
|
||||
- Commits, pushes, PRs, image/tag publication and dependency changes need explicit approval
|
||||
per repository rules; work proceeds locally until then.
|
||||
@@ -0,0 +1,332 @@
|
||||
# State Management Guide
|
||||
|
||||
This document explains when to use each state container in the QQQ Frontend Next codebase. Each container has a specific purpose — choosing the wrong one leads to stale data, lost state on navigation, or unnecessary complexity.
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| If the state is… | Use… |
|
||||
|---|---|
|
||||
| Data fetched from the server (records, metadata, widgets, processes) | TanStack Query |
|
||||
| Form field values and validation errors | React Hook Form |
|
||||
| Filters, pagination, sort order — things a user should be able to bookmark or share | URL search params |
|
||||
| User preferences that survive a session (density, column widths, saved views) | `useLocalStorage` |
|
||||
| Application-wide UI state (modal stack, page header, accent color, branding) | QContext |
|
||||
| Ephemeral UI state local to one component (dropdown open, hover, toggle) | `useState` / `useReducer` |
|
||||
|
||||
---
|
||||
|
||||
## TanStack Query — Server State
|
||||
|
||||
**Rule:** Never use `useState` for data that comes from the server. TanStack Query owns all server state.
|
||||
|
||||
The query client is configured in `src/lib/query-client.ts` with:
|
||||
- 5-minute stale time — metadata and record lists stay fresh without excessive refetching
|
||||
- 10-minute GC time — recently visited pages are snappy when navigating back
|
||||
- Smart retry: never retries 4xx errors; retries 5xx and network failures up to 3 times with exponential back-off
|
||||
- Global `QueryCache.onError` and `MutationCache.onError` — most errors are automatically toasted without per-call handling
|
||||
|
||||
### Query key factory
|
||||
|
||||
All query keys are defined in `queryKeys` in `src/lib/query-client.ts`. Always use the factory, never inline string arrays.
|
||||
|
||||
```typescript
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
// Fetch a single record
|
||||
useQuery({
|
||||
queryKey: queryKeys.tableRecord('order', recordId),
|
||||
queryFn: () => getRecord('order', recordId),
|
||||
})
|
||||
|
||||
// Invalidate all records for a table after a mutation
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords('order') })
|
||||
|
||||
// Invalidate everything (e.g. after logout)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.all() })
|
||||
```
|
||||
|
||||
### Key hierarchy (broadest → narrowest)
|
||||
|
||||
```
|
||||
['qqq']
|
||||
['qqq', 'metadata', 'all']
|
||||
['qqq', 'metadata', 'table', tableName]
|
||||
['qqq', 'records', tableName]
|
||||
['qqq', 'records', tableName, recordId]
|
||||
['qqq', 'records', tableName, 'count', filterHash]
|
||||
['qqq', 'processes', processName, processUUID, 'status', jobUUID]
|
||||
['qqq', 'widgets', widgetName, params]
|
||||
['qqq', 'possibleValues', 'table', tableName, fieldName, searchTerm]
|
||||
['qqq', 'search', searchTerm]
|
||||
```
|
||||
|
||||
Broad invalidations cascade down. For example, `queryKeys.tableRecords('order')` invalidates both the list query and all individual record queries for that table.
|
||||
|
||||
### What belongs in TanStack Query
|
||||
|
||||
- Record lists and counts
|
||||
- Single record fetches
|
||||
- All metadata (tables, processes, apps)
|
||||
- Widget data
|
||||
- Process status polling
|
||||
- Possible values / enum lookups
|
||||
- Global search results
|
||||
- Auth session metadata
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not use TanStack Query for UI state (modal open/close, form field values, user preferences). Those belong in the containers described below.
|
||||
|
||||
---
|
||||
|
||||
## React Hook Form — Form Field State
|
||||
|
||||
**Rule:** React Hook Form owns field values and validation state for forms only. Do not use it for non-form UI, and do not reach into the form state from outside the form.
|
||||
|
||||
Forms are built with Zod schemas derived from field metadata. The schema is constructed by `zodFromMetadata()` in `src/lib/utils/zod-from-metadata.ts`.
|
||||
|
||||
```typescript
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { zodFromMetadata } from '@/lib/utils/zod-from-metadata'
|
||||
|
||||
function EntityForm({ tableMetaData, record }: Props) {
|
||||
const schema = zodFromMetadata(tableMetaData)
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: record ?? {},
|
||||
})
|
||||
|
||||
// Map a 400 validation error back to individual fields
|
||||
const onSubmit = async (data: FieldValues) => {
|
||||
try {
|
||||
await saveRecord('order', data)
|
||||
} catch (error) {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
const fieldErrors = extractFieldErrors(error)
|
||||
for (const [field, message] of Object.entries(fieldErrors)) {
|
||||
form.setError(field, { message })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What belongs in React Hook Form
|
||||
|
||||
- All form field values
|
||||
- Validation errors (field-level and form-level)
|
||||
- Dirty/touched state
|
||||
- Submit handling
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not use React Hook Form for filter criteria in the query page — those live in URL params and reducer state managed by `useRecordQuery`.
|
||||
|
||||
---
|
||||
|
||||
## URL Search Params — Shareable State
|
||||
|
||||
**Rule:** Any state a user should be able to bookmark, share, or restore after a page refresh goes in the URL.
|
||||
|
||||
Use Next.js `useSearchParams` and `useRouter` to read and write URL state. This is the right home for:
|
||||
- Active filters and quick search term
|
||||
- Current page and page size
|
||||
- Sort column and direction
|
||||
|
||||
The `useRecordQuery` hook syncs filter/pagination state to and from the URL automatically. Components should read these values from the hook, not from `useSearchParams` directly.
|
||||
|
||||
```typescript
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
|
||||
// Reading
|
||||
const searchParams = useSearchParams()
|
||||
const page = Number(searchParams.get('page') ?? '1')
|
||||
const sort = searchParams.get('sort') ?? ''
|
||||
|
||||
// Writing — replace so the back button doesn't accumulate filter changes
|
||||
const router = useRouter()
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set('page', String(nextPage))
|
||||
router.replace(`?${params.toString()}`)
|
||||
```
|
||||
|
||||
### Key naming conventions in URL params
|
||||
|
||||
| Param | Values | Purpose |
|
||||
|---|---|---|
|
||||
| `page` | integer | Current page (1-based) |
|
||||
| `pageSize` | 10, 25, 50, 100 | Rows per page |
|
||||
| `sort` | `fieldName:asc` or `fieldName:desc` | Active sort |
|
||||
| `q` | string | Quick search term |
|
||||
| `filter` | base64-encoded JSON | Serialized filter criteria |
|
||||
|
||||
### What belongs in URL params
|
||||
|
||||
- Filters, quick search
|
||||
- Pagination (page, page size)
|
||||
- Sort order
|
||||
- Any state that makes a link shareable
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not put ephemeral UI state (modal open, panel open) in the URL. Do not put server data in the URL.
|
||||
|
||||
---
|
||||
|
||||
## localStorage — User Preferences
|
||||
|
||||
**Rule:** Preferences that should survive across sessions and browser restarts go in localStorage via the `useLocalStorage` hook.
|
||||
|
||||
The hook lives at `src/lib/hooks/use-local-storage.ts`. It is SSR-safe (returns `initialValue` on the server), cross-tab synced (listens to `storage` events), and has a stable setter that accepts a value or an updater function matching the `useState` API.
|
||||
|
||||
```typescript
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
|
||||
const [density, setDensity, removeDensity] = useLocalStorage<'compact' | 'standard' | 'comfortable'>(
|
||||
'qqq-order-density',
|
||||
'standard'
|
||||
)
|
||||
```
|
||||
|
||||
### Higher-level hooks built on `useLocalStorage`
|
||||
|
||||
| Hook | Key | Contents |
|
||||
|---|---|---|
|
||||
| `useUserPreferences` | `qqq-user-preferences` | Default page size, density, view mode |
|
||||
| `useColumnConfig` | `qqq-{tableName}-columns`, `qqq-{tableName}-column-order`, `qqq-{tableName}-column-widths` | Column visibility, order, pixel widths |
|
||||
| `useSavedViews` | `qqq-{tableName}-saved-views` | Named filter snapshots |
|
||||
|
||||
### Key naming convention
|
||||
|
||||
```
|
||||
qqq-{tableName}-{purpose} # table-scoped
|
||||
qqq-user-preferences # global user preferences
|
||||
qqq:{scope}:{purpose} # alternative colon-separated form
|
||||
```
|
||||
|
||||
Always prefix with `qqq-` to avoid collisions with other apps that might share the same origin.
|
||||
|
||||
### What belongs in localStorage
|
||||
|
||||
- Table density (compact / standard / comfortable)
|
||||
- Column visibility and order per table
|
||||
- Column pixel widths per table
|
||||
- Saved views (named filter snapshots)
|
||||
- Global user preferences (default page size, default view mode)
|
||||
- Dismissed banners or one-time notices
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not store server data in localStorage — use TanStack Query's cache. Do not store state that should be shareable — use URL params.
|
||||
|
||||
---
|
||||
|
||||
## QContext — Application-Wide UI State
|
||||
|
||||
**Rule:** Use QContext for state that must be accessible anywhere in the authenticated app tree and does not fit a more specific container.
|
||||
|
||||
The provider is `QContextProvider` in `src/lib/context/q-context.tsx`. Access it with the `useQContext()` hook. The hook throws at runtime if called outside a provider, which catches integration mistakes early.
|
||||
|
||||
```typescript
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
|
||||
function RecordQueryPage({ tableMetaData }: Props) {
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(tableMetaData.label)
|
||||
setTableMetaData(tableMetaData)
|
||||
}, [tableMetaData])
|
||||
}
|
||||
```
|
||||
|
||||
### What QContext contains
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `pageHeader` | `string \| ReactNode` | Content rendered in the page header area |
|
||||
| `accentColor` / `accentColorLight` | `string` | Theme accent colors (hex) |
|
||||
| `tableMetaData` | `QTableMetaData \| null` | Metadata for the currently viewed table |
|
||||
| `tableProcesses` | `QProcessMetaData[] \| null` | Processes for the current table |
|
||||
| `dotMenuOpen` | `boolean` | Whether the ellipsis action menu is open |
|
||||
| `modalStack` | `string[]` | Ordered list of open modal identifiers for keyboard nav |
|
||||
| `pushModalOnStack(id)` | function | Called when a modal opens |
|
||||
| `popModalOffStack(id)` | function | Called when a modal closes; warns if id is not on top |
|
||||
| `clearModalStack()` | function | Clears all modal identifiers (e.g. on navigation) |
|
||||
| `keyboardHelpOpen` | `boolean` | Keyboard shortcut overlay visibility |
|
||||
| `userId` | `string \| undefined` | Current authenticated user identifier |
|
||||
| `branding` | `QBrandingMetaData \| null` | Company name, app name, URLs from backend |
|
||||
| `pathToLabelMap` | `Record<string, string>` | URL path segment → human-readable breadcrumb label |
|
||||
|
||||
### Modal stack usage
|
||||
|
||||
The modal stack ensures that `Escape` closes only the top-most modal and that focus is restored correctly. Every modal or sheet component must push on open and pop on close.
|
||||
|
||||
```typescript
|
||||
const { pushModalOnStack, popModalOffStack, modalStack } = useQContext()
|
||||
const modalId = 'edit-record-modal'
|
||||
|
||||
// On open
|
||||
pushModalOnStack(modalId)
|
||||
|
||||
// On close
|
||||
popModalOffStack(modalId)
|
||||
|
||||
// Check if this modal is on top (for Escape handling)
|
||||
const isTopModal = modalStack[modalStack.length - 1] === modalId
|
||||
```
|
||||
|
||||
### What belongs in QContext
|
||||
|
||||
- Application-wide UI state that has no more specific home
|
||||
- Page header content (set by each page on mount)
|
||||
- Modal stack (for coordinated keyboard navigation)
|
||||
- Accent colors and branding
|
||||
- The currently active table metadata (consumed by header, breadcrumbs, action menus)
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
Do not put server data in QContext (use TanStack Query). Do not put form state in QContext (use React Hook Form). Do not put user preferences in QContext (use localStorage).
|
||||
|
||||
---
|
||||
|
||||
## useState / useReducer — Local Transient State
|
||||
|
||||
**Rule:** Use `useState` or `useReducer` only for state that is local to a single component or a small subtree, ephemeral (lost on unmount is acceptable), and neither server data nor shareable state.
|
||||
|
||||
```typescript
|
||||
// Good — ephemeral open/close that doesn't need to survive navigation
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
// Good — complex multi-field local state with clear action types
|
||||
const [state, dispatch] = useReducer(recordQueryReducer, initialState)
|
||||
```
|
||||
|
||||
### What belongs in useState / useReducer
|
||||
|
||||
- Dialog, dropdown, tooltip open/close
|
||||
- Hover and focus state
|
||||
- Local form wizard step (if not shareable)
|
||||
- Complex local state with many fields that transition together (handled by `useReducer`)
|
||||
|
||||
### What does NOT belong
|
||||
|
||||
- Server data (use TanStack Query)
|
||||
- State that should survive navigation or page refresh (use URL params or localStorage)
|
||||
- State needed by distant parts of the tree (use QContext or lift appropriately)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
```
|
||||
Server data → TanStack Query (queryKeys factory, automatic toasts on error)
|
||||
Form fields → React Hook Form + Zod
|
||||
Shareable/bookmarkable → URL search params
|
||||
User preferences → useLocalStorage (key: qqq-{tableName}-{purpose})
|
||||
App-wide UI state → QContext (useQContext hook)
|
||||
Local/ephemeral UI → useState / useReducer
|
||||
```
|
||||
@@ -0,0 +1,190 @@
|
||||
# QQQ Frontend Type Glossary
|
||||
|
||||
All types listed here are re-exported from `@/types` (the `src/types/index.ts` barrel).
|
||||
Application code should always import from `@/types`, not from individual module files,
|
||||
so that internal type organisation can change without affecting import paths.
|
||||
|
||||
```typescript
|
||||
import type { QInstance, QTableMetaData, QRecord, QQueryFilter } from '@/types'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. App & Auth
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QInstance` | `metadata.ts` | Top-level descriptor returned by `/metaData/instance`; the single source of truth for all apps, tables, processes, reports, widgets, and branding. **Key fields:** `apps`, `tables`, `processes`, `widgets`, `branding`, `appTree`, `environmentValues` |
|
||||
| `QAuthenticationMetaData` | `metadata.ts` | Describes the auth provider type (`AUTH_0`, `OAUTH2`, `FULLY_ANONYMOUS`, `MOCK`) and the provider-specific values (client ID, base URL, audience) needed to boot the correct auth flow. |
|
||||
| `QBrandingMetaData` | `metadata.ts` | Controls visual identity: company name, app name, logo URL, accent color, notification banners, and custom CSS injected via `data-qqq-id` selectors. |
|
||||
| `QThemeMetaData` | `metadata.ts` | Backend-supplied Tailwind CSS token overrides (`primaryColor`, `accentColor`, `mode`). Applied on top of the default theme by the theme provider. |
|
||||
| `Banner` | `metadata.ts` | A single notification banner (text, severity `info|warning|error`, optional color, dismissible flag) rendered at the top of the dashboard layout. |
|
||||
|
||||
### QInstance example
|
||||
|
||||
```typescript
|
||||
const instance: QInstance = {
|
||||
apps: { crm: { name: 'crm', label: 'CRM', children: [], ... } },
|
||||
appTree: [{ name: 'crm', label: 'CRM', type: 'APP', ... }],
|
||||
tables: { person: { name: 'person', label: 'People', fields: {}, ... } },
|
||||
processes: {},
|
||||
reports: {},
|
||||
widgets: {},
|
||||
branding: { companyName: 'Acme', companyUrl: 'https://acme.com', appName: 'Admin' },
|
||||
helpContents: {},
|
||||
environmentValues: {},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Metadata
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QTableMetaData` | `metadata.ts` | Full descriptor for a QQQ table: fields, sections, join relationships, capabilities, and per-user CRUD permissions. Drives the entire record query, view, create, and edit experience. **Key fields:** `name`, `label`, `primaryKeyField`, `fields`, `sections`, `capabilities`, `readPermission`, `insertPermission`, `editPermission`, `deletePermission` |
|
||||
| `QFieldMetaData` | `metadata.ts` | Descriptor for a single table column: data type, required/editable/hidden flags, max length, numeric bounds, possible-value source, display format, and adornments. **Key fields:** `name`, `label`, `type`, `isRequired`, `isEditable`, `isHidden`, `possibleValueSourceName`, `adornments` |
|
||||
| `QProcessMetaData` | `metadata.ts` | Descriptor for a multi-step wizard process: identity, permissions, input record bounds, and the ordered list of `QFrontendStepMetaData` objects to render. |
|
||||
| `QFrontendStepMetaData` | `metadata.ts` | A single step in a process wizard: label, ordered component list, and optional form/view/record-list field arrays used by the step renderer. |
|
||||
| `QFrontendComponent` | `metadata.ts` | A single UI component descriptor within a process step — a `type` (from `QComponentType`) plus arbitrary `values` passed as props to the matching renderer. |
|
||||
| `QAppMetaData` | `metadata.ts` | A top-level app grouping: sidebar navigation children, dashboard widget names, and labeled sections grouping tables, processes, and reports. |
|
||||
| `QAppTreeNode` | `metadata.ts` | One node in the recursive sidebar navigation tree; the `type` field (`TABLE`, `PROCESS`, `REPORT`, `APP`) determines the route. |
|
||||
| `QAppSection` | `metadata.ts` | A labeled card group on an app dashboard page, listing the table, process, and report names belonging to that group. |
|
||||
| `QWidgetMetaData` | `metadata.ts` | Dashboard widget descriptor: renderer type string, permissions, grid column span, optional toolbar dropdowns, reload/export button flags, and help content. |
|
||||
| `QTableSection` | `metadata.ts` | A collapsible card section on record view/edit pages: field names to display, optional embedded widget name, display tier, and grid column span. |
|
||||
| `QExposedJoin` | `metadata.ts` | A join relationship made available for query filters and record views: cardinality (`isMany`), joined table metadata, and the path of `QJoinMetaData` hops. |
|
||||
| `FieldAdornment` | `metadata.ts` | Discriminated union of all adornment shapes (`LINK`, `CHIP`, `TOOLTIP`, `ERROR`, `FILE_DOWNLOAD`, `SIZE`, `REVEAL`, `CODE_EDITOR`, `RENDER_HTML`, `FILE_UPLOAD`). Narrow with `Extract<FieldAdornment, { type: 'LINK' }>`. |
|
||||
|
||||
### QFieldMetaData example
|
||||
|
||||
```typescript
|
||||
const field: QFieldMetaData = {
|
||||
name: 'firstName',
|
||||
label: 'First Name',
|
||||
type: 'STRING',
|
||||
isRequired: true,
|
||||
isEditable: true,
|
||||
isHidden: false,
|
||||
isHeavy: false,
|
||||
maxLength: 100,
|
||||
adornments: [],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Records & Queries
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QRecord` | `records.ts` | A single backend record returned by the tables API. `values` holds raw typed data; `displayValues` holds pre-formatted strings ready for rendering. May include `associatedRecords` for joined/child data, and `errors`/`warnings` for bulk operations. |
|
||||
| `QPossibleValue` | `records.ts` | A single option from a possible-value source, used to populate autocomplete and select inputs. `id` is the stored value; `label` is the user-visible text. |
|
||||
| `QAuditRecord` | `records.ts` | An audit log entry for a record: who changed it, when, what action (`INSERT`/`UPDATE`/`DELETE`), and the list of `QAuditFieldChange` field-level diffs. |
|
||||
| `QQueryFilter` | `query.ts` | Root filter object sent to `/tables/{name}/query` and `/tables/{name}/count`. Combines `criteria` with `AND`/`OR`, supports nested `subFilters`, and carries `skip`/`limit` for pagination. |
|
||||
| `QFilterCriteria` | `query.ts` | A single field-level predicate: a `fieldName`, a `QCriteriaOperator`, and a `values` array that may contain literals or dynamic expressions (`NowExpression`, `NowWithOffsetExpression`, etc.). |
|
||||
| `QFilterOrderBy` | `query.ts` | A sort clause: `fieldName` and `isAscending`. Multiple entries form a compound sort. |
|
||||
| `QueryJoin` | `query.ts` | A join instruction sent alongside a `QQueryFilter` when fields from a related table are needed in query results. Specifies `joinTable`, join type (`INNER`/`LEFT`/etc.), and optional alias. |
|
||||
| `NowWithOffsetExpression` | `query.ts` | A dynamic filter value that resolves to "now minus/plus N days/weeks/months/years" at query time, enabling "last 7 days" style saved filters. |
|
||||
|
||||
### QQueryFilter example
|
||||
|
||||
```typescript
|
||||
const filter: QQueryFilter = {
|
||||
criteria: [
|
||||
{ fieldName: 'status', operator: 'EQUALS', values: ['ACTIVE'] },
|
||||
{ fieldName: 'createdDate', operator: 'GREATER_THAN', values: [{ type: 'NOW_WITH_OFFSET', offsetValue: 30, offsetUnit: 'DAY', isNegativeOffset: true }] },
|
||||
],
|
||||
booleanOperator: 'AND',
|
||||
orderBys: [{ fieldName: 'lastName', isAscending: true }],
|
||||
skip: 0,
|
||||
limit: 25,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Processes
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QJobStarted` | `processes.ts` | Initial response after submitting a process step; provides `processUUID` and `jobUUID` for subsequent status polling. |
|
||||
| `QJobRunning` | `processes.ts` | Intermediate polling response while a job executes asynchronously; provides a `message` and optional `current`/`total` progress counts for the progress bar. |
|
||||
| `QJobComplete` | `processes.ts` | Terminal success response; carries output `values`, optional `nextStep`/`backStep` navigation hints, and any `processMetaDataAdjustment` that modifies remaining wizard steps. |
|
||||
| `QJobError` | `processes.ts` | Terminal failure response; provides a technical `error` string and an optional `userFacingError` message for display in the wizard UI. |
|
||||
| `QJobResponse` | `processes.ts` | Discriminated union of `QJobStarted \| QJobRunning \| QJobComplete \| QJobError`. Narrow by checking for the discriminating property (`jobUUID`, `message`, `values`, or `error`). |
|
||||
| `ProcessMetaDataAdjustment` | `processes.ts` | Runtime modifications to the process wizard applied after a step completes: `addedSteps`, `removedSteps`, and partial `modifiedFields` overrides. |
|
||||
|
||||
### QJobResponse narrowing example
|
||||
|
||||
```typescript
|
||||
function handleJobResponse(response: QJobResponse) {
|
||||
if ('error' in response) {
|
||||
// QJobError
|
||||
showToast(response.userFacingError ?? response.error)
|
||||
} else if ('values' in response) {
|
||||
// QJobComplete
|
||||
advanceToStep(response.nextStep)
|
||||
} else if ('message' in response) {
|
||||
// QJobRunning — update progress bar
|
||||
setProgress({ message: response.message, current: response.current, total: response.total })
|
||||
} else {
|
||||
// QJobStarted — store jobUUID and start polling
|
||||
setJobUUID(response.jobUUID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Widgets
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `WidgetData` | `widgets.ts` | Base shape for all widget data payloads; the `type` string is the discriminant used to select the correct renderer. |
|
||||
| `ChartWidgetData` | `widgets.ts` | Recharts widget payload: `chartType` (`line`/`bar`/`pie`/`area`), `labels` array, and `datasets` array of `ChartDataset` series. |
|
||||
| `ChartDataset` | `widgets.ts` | A single Recharts data series: human-readable `label`, ordered `data` values aligned with the parent chart's `labels`, and optional `color`. |
|
||||
| `StatisticsWidgetData` | `widgets.ts` | KPI tile payload: headline `title`, `value`, optional `unit`, and optional `trend` object with direction (`up`/`down`/`flat`), numeric magnitude, and period label. |
|
||||
| `HtmlWidgetData` | `widgets.ts` | Raw HTML widget payload; the `html` string **must** be sanitized with DOMPurify before use in `dangerouslySetInnerHTML`. |
|
||||
| `RecordGridWidgetData` | `widgets.ts` | Tabular record list widget: `tableName`, optional `columns` (field name strings) or `fields` (full `QFieldMetaData[]`), `records` array, and optional `totalCount`. |
|
||||
| `BlockData` | `widgets.ts` | Discriminated union of all block element types renderable inside a `BlockWidgetData`: `text`, `big_number`, `up_or_down`, `progress`, `button`, `icon`, `image`, `audio`, `divider`, `input`, `html`. |
|
||||
| `BlockWidgetData` | `widgets.ts` | Ordered heterogeneous block widget: a `blocks` array of `BlockData` variants and an optional `layout` (`vertical`/`horizontal`/`grid`). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Enums
|
||||
|
||||
| Type | Source file | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `QFieldType` | `enums.ts` | String-union of all primitive field data types: `STRING`, `INTEGER`, `LONG`, `DECIMAL`, `BOOLEAN`, `DATE`, `TIME`, `DATE_TIME`, `TEXT`, `HTML`, `PASSWORD`, `BLOB`. Determines which renderer, input component, and Zod validator to use. |
|
||||
| `QCriteriaOperator` | `enums.ts` | String-union of all filter comparison operators used in `QFilterCriteria.operator`: `EQUALS`, `NOT_EQUALS`, `IN`, `NOT_IN`, `CONTAINS`, `STARTS_WITH`, `LESS_THAN`, `GREATER_THAN`, `IS_BLANK`, `IS_NOT_BLANK`, `BETWEEN`, and more. |
|
||||
| `Capability` | `enums.ts` | String-union of backend-declared table capabilities (`TABLE_QUERY`, `TABLE_GET`, `TABLE_COUNT`, `TABLE_INSERT`, `TABLE_UPDATE`, `TABLE_DELETE`) that gate UI elements such as Create, Edit, and Delete buttons. |
|
||||
| `QAppNodeType` | `enums.ts` | String-union identifying the entity kind of a sidebar navigation node: `TABLE`, `PROCESS`, `REPORT`, `APP`. Drives route prefix selection in `QAppTreeNode`. |
|
||||
| `QComponentType` | `enums.ts` | String-union of all UI component types renderable inside a process step: `EDIT_FORM`, `VIEW_FORM`, `RECORD_LIST`, `VALIDATION_REVIEW_SCREEN`, `BULK_LOAD_FILE_MAPPING_FORM`, `PROCESS_SUMMARY_RESULTS`, `HTML`, `WIDGET`, and more. |
|
||||
| `AdornmentType` | `enums.ts` | String-union of all field adornment identifiers (`LINK`, `CHIP`, `SIZE`, `ERROR`, `RENDER_HTML`, `REVEAL`, `CODE_EDITOR`, `FILE_DOWNLOAD`, `FILE_UPLOAD`, `TOOLTIP`). Mirrors the discriminant property of the `FieldAdornment` union for use in switch statements and type-guard utilities. |
|
||||
|
||||
---
|
||||
|
||||
## Quick-Reference Import Cheat Sheet
|
||||
|
||||
```typescript
|
||||
// App & auth
|
||||
import type { QInstance, QAuthenticationMetaData, QBrandingMetaData, Banner } from '@/types'
|
||||
|
||||
// Metadata
|
||||
import type {
|
||||
QTableMetaData, QFieldMetaData, QProcessMetaData,
|
||||
QFrontendStepMetaData, QWidgetMetaData, FieldAdornment,
|
||||
} from '@/types'
|
||||
|
||||
// Records & queries
|
||||
import type { QRecord, QPossibleValue, QQueryFilter, QFilterCriteria } from '@/types'
|
||||
|
||||
// Processes
|
||||
import type { QJobResponse, QJobComplete, QJobError, ProcessMetaDataAdjustment } from '@/types'
|
||||
|
||||
// Widgets
|
||||
import type { WidgetData, ChartWidgetData, StatisticsWidgetData, BlockData } from '@/types'
|
||||
|
||||
// Enums
|
||||
import type { QFieldType, QCriteriaOperator, Capability } from '@/types'
|
||||
```
|
||||
@@ -0,0 +1,125 @@
|
||||
# Browser matrix — real-backend acceptance (QRun-IO/qqq#649)
|
||||
|
||||
The acceptance suite runs against the owned sample backend and the production static
|
||||
export (javalin mode). The default gate runs Chromium only; the documented matrix is the
|
||||
four configured Playwright projects below, in one run and one gate.
|
||||
|
||||
```bash
|
||||
export QQQ_SAMPLE_JAR=/path/to/qqq-sample-project-<version>-jar-with-dependencies.jar
|
||||
# full gate, all four projects, fresh static export
|
||||
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile node scripts/acceptance.mjs
|
||||
# the same with explicit ports (use distinct ports for concurrent runs)
|
||||
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile \
|
||||
QQQ_ACCEPTANCE_BACKEND_PORT=18793 QQQ_ACCEPTANCE_FRONTEND_PORT=13793 \
|
||||
node scripts/acceptance.mjs
|
||||
# one project, reusing out/ (a filtered run is a partial gate)
|
||||
QQQ_ACCEPTANCE_BROWSERS=mobile node scripts/acceptance.mjs --skip-build
|
||||
```
|
||||
|
||||
| Project | Playwright device | Engine (Playwright 1.58.2) | Runs |
|
||||
|----------|-------------------|----------------------------|------|
|
||||
| chromium | Desktop Chrome | Chrome for Testing 145 | every spec |
|
||||
| firefox | Desktop Firefox | Firefox 146 | every spec |
|
||||
| webkit | Desktop Safari | WebKit 26.0 | every spec |
|
||||
| mobile | Pixel 7 (412 px, touch) | Chromium | tests tagged `@mobile` |
|
||||
|
||||
## Phone scope
|
||||
|
||||
Below 768 px the UI is a different layout by design: lists are card lists, record actions
|
||||
live in an action sheet, navigation is a drawer. Specs that assert the desktop layout
|
||||
(grid columns and cells, column resizing, grid keyboard navigation, desktop menus) do not
|
||||
describe phone behavior, and skipping them is not allowed (skips fail the gate). The
|
||||
`mobile` project therefore runs only tests whose title carries `@mobile`
|
||||
(`grep: /@mobile/` in `tests/acceptance/playwright.config.ts`). Those tests run on the
|
||||
desktop projects as well, either layout-neutral or pinned to a 412 px touch viewport, so
|
||||
every phone scenario is also checked in Firefox and WebKit at phone width.
|
||||
|
||||
The phone-relevant rows and their `@mobile` tests:
|
||||
|
||||
| Phone behavior | Tests |
|
||||
|----------------|-------|
|
||||
| Navigation drawer | NAV-026, INT-008 |
|
||||
| List / card view, loading state | INT-008, INT-009 (busy placeholder cards, QRun-IO/qqq#694) |
|
||||
| Record view | INT-008, INT-003 |
|
||||
| Forms and validation | INT-008, INT-007, PRC-009 |
|
||||
| Process run | PRC-001, PRC-004, PRC-009 |
|
||||
| Dialogs and focus (action sheet, delete dialog) | INT-003 (QRun-IO/qqq#694) |
|
||||
| Sign-in, logout, session expiry | SEC-020, SEC-021, SEC-022 (both) |
|
||||
| Accessibility scan at phone width | INT-005 |
|
||||
|
||||
The gate also fails any configured project that ran no tests.
|
||||
|
||||
## Results
|
||||
|
||||
Recorded 2026-09-25 on macOS (Darwin 27), sample `4.1.0-SNAPSHOT` with the #649 backend
|
||||
changes including QRun-IO/qqq#674 and #675, Next static export of
|
||||
`feature/GH-649-next-acceptance` with all six areas integrated (navigation, records, query,
|
||||
processes, widgets, security), one run of the full gate with the four projects:
|
||||
|
||||
Tests passed / run, per spec area and project (the mobile project runs the `@mobile` tests only):
|
||||
|
||||
| Area | chromium | firefox | webkit | mobile |
|
||||
|---|---|---|---|---|
|
||||
| navigation | 41/41 | 41/41 | 41/41 | 1/1 |
|
||||
| processes | 50/50 | 50/50 | 50/50 | 3/3 |
|
||||
| query | 50/50 | 50/50 | 50/50 | — |
|
||||
| records | 48/48 | 48/48 | 48/48 | — |
|
||||
| security | 57/57 | 57/57 | 57/57 | 9/9 |
|
||||
| widgets | 80/80 | 80/80 | 80/80 | — |
|
||||
| **total** | **326/326** | **326/326** | **326/326** | **13/13** |
|
||||
|
||||
`—`: the area has no `@mobile` test; the phone list, record and form behavior is proven by
|
||||
the interaction tests (INT-003, INT-007, INT-008, INT-009) in the security area.
|
||||
|
||||
Gate (`test-results/acceptance/gate.json`, 991 tests, 18.1 min, retries 0, commit `2c5eb2d`):
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": { "passed": 289, "failed": 0, "missing": 0, "excluded": 3 },
|
||||
"byProject": {
|
||||
"chromium": { "passed": 326, "failed": 0, "skipped": 0, "flaky": 0 },
|
||||
"firefox": { "passed": 326, "failed": 0, "skipped": 0, "flaky": 0 },
|
||||
"webkit": { "passed": 326, "failed": 0, "skipped": 0, "flaky": 0 },
|
||||
"mobile": { "passed": 13, "failed": 0, "skipped": 0, "flaky": 0 }
|
||||
},
|
||||
"problems": []
|
||||
}
|
||||
```
|
||||
|
||||
Every required row passes in every project that runs it. PRC-039, WID-033 and SEC-030 are
|
||||
`required: false` pending approval and are reported as excluded.
|
||||
|
||||
## Differences found between engines
|
||||
|
||||
Fixed:
|
||||
|
||||
- **WebKit, aborted prefetches.** WebKit raises "<url> due to access control checks." as
|
||||
a page error for same-origin Next.js route prefetches and RSC payload fetches that a
|
||||
document navigation cancels; the server answered 200 and Playwright never sees the
|
||||
request. Traces confirmed every report named a `/app/...` route, `__next.*.txt` or
|
||||
`?_rsc=` payload within a millisecond of a main-frame navigation. The shared diagnostics
|
||||
fixture ignores exactly these (same origin, Next route or payload URL, within one second
|
||||
of a navigation) and lists them under `interruptedFetches`; API, cross-origin and
|
||||
unrelated access-control failures still fail the test.
|
||||
- **WebKit, document title.** Next.js re-applies the static metadata title ("QQQ Admin")
|
||||
after client navigations, in WebKit after the layout set the metadata title (NAV-023,
|
||||
NAV-006). `useDocumentTitle` keeps the breadcrumb title in place.
|
||||
- **Firefox, rich text.** Firefox stores a typed space before a formatting change as
|
||||
` `; the editor now stores engine-neutral HTML (REC-020).
|
||||
- **Firefox/WebKit, computed styles.** A `CSSStyleDeclaration` returned from `evaluate()`
|
||||
only serializes in Chromium; WID-057 reads the properties inside the page.
|
||||
- **WebKit keyboard order.** WebKit, like Safari, moves Tab only between form fields;
|
||||
keyboard specs use Option+Tab there (`tabKey` in `specs/security/support/ui.ts`).
|
||||
- **Aborted requests.** A request aborted by the test is reported as `net::ERR_FAILED`
|
||||
(Chromium), `NS_ERROR_FAILURE` (Firefox) or "Blocked by Web Inspector" (WebKit).
|
||||
- **Provider logout in Firefox.** Starting an in-app navigation just before leaving for
|
||||
the identity provider made Firefox log "Failed to fetch RSC payload". Provider logout and
|
||||
sign-in retries now leave the page without a client navigation first.
|
||||
- **Set-Cookie visibility in WebKit.** WebKit hides `Set-Cookie` from page responses, so
|
||||
SEC-033 checks the logout response from Node.
|
||||
- **Phone defects (QRun-IO/qqq#694).** The card list showed "No records found" while
|
||||
loading; it now shows busy placeholder cards. The record action sheet moves focus in on
|
||||
open, closes on Escape, and returns focus to its trigger, also when a dialog opened from
|
||||
one of its items closes.
|
||||
|
||||
Open: none.
|
||||
@@ -0,0 +1,331 @@
|
||||
# Next UI feature acceptance matrix
|
||||
|
||||
Generated by `node scripts/acceptance-matrix-md.mjs` from `tests/acceptance/matrix/*.json` and the
|
||||
acceptance gate results. Each row is a supported QQQ UI feature or use case with executable
|
||||
real-backend acceptance coverage (QRun-IO/qqq#649). See `tests/acceptance/README.md` to run it.
|
||||
|
||||
Rows: 292. Required rows passing in the recorded runs: 289.
|
||||
|
||||
Recorded runs: `test-results/acceptance/gate.json` (2026-09-25T20:52:12.358Z).
|
||||
|
||||
## Navigation, application structure, branding and icons
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|---|---|---|---|---|---|---|
|
||||
| NAV-001 | Direct link to a table list renders seeded rows | success | | prime-test-database.sql | pass (chromium, firefox, webkit) | |
|
||||
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under their parent | admin sees Miscellaneous, Nav Empty App, Nav Level One, People App, Sample Widgets Dashboard, Sharing Demo in backend order; Greetings App appears only under People App (regression: it was duplicated at top level); group children use metadata labels | noPets persona: Pet and Pet Note are absent from the sidebar; backend returns 404 for their metadata and 403 for a query | stock sample apps + NavigationFixtures (Nav Level One, Nav Empty App); persona noPets | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
|
||||
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | expand three nested groups and open Nav Deep Item; grid rows match nav_deep_item; deep link to /app/navDeepItem expands all three groups and marks the item current | | NavigationFixtures: navLevelOne > navLevelTwo > navLevelThree > navDeepItem | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
|
||||
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | Dashboard is current on /app; click app, table, process; URL, content and aria-current follow; collapse hides children, expand shows them | | stock sample (Miscellaneous app) | pass (chromium, firefox, webkit) | |
|
||||
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | sidebar glyph per declared Material name, color applied, image path loads; app home entries and section headings show declared icons; command palette entries show declared icons | no declared icon renders the type fallback glyph | sample icons (stars, person, widgets, share, pets, local_shipping, badge) + NavigationFixtures (path icon, colored icon, inventory_2, inbox, layers) | pass (chromium, firefox, webkit) | [#538](https://github.com/QRun-IO/qqq/issues/538) |
|
||||
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access show not-found and the backend denies them | sidebar, app home, landing page and command palette omit City, Greet People, Simple Sleep and Nav Hidden Note | /app/city and /app/greet show the not-found state; backend returns 404 and 403 | sample city (hidden table), greet and simpleSleep (hidden processes); NavigationFixtures navHiddenNote (hidden, NOT_PROTECTED) | pass (chromium, firefox, webkit) | |
|
||||
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | /app/navHiddenNote lists nav_hidden_note rows; breadcrumbs show its enclosing apps | | NavigationFixtures navHiddenNote (isHidden, PermissionLevel.NOT_PROTECTED) | pass (chromium, firefox, webkit) | |
|
||||
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each entry navigates | Miscellaneous: Actions Sleep Interactive, Simple Throw; Data Carrier, Field Lab, Pet Species (labels, not names); section heading shows its label and declared icon; clicking an entry opens the table or process | hidden or unpermitted section entries are omitted (navHiddenNote, noPets persona) | sample greetingsApp and miscellaneous sections | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
|
||||
| NAV-009 | App home table entries show the backend record count | each Data entry reads 'N total records' equal to the SQL count | | sample carrier, fieldLab, petSpecies, person, pet, petNote rows | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
|
||||
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | People App shows Clone People and the Greetings App child app (regression: crashed on missing widgets); Nav Level One lists its child app and navigates to it; Nav Empty App shows the empty state | viewer persona: People App shows only the Greetings App child app (no denied process) | sample peopleApp; NavigationFixtures navLevelOne, navEmptyApp; persona viewer | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
|
||||
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared widget | sidebar click opens the dashboard; one widget region per declared widget label, in order | | sample SampleWidgetsDashboard (11 widgets) | pass (chromium, firefox, webkit) | |
|
||||
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding accepted without warnings | sidebar shows /samples-logo.png with alt QQQ Sample, loaded, linking to the dashboard; favicon link points to /kr-icon.png; primary color variables equal the branding accent color; no 'schema validation' console warning for branding without companyName/companyUrl | | sample branding (appName, logo /samples-logo.png, icon /kr-icon.png) + NavigationFixtures accentColor #1d4ed8 | pass (chromium, firefox, webkit) | [#539](https://github.com/QRun-IO/qqq/issues/539) |
|
||||
| NAV-013 | Branding banners render in their slots (top of site, top of body, side nav under logo) with text or sanitized HTML, severity and colors | each slot shows its message in the right place with severity and colors from metadata | unknown slots are not rendered | NavigationFixtures banners: QFMD_TOP_OF_SITE (INFO text), QFMD_TOP_OF_BODY (WARNING HTML), QFMD_SIDE_NAV_UNDER_LOGO (SUCCESS, colors, additional styles) | pass (chromium, firefox, webkit) | [#539](https://github.com/QRun-IO/qqq/issues/539) |
|
||||
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb navigates | /app/person: People App / Greetings App / Person; /app/navDeepItem/2/edit: Nav Level One / Nav Level Two / Nav Level Three / Nav Deep Item / 2 / Edit; process and create pages; clicking a crumb opens that app home | no breadcrumbs on the dashboard root | sample person/greetInteractive; NavigationFixtures navDeepItem | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
|
||||
| NAV-015 | Document title names the page, its enclosing apps and the application (Material parity) | Dashboard \| QQQ Sample; Person \| Greetings App \| People App \| QQQ Sample; record label title on the record page | unknown route title is Not Found \| QQQ Sample | stock sample | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
|
||||
| NAV-016 | The site root redirects to the dashboard | / replaces itself with /app (back does not return to /) | | stock sample | pass (chromium, firefox, webkit) | |
|
||||
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata by label | counts equal the navigable tables, processes, apps and their widgets from backend metadata; quick actions create only navigable insertable tables and run only navigable processes, by label (regression: listed internal and table-bulk processes, crashed on apps without widgets); application cards open app homes | viewer persona: no create or process quick actions | stock sample + NavigationFixtures; persona viewer | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
|
||||
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | app home, nested app home, table, process, record view, record edit, create, saved view | | stock sample + NavigationFixtures | pass (chromium, firefox, webkit) | |
|
||||
| NAV-019 | Browser refresh keeps the route, its query string and the rendered content | refresh on a table with a query string, a record view, and an app home | | stock sample | pass (chromium, firefox, webkit) | |
|
||||
| NAV-020 | Browser back and forward traverse navigation history with the right content | dashboard > app home > table > record, then back three times and forward twice | | stock sample | pass (chromium, firefox, webkit) | |
|
||||
| NAV-021 | An unknown /app/{name} shows an in-shell not-found state with a link to the dashboard | /app/noSuchThing shows 'Page not found' naming the segment; the link opens the dashboard; no failed requests | | stock sample | pass (chromium, firefox, webkit) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
|
||||
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | /app/person/1/no/such and /no-such-page respond 404 and render the 404 page; Go Home reaches the dashboard | only the document 404 is reported | stock sample | pass (chromium, firefox, webkit) | |
|
||||
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | ?code=NAV-B2 opens record 2 (Tide Chart); ?email=... opens that person | no match, several matches (?shelf=North), unknown field, no parameters: error message, no navigation | NavigationFixtures navDeepItem (unique key code); sample person emails | pass (chromium, firefox, webkit) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
|
||||
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context, filters, and navigates by keyboard | filter 'pet' shows Pet, Pet Note, Pet Species with type and app; Enter opens the first; '.' opens the palette; Escape closes it | hidden objects are not listed | stock sample + NavigationFixtures | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
|
||||
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records without a backend search endpoint | typing a label lists matching pages with app context; choosing one navigates; a viewed record is offered as a recent record; Enter without a selection opens /app/search?q= with the matches, surviving refresh | no POST /qqq/v1/search request is made (regression: every keystroke produced a 404) | stock sample | pass (chromium, firefox, webkit) | [#653](https://github.com/QRun-IO/qqq/issues/653) |
|
||||
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | hamburger opens the drawer; choosing a table navigates and closes it; close button closes it | | stock sample, 390x844 viewport | pass (chromium, firefox, mobile, webkit) | |
|
||||
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | metadata counts equal the backend's; framework shows the bundled Next.js version (regression: hardcoded 'Next.js 15') | | stock sample | pass (chromium, firefox, webkit) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
|
||||
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report page | sidebar entry with its icon opens /app/navDeepItemReport with the report heading, title and breadcrumbs; app home Reports group lists and opens it | | NavigationFixtures navDeepItemReport (child of Nav Level Three, icon assessment) | pass (chromium, firefox, webkit) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
|
||||
| NAV-029 | A user who may access no apps sees the Dashboard entry and the no-apps message (Material NoApps parity) | sidebar shows only Dashboard; landing shows You do not have permission to access any apps. | app home links show the not-found state | harness persona noApps (every permission except App HAS_ACCESS) - requested: not yet in AcceptanceSampleServer | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
|
||||
|
||||
## Processes: steps, components, lifecycle, bulk load/edit/delete
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|---|---|---|---|---|---|---|
|
||||
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list results render | recordIds 1,2 -> setup prefix/suffix -> results view values and both greeted rows | | sample greetInteractive | pass (chromium, firefox, mobile, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) [#656](https://github.com/QRun-IO/qqq/issues/656) |
|
||||
| PRC-002 | All-records run from a filter selection with no criteria | filterJSON {criteria:[]} greets every seeded person (count checked against SQL) | | sample greetInteractive | pass (chromium, firefox, webkit) | |
|
||||
| PRC-003 | Zero-record runs from a filter that matches nothing | clonePeople input 0, validation on 0 with no lines, 0 processed | greetInteractive record-list step with zero records is refused ("Missing input records.") | sample greetInteractive, clonePeople | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
|
||||
| PRC-004 | Processes launched from an app (no records) | app shortcut opens Component Lab without records | greetInteractive without records is refused at init ("Missing input records.") | prcLab app, sample greetInteractive | pass (chromium, firefox, mobile, webkit) | |
|
||||
| PRC-005 | Process launched from selected rows of a table query | select Avery and Casey, Actions -> Greet Interactive -> both greeted | | sample person + greetInteractive | pass (chromium, firefox, webkit) | |
|
||||
| PRC-006 | Minimum and maximum input records | 1-2 selected records run and are listed | 0 or 3 explicit records show a message without init; filter selecting 5 shows the backend refusal; API filter selecting 0 is refused | prcBounds (min 1, max 2) | pass (chromium, firefox, webkit) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
|
||||
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | HELP_TEXT, HELP_TEXT, VIEW_FORM, EDIT_FORM, HTML, EDIT_FORM in order | | prcComponents mixed screen | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
|
||||
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | plain lines; Show/Hide preview text | | prcComponents | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
|
||||
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | Lab Inputs section holds only its fields; values saved (SQL prc_lab_run) | | prcComponents | pass (chromium, firefox, mobile, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
|
||||
| PRC-010 | Required input validation on screens and in the backend | filled required field proceeds | empty required field blocks without a request; API step without the required input is refused | prcComponents | pass (chromium, firefox, webkit) | |
|
||||
| PRC-011 | Process possible-value fields | options from the process route; chosen id saved, label shown on review; search filters options | | prcComponents labColor (prcColor enum) | pass (chromium, firefox, webkit) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
|
||||
| PRC-012 | VIEW_FORM labels and backend values | Introduction and Status values | | prcComponents | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
|
||||
| PRC-013 | HTML component renders the step html value, sanitized | markup rendered | script and onerror removed, never executed | prcComponents mixed.html | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
|
||||
| PRC-014 | DOWNLOAD_FORM delivers the generated file | download bytes equal the generated file | unregistered file path refused (403) | prcComponents compute step | pass (chromium, firefox, webkit) | |
|
||||
| PRC-015 | Step help content for process screens | help text shown | | prcComponents helpContent (PROCESS_SCREEN) | pass (chromium, firefox, webkit) | |
|
||||
| PRC-016 | Back to the backend back step with entered values | Back restarts at the back step with values (possible-value label shown); resubmitting saves again | no Back without a backend backStep | prcComponents review, prcWizard longDetails, greetInteractive | pass (chromium, firefox, webkit) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#667](https://github.com/QRun-IO/qqq/issues/667) |
|
||||
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | short route drops a screen from the stepper; values logged (SQL) | | prcWizard | pass (chromium, firefox, webkit) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
|
||||
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | field relabelled and required on the later screen | empty relabelled required field blocks | prcWizard long route | pass (chromium, firefox, webkit) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
|
||||
| PRC-019 | Async job progress (message, N of M, progress bar, updated time) then continue | RUNNING progress shown while polling; finished screen and SQL log | | prcProgress | pass (chromium, firefox, webkit) | [#657](https://github.com/QRun-IO/qqq/issues/657) |
|
||||
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | confirm dialog; cancel step row (SQL) from a screen and while working | Stay on Page keeps the run | prcProgress cancelStep | pass (chromium, firefox, webkit) | |
|
||||
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | user-facing message; internal detail toggle; simpleThrow "I always throw."; Close returns to the app | | prcFailures, prcComponents, sample simpleThrow | pass (chromium, firefox, webkit) | |
|
||||
| PRC-022 | Retry after an error restarts with the original record selection | retry loads the selected records 2 and 4 | | prcFlaky (fails once) | pass (chromium, firefox, webkit) | [#658](https://github.com/QRun-IO/qqq/issues/658) |
|
||||
| PRC-023 | Completion: Next/Submit labels, Return on the last screen or when noMoreSteps | Submit on the step before the last screen; Return only on the last screen; noMoreSteps ends early | | prcComponents, prcWizard, prcEarlyFinish | pass (chromium, firefox, webkit) | |
|
||||
| PRC-024 | Linear stepper and step position | all step labels; current step marked; Step i of n | | prcComponents | pass (chromium, firefox, webkit) | |
|
||||
| PRC-025 | RECORD_LIST paging and page size over process records | 1-10, 11-20, 21-23; page size 25 | next disabled on the last page | prcManyRows (23 generated records) | pass (chromium, firefox, webkit) | |
|
||||
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | Input: 2 Person records.; radio switches the button label; preview 1 of 2 / 2 of 2 | | sample clonePeople | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
|
||||
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | four lines with counts and messages; link filters to the refused record | nothing persisted by validation | sample clonePeople + inserted clone persons | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
|
||||
| PRC-028 | Process summary results after execution and persisted effects | result lines and processed count; clones persisted (SQL); skip validation executes immediately | refused and nested records not cloned | sample clonePeople | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
|
||||
| PRC-029 | Bulk edit: switched-on fields only, review and result | edited fields persisted for selected records only (SQL); result lines | no switched-on field blocks without a request | enricher person.bulkEdit | pass (chromium, firefox, webkit) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#659](https://github.com/QRun-IO/qqq/issues/659) |
|
||||
| PRC-030 | Bulk delete: review, validation and result | selected records removed (SQL) | validation deletes nothing | enricher person.bulkDelete | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
|
||||
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | uploaded CSV mapped and inserted (SQL) | missing required file blocks | enricher person.bulkInsert + prcUploadArchive | pass (chromium, firefox, webkit) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#660](https://github.com/QRun-IO/qqq/issues/660) [#543](https://github.com/QRun-IO/qqq/issues/543) |
|
||||
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | Back from review keeps the mapping; Back from mapping to upload | unmapped required column blocks without a request | enricher person.bulkInsert | pass (chromium, firefox, webkit) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#660](https://github.com/QRun-IO/qqq/issues/660) |
|
||||
| PRC-033 | Bulk edit with a file matched by key fields | rows matched by Id updated (SQL) | | enricher person.bulkEditWithFile | pass (chromium, firefox, webkit) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#660](https://github.com/QRun-IO/qqq/issues/660) |
|
||||
| PRC-034 | Upload instructions HTML with downloadable template | template CSV downloads with the header row | | enricher person.bulkInsert upload.html | pass (chromium, firefox, webkit) | |
|
||||
| PRC-035 | Named WIDGET fetched with the process UUID and values | widget text built from process values | | prcWidgets prcHtmlWidget | pass (chromium, firefox, webkit) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
|
||||
| PRC-036 | Named composite WIDGET seeded from process values | seeded blocks rendered, no fetch | | prcWidgets prcCompositeWidget | pass (chromium, firefox, webkit) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
|
||||
| PRC-037 | Ad hoc composite WIDGET: interpolation, conditional blocks, action buttons, submit-on-enter inputs | Approve submits actionCode (SQL); Enter submits the scan code (SQL) | conditional block hidden | prcWidgets ad hoc blocks | pass (chromium, firefox, webkit) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
|
||||
| PRC-038 | GOOGLE_DRIVE_SELECT_FOLDER screen keeps its other components working | help text and edit form submit (SQL); picker explains it is not configured | required export note blocks | prcDrive | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
|
||||
| PRC-039 | Google Drive folder selection through the Google Picker | pick a folder and submit its id | | needs GOOGLE_APP_CLIENT_ID, GOOGLE_APP_API_KEY and a Google account | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): Google Drive folder picker needs a real Google OAuth client and account; the rest of the step is covered by PRC-038; real-service testing is follow-up work.) | |
|
||||
| PRC-040 | Reload mid-process starts a fresh run | new process UUID, first screen, empty inputs; earlier effects kept (SQL) | | prcComponents | pass (chromium, firefox, webkit) | |
|
||||
| PRC-041 | Expired session mid-process returns to sign-in | 401 redirects to login | nothing saved | prcComponents + expired persona | pass (chromium, firefox, webkit) | [#668](https://github.com/QRun-IO/qqq/issues/668) |
|
||||
| PRC-042 | Process permission enforcement | revoked permission shows the permission message | step refused, nothing saved; direct link offers no process; API init refused (403) | prcComponents + noProcesses persona | pass (chromium, firefox, webkit) | |
|
||||
| PRC-043 | Default process values from the link (defaultProcessValues) | sleepMillis preset drives an async sleep to screen1; labName prefilled | | sample sleepInteractive, prcComponents | pass (chromium, firefox, webkit) | |
|
||||
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | auto-focused input submits on Enter (SQL) | no Next/Cancel buttons rendered | prcScanner (format scanner) | pass (chromium, firefox, webkit) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
|
||||
| PRC-045 | State-machine step flow: repeated screens, no linear stepper | screen repeats with fresh inputs and counted rounds; noMoreSteps ends with Return | no stepper or Step i of n | prcLoop (STATE_MACHINE) | pass (chromium, firefox, webkit) | |
|
||||
| PRC-046 | Saved bulk load profiles: save, choose for a later load, delete | Save As stores the mapping (SQL); choosing the profile restores the mapping and is named on review; Delete removes it (SQL) | a duplicate name is refused | SavedBulkLoadProfileMetaDataProvider on owned saved_bulk_load_profile tables | pass (chromium, firefox, webkit) | [#660](https://github.com/QRun-IO/qqq/issues/660) |
|
||||
| PRC-047 | A run with no frontend screens completes with a completion screen | completion heading and message; backend effect persisted (SQL) | | prcQuickTask (backend step only) | pass (chromium, firefox, webkit) | |
|
||||
|
||||
## Querying, relationships, saved views, export and bulk selection
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|---|---|---|---|---|---|---|
|
||||
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | Headers use field labels; Rows equal SQL select ordered by id desc; Possible-value cells show labels, booleans Yes/No, money/decimals formatted | Null boolean renders empty, not No; Hidden and heavy (TEXT) fields are not columns | QueryFixtures.java qryItem (8 rows, every field type) | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) [#691](https://github.com/QRun-IO/qqq/issues/691) |
|
||||
| QRY-002 | Pagination with Material page sizes and URL state | Page 2 shows the remaining rows; Page sizes 10/25/50/100/250; Page and size survive reload via URL | Next is disabled on the last page | sample (person, pet, petNote, carrier, petSpecies) carrier (11 rows) | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
|
||||
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | Ascending and descending match SQL order by; Sort persists across reload | Third click returns to the PK-descending default | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
|
||||
| QRY-004 | Column visibility and order persist per table | Hide/show and reorder columns; Order and visibility survive reload; Show/hide all per table group | Another table keeps its own columns | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | |
|
||||
| QRY-005 | Column widths, density and card view | Resized width persists; Density changes row height; Card view shows the same records | Keyboard resize separator exposes its value | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | |
|
||||
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | Filter, sort and page are restored after Back; Refresh shows a row changed through the API | No stale cached rows after refresh | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | |
|
||||
| QRY-007 | A failed query reports the backend error with a retry | Backend error text shown; Retry reloads after the failure clears | No rows from the failed request are shown | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | |
|
||||
| QRY-010 | Text operators match Material labels and backend semantics | equals, does not equal, contains, starts/ends with, is any of, is like, is empty/not empty; results equal SQL | does not equal includes empty values (NOT_EQUALS_OR_IS_NULL); Incomplete criteria are not sent | QueryFixtures.java qryItem.name/code | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
|
||||
| QRY-011 | Number operators including ranges and lists | equals, greater/less (or equal), is between, is any of; results equal SQL; Values sent as numbers | not between and none of exclude the bounds/list | QueryFixtures.java qryItem.quantity/price | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
|
||||
| QRY-012 | Date operators and relative date expressions | is/after/before/between with dates; today, N days ago, start of this/last month send backend expression JSON and match SQL | Clearing an expression restores a plain value input | QueryFixtures.java qryItem.receivedDate (relative to today) | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
|
||||
| QRY-013 | Date-time operators convert local input to UTC and support relative values | Local input sent as UTC instant; now and N hours ago expressions match SQL | Grid shows formatted date-times, not raw ISO | QueryFixtures.java qryItem.checkedAt | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) [#679](https://github.com/QRun-IO/qqq/issues/679) |
|
||||
| QRY-014 | Boolean, blob and long-text operators | equals yes / equals no / is empty for booleans; blob is empty / not empty; long text contains | Blob offers no value operators | QueryFixtures.java qryItem.isActive/photo/notes | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
|
||||
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | is / is any of / is none of with label chips; Link-loaded ids render labels; Results equal SQL | Search with no match shows no options | QueryFixtures.java qryItem.ownerId (person), speciesId (petSpecies enum) | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) [#667](https://github.com/QRun-IO/qqq/issues/667) |
|
||||
| QRY-016 | AND/OR and nested groups combine as declared | AND group, OR group and nested subfilter match SQL | Removing the last criterion clears the filter | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
|
||||
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | Reload keeps the filter; Back/forward switches filters; Plain JSON filter link opens filtered | Malformed filter param falls back to no filter | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
|
||||
| QRY-018 | Backend-only operators from links render and filter correctly | Each operator from a link renders its label and returns the SQL rows | FALSE matches no records; builder does not crash | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
|
||||
| QRY-019 | Quick search combines with the advanced filter | Search term ANDs with Is Active = yes | Clearing search restores the filtered rows | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
|
||||
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | Join column 'Owner: First Name' hidden by default, shown on demand; Join filter and sort match SQL LEFT JOIN | Joins only sent when used; rows without a match are kept | QueryFixtures.java qryItem -> person (MANY_TO_ONE) | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) [#684](https://github.com/QRun-IO/qqq/issues/684) |
|
||||
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | Repeated rows per note; Count shows (N distinct) | Row selection dedupes repeated ids | QueryFixtures.java qryItem -> qryItemNote (ONE_TO_MANY) | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
|
||||
| QRY-022 | Column statistics aggregate the filtered rows | Value distribution and count/distinct/sum/avg/min/max equal SQL under the filter | Sort by value/count | QueryFixtures.java qryItem (QUERY_STATS) | pass (chromium, firefox, webkit) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
|
||||
| QRY-023 | Tables without QUERY_STATS offer no column statistics | No statistics button on headers | Process not launched | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
|
||||
| QRY-030 | Selection menu: this page, full query result, first N, clear | Banners and counts for page, all and subset | Subset dialog rejects invalid N | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
|
||||
| QRY-031 | Bulk Edit launches with the checked record ids | recordIds param equals checked rows | No selection shows 'No records were selected to Bulk Edit.' | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
|
||||
| QRY-032 | Bulk Delete of the full query result sends the whole filter | filterJSON has no skip/limit; backend recordCount equals SQL count | Current page paging is not sent | sample (person, pet, petNote, carrier, petSpecies) carrier (page 2) | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
|
||||
| QRY-033 | First-N selection under a filter launches with that limit | filterJSON limit N with the filter and sort | N larger than the result is capped | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
|
||||
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | Bulk Load/Edit/Edit With File/Delete then table processes; Launch carries the selection | Process min/max record checks block launch with a message | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) [#662](https://github.com/QRun-IO/qqq/issues/662) |
|
||||
| QRY-035 | Viewers get no create, bulk or process actions and the backend refuses them | No Create, bulk or write process entries | Direct insert/delete API calls return 403 | viewer persona on sample (person, pet, petNote, carrier, petSpecies) | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) [#671](https://github.com/QRun-IO/qqq/issues/671) |
|
||||
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | File name, headers and rows equal SQL; Join columns exported | Hidden columns excluded | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
|
||||
| QRY-041 | XLSX and JSON exports carry the same rows | XLSX sheet and JSON array equal SQL rows | JSON keys limited to exported fields | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
|
||||
| QRY-042 | Nothing to export disables the formats | Empty filter result disables CSV/XLSX/JSON | No export request sent | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
|
||||
| QRY-043 | Backend refuses exports of unreadable tables | Export of a readable table succeeds | Export of pet returns 403 without data | noPets persona | pass (chromium, firefox, webkit) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
|
||||
| QRY-050 | Your saved views are listed and open on their own route | 'Your Saved Views' lists backend views; /savedView/{id} applies filter/columns | Empty group message | sample sharing demo (Alice People View) | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
|
||||
| QRY-051 | Saving a new view stores filter, sort, columns and page size | saved_view row viewJson matches Material RecordQueryView | Unsaved-changes indicator clears after save | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
|
||||
| QRY-052 | Update, rename and delete your view | Update keeps unrelated view JSON; Rename and delete change the saved_view row | Reset All Changes restores the stored view | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
|
||||
| QRY-053 | Duplicate names and unknown views are reported without changes | Duplicate name error shown in the dialog | Unknown view id shows an error; saved_view unchanged | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
|
||||
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | Listed under 'Views Shared with you' for bob; Opens with its filter | Owner-only actions disabled; storeSavedView by bob refused | QueryFixtures.java (Alice People View shared with sample:bob READ_ONLY) | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
|
||||
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | Picker lists /variants; Rows equal the chosen store; Choice remembered and changeable; export uses it | Filter narrows options | QueryFixtures.java qryStock on qryVariantMemory (qryStore) | pass (chromium, firefox, webkit) | [#690](https://github.com/QRun-IO/qqq/issues/690) |
|
||||
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | Prompt shown with a choose button | No query sent; API query without a variant fails | QueryFixtures.java qryStock | pass (chromium, firefox, webkit) | [#690](https://github.com/QRun-IO/qqq/issues/690) |
|
||||
| QRY-062 | A table without count, export or write capabilities offers none of them | Rows listed without a total; Export disabled with explanation; No create or bulk actions | Backend insert refused | QueryFixtures.java qryLedger (no TABLE_INSERT/UPDATE/DELETE/EXPORT/COUNT) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) [#688](https://github.com/QRun-IO/qqq/issues/688) |
|
||||
| QRY-063 | Enum-backed Pet Species table is read-only | Metadata omits write capabilities; no Create or bulk writes | Insert returns 403, not 500 | sample (person, pet, petNote, carrier, petSpecies) petSpecies | pass (chromium, firefox, webkit) | [#692](https://github.com/QRun-IO/qqq/issues/692) |
|
||||
| QRY-064 | Backend refuses count and export for a table without those capabilities | Query still succeeds | v1 count, legacy count and export return 403 | QueryFixtures.java qryLedger | pass (chromium, firefox, webkit) | [#693](https://github.com/QRun-IO/qqq/issues/693) |
|
||||
| REL-001 | Possible-value cells show the related record label and link to it | Owner cell shows the person label; Link opens /app/person/{id} | Enum values are not links | sample (person, pet, petNote, carrier, petSpecies) pet.personId | pass (chromium, firefox, webkit) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
|
||||
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | Panel rows equal SQL children; View All filters /app/pet and Back returns | Parent without children shows an empty panel | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
|
||||
| REL-003 | Adding a child from its parent links it and shows it immediately | New pet stored with person_id of the parent; Panel refreshes | Join field not editable; no other rows changed | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium, firefox, webkit) | |
|
||||
| REL-004 | Three-level associations: each pet shows only its own notes | Notes equal SQL per pet | Other pets' notes absent | sample (person, pet, petNote, carrier, petSpecies) person -> pet -> petNote | pass (chromium, firefox, webkit) | |
|
||||
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | Renamed pet shown in parent panel and list; Deleting a pet removes its notes | Other rows unchanged | sample (person, pet, petNote, carrier, petSpecies) pet, petNote | pass (chromium, firefox, webkit) | |
|
||||
| REL-006 | Denied child reads show an unavailable panel without data or actions | Panel says related records are unavailable | No data, View All or Create; API query 403 | noPets persona; qryShelter whose associated qryShelterPet is denied with DenyBehavior.DISABLED | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
|
||||
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | childRecordList and rowBuilder show SQL-matching members; Composite join on code + review date | Household without members shows empty | QueryFixtures.java qryHousehold/qryMember (members, care group / primary, scheduled reviews) | pass (chromium, firefox, webkit) | |
|
||||
| REL-008 | Adding through a composite association stores every join value | New member stored with household_code and review_date from the parent | Aliased association unaffected; other rows unchanged | QueryFixtures.java qryHousehold/qryMember | pass (chromium, firefox, webkit) | |
|
||||
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | Person and its pets removed | Other pets and notes unchanged; list reflects SQL | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium, firefox, webkit) | |
|
||||
|
||||
## Records, fields, editors, adornments, validation and persistence
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|---|---|---|---|---|---|---|
|
||||
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | label heading; T1 header fields; T2 section cards; currency, grouped integer, Yes/No, date | values match independent SQL readback | prime-test-database.sql (person 1) | pass (chromium, firefox, webkit) | |
|
||||
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | NULL date shows an em dash; hidden field absent from view and from the GET payload; hidden section not rendered | hidden value stays stored (SQL) | person 4 (NULL birth date); recordLab hiddenCode + hidden legacy section | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#680](https://github.com/QRun-IO/qqq/issues/680) |
|
||||
| REC-003 | Record view of a missing record shows Record Not Found and a way back | not-found panel; back to the table list | 404 read is the only failed request; no generic error toast | person 999 (absent) | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
| REC-004 | Record view tabs and list view expose every visible section | Overview shows all T2 sections; a section tab shows only that section; list view shows every section | | fieldLab record created in the test | pass (chromium, firefox, webkit) | |
|
||||
| REC-005 | Create form validates required fields in the browser before any request | required markers and messages for each required field | no insert request, no row | person | pass (chromium, firefox, webkit) | |
|
||||
| REC-006 | Create persists the entered values and opens the new record | success toast; navigates to /app/person/<new id>; view shows the entered values | non-editable fields are not offered on create; row matches SQL readback | person | pass (chromium, firefox, webkit) | |
|
||||
| REC-007 | Backend enforces required fields when the UI is bypassed | direct insert without Name is rejected with the backend message | no row written | fieldLab | pass (chromium, firefox, webkit) | |
|
||||
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | clean cancel returns immediately; dirty cancel asks Stay/Leave; Stay keeps typed values; Leave discards | no insert or update request; no row created or changed | person | pass (chromium, firefox, webkit) | |
|
||||
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | fields prefilled from the record; Save disabled until a change; view shows the new value without reload | only the changed field is submitted; other columns unchanged in SQL | person 1 | pass (chromium, firefox, webkit) | [#672](https://github.com/QRun-IO/qqq/issues/672) [#676](https://github.com/QRun-IO/qqq/issues/676) |
|
||||
| REC-010 | Edit shows non-editable fields read-only and never submits them | read-only controls with the stored values | controls are disabled; create date unchanged in SQL after save | person 1 (id, createDate, modifyDate) | pass (chromium, firefox, webkit) | [#680](https://github.com/QRun-IO/qqq/issues/680) [#537](https://github.com/QRun-IO/qqq/issues/537) |
|
||||
| REC-011 | Edit of a missing record shows the load failure instead of a form | error message, no form fields | 404 is the only failed request | person 999 | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | dialog names the record; Cancel keeps it; Delete removes it and returns to the list; success toast | no Resource not found toast; no 404 read of the deleted record; row gone in SQL | person 5 | pass (chromium, firefox, webkit) | [#541](https://github.com/QRun-IO/qqq/issues/541) |
|
||||
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | source values prefilled without the key; save creates a new row and opens it; source unchanged | unique-key conflict on copy shows the backend message and keeps the form; download-URL files are not copied | person 1; recordLab 1 | pass (chromium, firefox, webkit) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
|
||||
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | single-line input; multi-line textarea keeps line breaks in view and SQL | | fieldLab | pass (chromium, firefox, webkit) | |
|
||||
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | LONG beyond 2^53 saved and shown exactly; DECIMAL with 4 places | fractional LONG rejected in the browser; backend rejects a fractional LONG sent directly | fieldLab | pass (chromium, firefox, webkit) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
|
||||
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | create form starts at the default (true); switching to false persists false; view shows Yes/No | unchanged boolean is not rewritten on edit | fieldLab (booleanValue default true) | pass (chromium, firefox, webkit) | [#680](https://github.com/QRun-IO/qqq/issues/680) |
|
||||
| REC-017 | DATE and TIME editors round-trip values and clearing | date input value and view; time input value and backend 12-hour display; clearing a date stores NULL | | fieldLab | pass (chromium, firefox, webkit) | |
|
||||
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | typed local time stored as the UTC instant; view shows yyyy-MM-dd hh:mm:ss AM EDT; edit form shows the local time | unchanged date-times are not rewritten; non-editable date-times read-only | fieldLab; browser zone America/New_York | pass (chromium, firefox, webkit) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
|
||||
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | fixed America/Chicago display; display in the record's own timeZone; daylight-saving boundary | invalid zone falls back to UTC | fieldLab | pass (chromium, firefox, webkit) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
|
||||
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | formatted text rendered on the view | script content is not executed | fieldLab htmlValue | pass (chromium, firefox, webkit) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
|
||||
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | view shows the mask; typing a new password saves it | editing another field keeps the stored password (SQL) | fieldLab passwordValue | pass (chromium, firefox, webkit) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
|
||||
| REC-022 | BLOB upload and download without a download adornment | file chosen on create is stored byte-for-byte; view offers the bytes as a download | | fieldLab blobValue | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-023 | Dynamic default values (user id, create day, modify day, explicit NONE) | user id and dates filled on create; modify day updated on edit, create day kept | NONE leaves the value empty | fieldLab | pass (chromium, firefox, webkit) | |
|
||||
| REC-024 | Max-length policies: truncate, ellipsis, pass-through and reject | truncate, ellipsis and pass-through stored as declared | reject shows the backend message, keeps the form and writes nothing | fieldLab (maxLength 8) | pass (chromium, firefox, webkit) | [#680](https://github.com/QRun-IO/qqq/issues/680) |
|
||||
| REC-025 | Case and whitespace normalization on save | upper, lower, trim variants, remove-all-whitespace and unchanged stored as declared | | fieldLab | pass (chromium, firefox, webkit) | |
|
||||
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | clip to 0/100 and 0.01/99.99 | out-of-range and exclusive-boundary values show the backend message and write nothing | fieldLab | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
| REC-027 | Unique keys are enforced with the backend message on create and edit | distinct values save | duplicate Name on create; duplicate normalized key on edit; form keeps values, row unchanged | fieldLab (name, normalizedKey) | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
| REC-028 | LINK adornment: record links and URL links | toRecordFromTable opens the referenced record; URL opens in a new tab (target _blank) | a non-http value is not turned into a link | recordLab (ownerId, website) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-029 | CHIP adornment colors and icons by value | label is the display value; color from color.<value>; icon from icon.<value> | undeclared value uses the default color | recordLab status | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-030 | SIZE adornment sets the query grid column width | column is 100px wide | undeclared columns keep the default width | recordLab shortCode (width small) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | view shows the code and its language; Format JSON / Reset Format; editor saves edited code | invalid JSON reports a formatting error | recordLab config (json) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-032 | RENDER_HTML adornment renders sanitized HTML | markup rendered | script removed and not executed | recordLab htmlNote | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-033 | REVEAL adornment masks, reveals and copies the value | masked by default; reveal shows the exact stored value; hide masks again | | recordLab apiToken (PASSWORD + REVEAL) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | file name from the companion field; file name from the format; open and download return the stored bytes | record without a file shows no link | recordLab attachment (heavy, fileNameField) and notesFile (fileNameFormat) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | drag-and-drop zone and button formats; current file shown on edit; new file replaces bytes and file name | removing the file clears bytes and name | recordLab attachment (dragAndDrop), notesFile (button) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#680](https://github.com/QRun-IO/qqq/issues/680) |
|
||||
| REC-036 | TOOLTIP adornment shows its text on the value | tooltip on hover and keyboard focus | | recordLab hint (staticText) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#685](https://github.com/QRun-IO/qqq/issues/685) |
|
||||
| REC-037 | WIDGET adornment renders the field value as the named widget | widget content from the record | the value is not offered as an editable field | recordLab summaryWidget | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-038 | ERROR adornment highlights the value | value shown with error styling | | recordLab problem | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
|
||||
| REC-039 | Field help content follows screen roles and formats | view: READ_SCREENS text and VIEW_SCREEN HTML; edit: WRITE_SCREENS markdown rendered and EDIT_SCREEN text; create: INSERT_SCREEN text | help for other screens is not shown | recordLab title/website/ownerId help | pass (chromium, firefox, webkit) | [#681](https://github.com/QRun-IO/qqq/issues/681) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| REC-040 | Section help content on the record view | HTML help under the section heading | | recordLab presentation section | pass (chromium, firefox, webkit) | [#681](https://github.com/QRun-IO/qqq/issues/681) |
|
||||
| REC-041 | Table help content slots are delivered in table metadata | v1 table metadata carries the slot help | | recordLab summary slot | pass (chromium, firefox, webkit) | |
|
||||
| REC-042 | Audit history lists who changed what, newest first | edit then open Audit: message, user and per-field sentences; audit rows independently verified in SQL | unaudited record shows no audits | recordLab (FIELD audits) | pass (chromium, firefox, webkit) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
|
||||
| REC-043 | Audit history without process permission reads the audit table | same entries via the audit table query | no process request is made | recordLab; persona noProcesses | pass (chromium, firefox, webkit) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
|
||||
| REC-044 | Record label format is used for the record's title and dialogs | heading; delete dialog; audit title | | recordLab (Lab: %s) | pass (chromium, firefox, webkit) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
|
||||
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | edit shows the stored label, not the id; create default shows its label; keyboard search and select saves the id | | recordLab status/ownerId | pass (chromium, firefox, webkit) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
|
||||
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | no Edit, Copy or Delete; create and edit URLs show the permission message | direct insert, update and delete are 403 and change nothing | persona viewer | pass (chromium, firefox, webkit) | |
|
||||
| REC-047 | Forms are labelled and keyboard operable | every control has a label; keyboard-only create saves the record | invalid fields expose aria-invalid and a described error | person | pass (chromium, firefox, webkit) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
|
||||
| REC-048 | Server validation errors show the backend message once and keep the form | alert and toast show the backend message; typed values remain | no generic Something went wrong toast | fieldLab | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
|
||||
## Permissions, sessions, authentication and interaction quality
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|---|---|---|---|---|---|---|
|
||||
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend refuses | noPets sees Person but no Pet / Pet Note in navigation; person record neither shows nor requests the hidden pet association | direct link /app/pet shows unknown resource with no data request; API query/get of pet returns 403, table metadata 404 | tests/acceptance/fixture/AcceptanceSampleServer.java personas (noPets) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
|
||||
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denial without loading data | admin lists the ledger rows; noPets sees the Security Ledger link | noPets list, record, create and edit links show the permission message and request no records; API query/get/insert/update return 403 and rows are unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityLedger, base name pet, noPets) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
|
||||
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | viewer list has no Create button and the dashboard has no Create quick action | /app/person/create shows the permission message with no form; POST /data/person returns 403 and the person count is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium, firefox, webkit) | |
|
||||
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | viewer record view has no Edit action | /app/person/1/edit shows the permission message without loading the record; PUT /data/person/1 returns 403 and the name is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium, firefox, webkit) | |
|
||||
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothing | record view and bulk action bar have no Delete | /app/person.bulkDelete is unknown to the viewer; DELETE /data/person/1 and bulkDelete init return 403; the row remains | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium, firefox, webkit) | |
|
||||
| SEC-006 | Copy requires insert permission: copy not offered and copy link refused | viewer record view has no Copy action | /app/person/1/copy shows the permission message; nothing is inserted | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium, firefox, webkit) | |
|
||||
| SEC-007 | Process permission denied (hidden): processes not offered in navigation or launcher, links do not start them, backend refuses | viewer has no Actions menu; noProcesses' Actions menu offers only the table's bulk actions (table rights) and no standalone process; no process links; noProcesses keeps table rights (Create offered) | /app/clonePeople and /app/greetInteractive are unknown and make no process request; legacy and v1 process init return 403; no person is cloned | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer, noProcesses) | pass (chromium, firefox, webkit) | |
|
||||
| SEC-008 | Process disabled for a user (DenyBehavior.DISABLED): shown disabled, link explains the denial, backend refuses with no side effect | admin runs Security Audit and one audit row is written; viewer sees the audit tile disabled and no dashboard quick action for it | viewer direct link shows the permission message and never calls init; process init returns 403 and the audit log stays empty | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityAudit process) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
|
||||
| SEC-009 | Widget restrictions: hidden widgets absent, disabled widgets show a permission state without loading data, backend refuses | admin sees both pet widgets with their content; noPets sees the open bulletin widget | noPets: hidden widget absent, disabled widget shows "You do not have permission to view this data." and no /widget request; GET /widget/<denied> returns 403 | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityPetWidget, securityPetDisabledWidget) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
|
||||
| SEC-010 | App restrictions: hidden apps absent from navigation and unknown by link; disabled apps keep their children gated | admin sees both pet apps | noPets: Pet Vault App absent and /app/securityPetApp unknown; Pet Disabled App children stay permission-gated | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityPetApp hidden, securityPetDisabledApp disabled) | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) [#671](https://github.com/QRun-IO/qqq/issues/671) |
|
||||
| SEC-011 | Record-level security (sharing demo): users see only their own or shared saved views/reports; direct links and writes to others are refused | alice lists and opens her own saved view | bob's list omits alice's view; bob's direct link shows not found; bob's GET/PUT/DELETE of alice's view are refused and the row is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (alice, bob) | pass (chromium, firefox, webkit) | |
|
||||
| SEC-012 | Record security lock on a data table: list filtered to the key, other users' records unreachable, forged owner refused | alice sees and edits only her notes (persisted) | bob's direct link to alice's note shows not found; bob's update/delete of alice's note and insert with alice as owner are refused; SQL unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityNote locked by sample user) | pass (chromium, firefox, webkit) | |
|
||||
| SEC-013 | Field protection: hidden fields never shown or returned, password fields masked and never overwritten by the mask, heavy fields not loaded in lists | REVEAL password can be revealed on request; editing another field keeps the stored password | hidden value absent from list, record, edit form and API responses; masked password shows only the mask; the list query does not carry the heavy BLOB | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityVault) | pass (chromium, firefox, webkit) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
|
||||
| SEC-014 | Capability restrictions: tables without insert/update/delete capabilities offer none of them and refuse direct links | admin can list and view the archive | no Create/Edit/Delete offered; create and edit links explain the table cannot be changed; backend insert/update/delete are refused; SQL unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityArchive) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
|
||||
| SEC-015 | Permission revoked mid-session: stale UI actions are refused by the backend with a clear message, nothing is written, reload reflects the new rights | after reload the viewer rights are shown | saving an open edit form after revocation shows a permission error and the row is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (admin then viewer) | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | lands on the requested page with rows; user menu shows Alice (sample) / sample:alice | | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium, firefox, mobile, webkit) | [#332](https://github.com/QRun-IO/qqq/issues/332) [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per-user client data is cleared | Sign in returns to the original page | after logout a protected page (full load) stays on login and reads no data; a different user signing in on the same browser never sees the previous user's cached pages or recent records | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium, firefox, mobile, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
| SEC-022 | Session expiry: 401 redirects to login with returnTo and re-authenticates back to the page (initial load and mid-use) | expired on first load returns to the page; expired mid-use returns to the clicked record | no hang on "Setting up anonymous session" | tests/acceptance/fixture/AcceptanceSampleServer.java personas (expired) | pass (chromium, firefox, mobile, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
| SEC-023 | Denied sign-in: a 401 from manageSession keeps the user out with an explanation (no silent anonymous fallback) | | login shows "Sign-in was denied", no navigation or data is rendered | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
| SEC-024 | Post-login returnTo is limited to in-app paths (open-redirect guard) | in-app returnTo with query string is honoured | absolute, protocol-relative, backslash and javascript: targets land on /app | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend token exchange, session, reload resume) | authorize request carries S256 challenge, state, client id, scopes and /token redirect; backend exchanges code + verifier with client secret; user_session row stored; provider identity is displayed; reload resumes without the provider | | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| SEC-026 | OAUTH2 sign-in failures: provider denial, tampered state and failed code exchange are reported and create no session | | access_denied shows the provider denial; a state mismatch is rejected before any token exchange; a rejected code exchange shows the failure; no user_session row | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
|
||||
| SEC-027 | OAUTH2 logout: backend session deleted, provider end-session called, next visit requires provider sign-in | user_session row deleted and provider session ended | protected page after logout returns to the provider login | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) [#674](https://github.com/QRun-IO/qqq/issues/674) |
|
||||
| SEC-028 | OAUTH2 session revoked server-side mid-use: 401 sends the user through the provider and back to the page | returns to the same page after provider sign-in | revoked session gets 401, not stale data | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
|
||||
| SEC-029 | AUTH_0 authorization code + PKCE with an owned Auth0-compatible provider (browser token exchange, backend JWT validation via JWKS) | sign-in and data access through the Auth0 module; logout returns through the provider | provider denial is reported | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=AUTH_0 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
|
||||
| SEC-030 | AUTH_0 against a real Auth0 tenant | real tenant sign-in/logout | | Requires an Auth0 tenant QRun does not own in CI | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): real Auth0 tenant; the AUTH_0 flow is covered against an owned Auth0-compatible provider by SEC-029/SEC-031; real-service testing is follow-up work.) | |
|
||||
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in again | deep link loads data as Anonymous | after logout the page stays signed out until Sign in | SecurityAcceptanceServer -Dqqq.security.auth=FULLY_ANONYMOUS | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
| SEC-032 | Unsupported authentication type (TABLE_BASED, also unsupported by Material) is reported, never silently treated as another type | | login shows "Unsupported authentication type: TABLE_BASED" and no data is requested | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
| SEC-033 | Server-side logout invalidation: v1 logout expires every session cookie it issued and an ended Auth0 session cannot be replayed | logout response expires sessionUUID and sessionId | a logged-out Auth0 sessionUUID replayed against the API gets 401 | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#674](https://github.com/QRun-IO/qqq/issues/674) [#675](https://github.com/QRun-IO/qqq/issues/675) |
|
||||
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | edit saved by keyboard persists (SQL) | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#676](https://github.com/QRun-IO/qqq/issues/676) [#682](https://github.com/QRun-IO/qqq/issues/682) |
|
||||
| INT-002 | Keyboard: run a process through its steps without a pointer | greetInteractive completes and shows results | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | |
|
||||
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | keyboard delete confirmation deletes the row (SQL) | Escape cancels without deleting; focus returns to Delete | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | [#682](https://github.com/QRun-IO/qqq/issues/682) [#683](https://github.com/QRun-IO/qqq/issues/683) [#694](https://github.com/QRun-IO/qqq/issues/694) |
|
||||
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | command menu navigates to a table | Escape closes and returns focus | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#682](https://github.com/QRun-IO/qqq/issues/682) |
|
||||
| INT-005 | Automated accessibility scan (axe WCAG 2.1 AA) of login, dashboard, list, record, edit form and process step | no serious or critical violations on each page | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | [#682](https://github.com/QRun-IO/qqq/issues/682) |
|
||||
| INT-006 | Form labels and ARIA: every field has an accessible label, required fields are marked, invalid fields announce their error | labels match metadata labels | submitting empty required fields sets aria-invalid and a described error | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | |
|
||||
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | | missing required value is named and focused; backend rejection is shown verbatim; SQL unchanged | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | |
|
||||
| INT-008 | Responsive layout at phone width (Pixel 7): navigation drawer, list, record and form usable without horizontal scrolling | drawer navigation to a list, open and edit a record on a phone viewport | no horizontal page overflow | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
|
||||
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | busy indicator while the real request is held, then rows | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | [#694](https://github.com/QRun-IO/qqq/issues/694) |
|
||||
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | retry succeeds | the failure is explained, not a blank page | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | |
|
||||
| INT-011 | Delayed save: the submit control is disabled while saving and double submission creates one record | exactly one row inserted (SQL) | second click while pending is ignored | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | record edited through the API shows the new value on revisit | record deleted through the API shows not found | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#683](https://github.com/QRun-IO/qqq/issues/683) |
|
||||
| INT-013 | Empty state: a query with no results explains it and offers the create action | no-match filter shows the empty message and Create | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | |
|
||||
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | Try again after recovery signs in | network failure shows "Sign-in failed" | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
|
||||
## Dashboards, widgets, reports, saved and scheduled reports, and sharing
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|---|---|---|---|---|---|---|
|
||||
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | WARNING alert renders strong HTML and each bullet as sanitized HTML | hideWidget and empty alerts render nothing (WID-063) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accAlert | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-002 | barChart widget: one bar per label in backend colors | bars carry per-point backgroundColors; accessible data table has exact values | | sample SampleWidgetsDashboard/SampleBarChartWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-003 | chart output from an untyped widget renders as a bar chart | metadata type absent, payload type chart, values rendered | zero-value month has no drawn bar | sample greetingsApp/PersonsByCreateDateBarChart | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-004 | divider widget renders a rule without card chrome | hr rendered, no label or card | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accDivider | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-005 | fieldValueList widget: labels, display values, prefix icons, indentation | display value wins, zero shown, icon color, indent | empty field list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accFieldValueList | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-006 | generic widget renders its common payload fields (sublabel, footer) | sublabel and sanitized footer HTML shown | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accGeneric | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-007 | horizontalBarChart widget: horizontal bars with negative values left of zero | negative bar extends left of the zero line; height from payload | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accHorizontalBarChart | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-008 | html widget (no-code) renders sanitized user HTML | velocity output with i/b/u and centered wrapper | empty html renders an empty body (WID-052) | sample SampleWidgetsDashboard/SampleHTMLWidget | pass (chromium, firefox, webkit) | |
|
||||
| WID-009 | lineChart widget: line with a point per label and description | points carry exact values | | sample SampleWidgetsDashboard/SampleLineChartWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-010 | smallLineChart widget: compact line with title and HTML description | title, strong description text, 5 points | | sample SampleWidgetsDashboard/SampleSmallLineChartWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-011 | location widget: image, title, description, address and footer | owned image loads from the owned service | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accLocation | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | every group and value matches the payload | empty group list shows empty state (WID-052) | sample SampleWidgetsDashboard/SampleMultiStatisticsWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-013 | multiTable widget renders each table with its label and rows | two labelled tables with their rows | empty table list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accMultiTable | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-014 | pieChart widget: colored slices, legend values and subheader | slice fills, legend label: value, main number, up/good change | | sample SampleWidgetsDashboard/SamplePieChartWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-015 | quickSightChart widget embeds the provider URL (real renderer, owned local provider endpoint) | iframe src is the owned embed URL and its content renders | metadata never exposes the secret key | tests/acceptance/fixture/WidgetsFixtures.java repointSampleQuickSight (greetingsApp/QuickSightChartRenderer against the owned loopback endpoint) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-016 | statistics widget: count, context, colored percentage change and links | decrease with increaseIsGood is bad; increase with increaseIsGood=false is bad; count links to countURL | | sample SampleStatisticsWidget; tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accStatisticsGood | pass (chromium, firefox, webkit) | |
|
||||
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | bars of one label share x; dataset colors | | sample SampleStackedBarChartWidget; tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accStackedBars | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | states and current link | empty steps show empty state (WID-052) | sample SampleWidgetsDashboard/SampleStepperWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-019 | table widget: headers, rows, alignment and fixed totals row | rows and sticky last row match payload | no rows shows noRowsFoundHTML or default (WID-052) | sample SampleWidgetsDashboard/SampleTableWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-020 | usaMap widget plots each marker with name and coordinates | 3 markers placed west to east by longitude; marker list | empty marker list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accUsaMap | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-021 | process widget runs the sample interactive process inline | form fields render, submit, result greeting shows | viewer persona: process denied contained (WID-061) | tests/acceptance/fixture/WidgetsFixtures.java widgetProcess/accProcess (greetInteractive) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-022 | parentWidget renders its children in a 12-column grid | children render with their own data | tabs (WID-055), denied/missing children (WID-056) | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentGrid | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | every block value, direction, link and tooltip | no blocks renders an empty composite (WID-052) | sample SampleWidgetsDashboard/SampleBigNumberBlocksWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-024 | childRecordList widget without association binding lists joined child records | rows link to child records, Showing 2 of 3, View All filter link | host with no children shows empty state | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accWidgetHostJoinChild (maxRows 2) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#663](https://github.com/QRun-IO/qqq/issues/663) |
|
||||
| WID-025 | customComponent widget loads its bundle and renders the named component | component receives widget metadata and data | missing bundle shows contained error (WID-062) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accCustomComponent (owned bundle) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-026 | cronUI widget shows the record's schedule expression, description and time zone | expression, backend description and time zone | record without expression shows No schedule set | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostCron | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-027 | dynamicForm widget shows labeled values from the payload | labels and values incl. 0 | | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostDynamicForm | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-028 | dataBagViewer widget lists versions and shows the selected version contents | newest version current and shown; selecting older version shows its JSON | bag without versions shows empty message | tests/acceptance/fixture/WidgetsFixtures.java dataBag/1 section accDataBagViewer (owned dataBag tables) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-029 | pivotTableSetup widget shows the saved report's pivot definition | rows/values labels from the source table | report without pivot shows message | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned pivot saved report | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-030 | filterAndColumnsSetup widget shows the saved report's filter, sort and columns | criteria with field labels and operator phrases, columns by label | report without filter shows No filters | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned filtered saved report | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-031 | rowBuilder widget without association binding shows its rows read-only | column labels from widget fields, row values incl. 0 | | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostRows | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#663](https://github.com/QRun-IO/qqq/issues/663) |
|
||||
| WID-032 | scriptViewer widget lists script revisions and shows the selected revision's files | current revision marked and its file contents shown; selecting older revision | | tests/acceptance/fixture/WidgetsFixtures.java script/1 (owned memory script with 2 revisions) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-033 | quickSightChart against a real AWS QuickSight account and dashboard | real embed URL | | requires AWS credentials and a QuickSight dashboard (external service) | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): real AWS QuickSight dashboard; the widget and renderer are covered against an owned loopback provider by WID-015; real-service testing is follow-up work.) | |
|
||||
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | order matches metadata; 12 spans the grid; 4 is a third | | sample SampleWidgetsDashboard | pass (chromium, firefox, webkit) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-041 | widget chrome: card vs plain (isCard=false), sublabel, metadata and payload footer HTML | plain widget has no card border; footers render | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accPlain, widgetGallery/accGeneric | pass (chromium, firefox, webkit) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-042 | header icons (topLeftInsideCard/topRightInsideCard) with metadata colors | icon names, colors and placement | | sample SampleWidgetsDashboard | pass (chromium, firefox, webkit) | [#538](https://github.com/QRun-IO/qqq/issues/538) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-043 | widget label tooltip from metadata on hover and keyboard focus | hover and focus show, Escape hides | | sample SampleWidgetsDashboard/SampleBarChartWidget | pass (chromium, firefox, webkit) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-044 | widget help content (HTML) on the label | help icon shows sanitized HTML help | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accHelp | pass (chromium, firefox, webkit) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-045 | reload button re-renders the widget from the backend | render count increments per reload | no reload button without showReloadButton | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accReload | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-046 | export button downloads the widget's csvData as CSV | file bytes match Material quoting rules | no csvData: no-data message and no download | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accExport, accExportEmpty, accControls | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-047 | PVS dropdown from the widget payload: selection is sent as a parameter and re-renders | options from payload, choice reaches renderer, clearing removes it | non-stored selection is not persisted | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accDropdownHtml | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-048 | DATE_PICKER dropdown sends the chosen date | date reaches the child renderer | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-049 | required dropdowns: the parent waits with the backend message until selections are made | message then children render with selections | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-050 | storeDropdownSelections persists selections across reload; stale stored options are dropped | localStorage qqq.widgets.dropdownData.<widget>.<param>; reload keeps selections | removed option in storage is not selected | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | error message, retry re-requests, neighbor content | no error toast, no console error | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accError + accHealthy | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-052 | empty payloads show descriptive empty states for every display type | table, multiStatistics, charts, stepper, statistics 0, composite, multiTable, field values, map, html | | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accEmpty* | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-053 | malformed payloads are contained to their own widget | chart, table, composite, stepper, multiStatistics show a format notice | no crash, no console error, neighbors render | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accMalformed* | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-054 | widget permissions: a denied widget is absent from metadata, not rendered and not requested; the API denies it | admin sees it | noPets: no card, no request, GET /widget 403, renderer not called | tests/acceptance/fixture/WidgetsFixtures.java widgetPermissions/accDenied (pet.accDenied.hasAccess) with the noPets persona | pass (chromium, firefox, webkit) | |
|
||||
| WID-055 | parent widget TABS layout switches tabs and remembers the selected tab | tab click, arrow keys, stored tab restored after reload | | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentTabs | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| WID-056 | parent widget skips denied and unknown children without requests | allowed children render | noPets: denied child absent and not requested; unknown child skipped | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentDenied, accParentMissing | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-057 | composite renders every block type with its values and styles | TEXT, BIG_NUMBER, UP_OR_DOWN_NUMBER, NUMBER_ICON_BADGE, ICON, TABLE_SUB_ROW_DETAIL_ROW, PROGRESS_BAR, DIVIDER, IMAGE, AUDIO, INPUT_FIELD, BUTTON | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-058 | composite layouts and slot links/tooltips | FLEX_COLUMN, FLEX_ROW, BADGES_WRAPPER, TABLE_SUB_ROW_DETAILS, FLEX_ROW_SPACE_BETWEEN, FLEX_ROW_CENTER, FLEX_ROW_WRAPPED; link to /app/person with tooltip | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-059 | unsupported block type shows a contained warning | Unsupported block type: OWNED_UNKNOWN; neighbor healthy | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocksUnknown | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-060 | record-view section widgets receive the record context (id, tableName) | record 1 and 2 show their own values | renderer enforces the user's table read | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost sections accHostHtml, accHostFieldValues | pass (chromium, firefox, webkit) | |
|
||||
| WID-061 | process widget for a persona without process permission is contained | error shown in the widget; neighbor healthy | process metadata 403, no process run | tests/acceptance/fixture/WidgetsFixtures.java widgetProcess/accProcess with the noProcesses persona | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-062 | custom component whose bundle cannot load shows a contained error | Error loading MissingOwnedComponent | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accCustomComponentMissing | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-063 | alerts with hideWidget or without HTML render nothing | no card rendered | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accAlertHidden, widgetStates/accEmptyAlert | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
|
||||
| RPT-001 | TABLE report runs through its process and downloads CSV matching the database | CSV header labels and every person row from SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
|
||||
| RPT-002 | TABLE report downloads XLSX with the same rows | sheet rows match SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
|
||||
| RPT-003 | TABLE report downloads JSON with the same rows | JSON objects match SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
|
||||
| RPT-004 | SUMMARY report groups rows with counts and a totals row | pets per owner match SQL group by; Totals row | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPetSummaryReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
|
||||
| RPT-005 | PIVOT report produces a native pivot table in XLSX | xlsx contains the pivot table and cache over the rows sheet | CSV omits the pivot view (no native pivots) | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPetPivotReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) [#484](https://github.com/QRun-IO/qqq/issues/484) |
|
||||
| RPT-006 | report input fields: required validation and the value filters the rows | minimumId filters rows >= value | empty required input blocks submit | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonInputReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
|
||||
| RPT-007 | report permissions: restricted report denied in UI and by the server | admin can run it | noPets: no-permission message, legacy route 403 | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accRestrictedReport with the noPets persona | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
|
||||
| RPT-008 | reports appear in app navigation and open their run page | sidebar REPORT nodes navigate to the report page | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports app | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) [#651](https://github.com/QRun-IO/qqq/issues/651) |
|
||||
| RPT-009 | saved report view shows its filter/columns and pivot setup | filter criteria, columns, pivot rows/values labels | | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned saved reports | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| RPT-010 | saved report renders through renderSavedReport and downloads the saved columns and rows | format chosen, CSV contains the saved columns and every species | | prime-sharing savedReport 1 (Pet Species Report) | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) [#661](https://github.com/QRun-IO/qqq/issues/661) |
|
||||
| RPT-011 | streaming report route for reports without a process | CSV streamed from /reports/{name}?format=csv matches SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accStreamedReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
|
||||
| RPT-012 | scheduled report: create a schedule for a saved report and view its cron schedule | schedule persisted with cron description; record view shows the schedule widget | invalid cron expression rejected with message | scheduledReport (memory) for savedReport 1 | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
| RPT-013 | sharing: the owner shares a saved report with a user (Read-Only) | dialog lists Bob; shared_saved_report row READ_ONLY | | prime-sharing savedReport 1 owned by alice | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) [#444](https://github.com/QRun-IO/qqq/issues/444) |
|
||||
| RPT-014 | sharing: change a share's scope to Read and Edit | row scope READ_WRITE | | prime-sharing savedReport 1 | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
|
||||
| RPT-015 | sharing: remove a share | row deleted, list empty | | prime-sharing savedReport 1 | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
|
||||
| RPT-016 | sharing record security: another user cannot see an unshared report until it is shared | bob sees it after sharing (UI list + API) | before sharing: not in bob's list, GET 404 | prime-sharing savedReport 1 (alice), user bob | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
|
||||
| RPT-017 | sharing: only the owner may share | Share disabled with reason for the non-owner | server rejects a non-owner share; no row | tests/acceptance/fixture/WidgetsFixtures.java savedReport owned by casey shared with alice | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) [#371](https://github.com/QRun-IO/qqq/issues/371) |
|
||||
| RPT-018 | sharing a saved view through the share dialog | shared_saved_view row for Casey | | prime-sharing savedView 1 owned by alice | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
|
||||
@@ -0,0 +1,6 @@
|
||||
# qqq-frontend-next
|
||||
|
||||
This Maven artifact contains the static export of the QQQ Next dashboard under
|
||||
`next-dashboard/`. It has no Java sources or API documentation; the source code is at
|
||||
https://github.com/QRun-IO/qqq-frontend-next. Add the jar to a QQQ application and
|
||||
`QApplicationJavalinServer` serves the dashboard at `/`.
|
||||
@@ -0,0 +1,409 @@
|
||||
# Prompt: Add `node_app_*` jobs to kingsrook/qqq-orb
|
||||
|
||||
## Context
|
||||
|
||||
You are working in the `kingsrook/qqq-orb` CircleCI orb repo at:
|
||||
`/Users/james.maes/Git.Local/QRun-IO/qqq-orb`
|
||||
|
||||
The orb currently supports:
|
||||
- `mvn_*` — Maven Java libraries
|
||||
- `mvn_frontend_*` — Maven + bundled frontend
|
||||
- `node_*` — npm-published TypeScript libraries
|
||||
|
||||
You are adding a new project type: **`node_app_*`** — pnpm-based Node.js
|
||||
applications (Next.js, Vite, etc.) that are _deployed_, not published to npm.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Read these files before writing anything
|
||||
|
||||
### qqq-orb existing patterns (understand before touching)
|
||||
|
||||
```
|
||||
src/@orb.yml ← orb-level imports (browser-tools, node orbs already imported)
|
||||
src/executors/default.yml ← Docker executor (cimg/node)
|
||||
src/jobs/node_test_only.yml ← existing node job structure
|
||||
src/jobs/node_publish.yml ← existing node publish job
|
||||
src/commands/node_install_dependencies.yml
|
||||
src/commands/node_run_tests.yml
|
||||
src/commands/node_build_package.yml
|
||||
src/scripts/node_npm_auth.sh ← existing script style/conventions
|
||||
src/scripts/node_version_commit.sh ← existing script style/conventions
|
||||
CLAUDE.md ← orb architecture, make commands, linting rules
|
||||
```
|
||||
|
||||
### Munitor reference orb (copy engineering patterns from here)
|
||||
|
||||
These files live at `/Users/james.maes/Git.Local/Kof22/Munitor/`. Read them
|
||||
to understand the patterns you must replicate — **not to copy content**, but
|
||||
to adopt the same engineering discipline.
|
||||
|
||||
```
|
||||
src/scripts/munitor_helpers.sh ← shared helper library pattern
|
||||
src/scripts/install_node.sh ← nvm-based node install with BASH_ENV export
|
||||
src/scripts/npm_install.sh ← lockfile validation before install
|
||||
src/scripts/npm_test.sh ← JSON test_commands array override pattern
|
||||
src/scripts/npm_lint.sh ← lint with JSON artifact output
|
||||
src/scripts/run_e2e_playwright.sh ← Playwright install + JUnit reporter
|
||||
src/commands/restore_npm_cache.yml ← cache key pattern
|
||||
src/commands/save_npm_cache.yml
|
||||
src/jobs/npm_build_and_test.yml ← two-job pipeline with workspace handoff
|
||||
src/jobs/npm_e2e_test.yml ← E2E job that attaches workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create a shared helper script
|
||||
|
||||
### `src/scripts/qqq_helpers.sh`
|
||||
|
||||
Model this on Munitor's `munitor_helpers.sh`. It must provide:
|
||||
|
||||
- `qqq_header <step_name>` — prints a banner with step name, date (UTC),
|
||||
hostname, and current directory. Format to match qqq style (not Munitor branding).
|
||||
- `qqq_check_tool <command> [version_flag]` — verifies tool is in PATH,
|
||||
prints its version. Exits 1 with clear error if missing.
|
||||
- Guard against double-sourcing (`[[ -n "${_QQQ_HELPERS_LOADED:-}" ]] && return 0`)
|
||||
|
||||
Sourcing pattern every script must use at the top:
|
||||
```bash
|
||||
QQQ_HELPERS="${QQQ_HELPERS:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/qqq_helpers.sh}"
|
||||
[[ -f "${QQQ_HELPERS}" ]] && source "${QQQ_HELPERS}"
|
||||
```
|
||||
|
||||
With a fallback stub if helpers are missing (same pattern as Munitor):
|
||||
```bash
|
||||
elif ! type qqq_header &>/dev/null; then
|
||||
qqq_header() { echo "=== QQQ: ${1:-unknown} ==="; }
|
||||
qqq_check_tool() { command -v "$1" &>/dev/null || { echo "ERROR: $1 not found"; exit 1; }; }
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create shell scripts
|
||||
|
||||
All scripts: `#!/usr/bin/env bash`, `set -euo pipefail`, source `qqq_helpers.sh`
|
||||
at top. Clear section headers. Descriptive error messages. Print summaries.
|
||||
|
||||
### `src/scripts/node_app_install.sh`
|
||||
|
||||
Purpose: Enable pnpm via corepack, validate lockfile exists, install deps.
|
||||
|
||||
Environment variables consumed:
|
||||
- `NODE_PKG_MANAGER` — `pnpm` (default), `npm`, or `yarn`
|
||||
|
||||
Logic:
|
||||
1. Call `qqq_header "node_app_install"`
|
||||
2. Check tool: node, npm (for corepack)
|
||||
3. If `NODE_PKG_MANAGER=pnpm`:
|
||||
- Run `corepack enable`
|
||||
- Run `corepack prepare pnpm@latest --activate`
|
||||
- Verify `pnpm --version`
|
||||
- Validate `pnpm-lock.yaml` exists; exit 1 with clear message if not
|
||||
- Run `pnpm install --frozen-lockfile`
|
||||
4. If `NODE_PKG_MANAGER=npm`:
|
||||
- Validate `package-lock.json` exists
|
||||
- Print lockfile version + npm version compatibility warning (copy logic from
|
||||
Munitor's `npm_install.sh`)
|
||||
- Run `npm ci`
|
||||
5. If `NODE_PKG_MANAGER=yarn`:
|
||||
- Validate `yarn.lock` exists
|
||||
- Run `yarn install --frozen-lockfile`
|
||||
6. Print count of installed packages
|
||||
|
||||
### `src/scripts/node_app_test.sh`
|
||||
|
||||
Purpose: Run type check, lint, and unit tests. Each step is individually
|
||||
skippable. Supports custom test commands via JSON array (replaces default test
|
||||
runner when provided).
|
||||
|
||||
Environment variables consumed:
|
||||
- `NODE_PKG_MANAGER` — pnpm | npm | yarn (default: pnpm)
|
||||
- `TYPE_CHECK_SCRIPT` — npm script name, or empty to skip (default: `tsc`)
|
||||
- `TYPE_CHECK_ARGS` — extra args (default: `--noEmit`)
|
||||
- `LINT_SCRIPT` — npm script name, or empty to skip (default: `lint`)
|
||||
- `TEST_SCRIPT` — npm script name (default: `test`)
|
||||
- `QQQ_TEST_COMMANDS_JSON` — JSON array of shell commands; if set and non-empty,
|
||||
runs these instead of the default test command (same pattern as Munitor's
|
||||
`MUNITOR_TEST_COMMANDS_JSON`)
|
||||
|
||||
Logic:
|
||||
1. `qqq_header "node_app_test"`
|
||||
2. If `TYPE_CHECK_SCRIPT` is non-empty: run `<pkg_manager> run <TYPE_CHECK_SCRIPT> <TYPE_CHECK_ARGS>`
|
||||
3. If `LINT_SCRIPT` is non-empty: run `<pkg_manager> run <LINT_SCRIPT>`
|
||||
4. If `QQQ_TEST_COMMANDS_JSON` is set and not `[]`/`null`:
|
||||
- Parse JSON array using `jq` and run each command in sequence
|
||||
5. Else: run `<pkg_manager> run <TEST_SCRIPT>`
|
||||
6. Print pass/fail summary
|
||||
|
||||
### `src/scripts/node_app_build.sh`
|
||||
|
||||
Purpose: Run the production build. For Next.js this is `pnpm build`. Exits
|
||||
non-zero on failure with clear output.
|
||||
|
||||
Environment variables consumed:
|
||||
- `NODE_PKG_MANAGER` — default: pnpm
|
||||
- `BUILD_SCRIPT` — npm script name (default: `build`)
|
||||
|
||||
Logic:
|
||||
1. `qqq_header "node_app_build"`
|
||||
2. Check pkg manager is available
|
||||
3. Run `<pkg_manager> run <BUILD_SCRIPT>`
|
||||
4. Print success with timing
|
||||
|
||||
### `src/scripts/node_app_e2e.sh`
|
||||
|
||||
Purpose: Install Playwright browsers and run E2E tests. Reports results in
|
||||
JUnit format for CircleCI test analytics.
|
||||
|
||||
Environment variables consumed:
|
||||
- `NODE_PKG_MANAGER` — default: pnpm
|
||||
- `E2E_TEST_DIR` — default: `e2e`
|
||||
- `PLAYWRIGHT_BROWSERS` — comma-separated browsers (default: `chromium`)
|
||||
|
||||
Logic:
|
||||
1. `qqq_header "node_app_e2e"`
|
||||
2. Install Playwright browsers: `npx playwright install --with-deps <browsers>`
|
||||
3. Run: `npx playwright test --reporter=junit --output=e2e-results`
|
||||
- If `E2E_TEST_DIR/playwright.config.ts` exists, pass `--config=<path>`
|
||||
4. On failure: print "E2E tests failed. See playwright-report artifact." and
|
||||
re-exit with the original exit code
|
||||
5. Print pass summary
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create commands
|
||||
|
||||
### `src/commands/node_app_install.yml`
|
||||
|
||||
Parameters:
|
||||
- `pkg_manager` — string, default: `"pnpm"` — package manager (pnpm | npm | yarn)
|
||||
|
||||
Steps:
|
||||
1. Restore cache with keys:
|
||||
- `node-app-v1-{{ checksum "pnpm-lock.yaml" }}-{{ checksum "package-lock.json" }}-{{ checksum "yarn.lock" }}`
|
||||
- `node-app-v1-`
|
||||
2. Run `node_app_install.sh` with environment `NODE_PKG_MANAGER: << parameters.pkg_manager >>`
|
||||
3. Save cache key (same composite key as restore), paths: `node_modules`
|
||||
|
||||
### `src/commands/node_app_test.yml`
|
||||
|
||||
Parameters:
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `type_check_script` — string, default: `"tsc"` — set empty to skip
|
||||
- `type_check_args` — string, default: `"--noEmit"`
|
||||
- `lint_script` — string, default: `"lint"` — set empty to skip
|
||||
- `test_script` — string, default: `"test"`
|
||||
- `test_commands` — string, default: `""` — JSON array of custom test commands
|
||||
|
||||
Steps:
|
||||
1. Run `node_app_test.sh` with environment:
|
||||
```
|
||||
NODE_PKG_MANAGER: << parameters.pkg_manager >>
|
||||
TYPE_CHECK_SCRIPT: << parameters.type_check_script >>
|
||||
TYPE_CHECK_ARGS: << parameters.type_check_args >>
|
||||
LINT_SCRIPT: << parameters.lint_script >>
|
||||
TEST_SCRIPT: << parameters.test_script >>
|
||||
QQQ_TEST_COMMANDS_JSON: << parameters.test_commands >>
|
||||
```
|
||||
2. store_artifacts: path: `test-results`, destination: `test-results` (if exists)
|
||||
|
||||
### `src/commands/node_app_build_step.yml`
|
||||
|
||||
Parameters:
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `build_script` — string, default: `"build"`
|
||||
|
||||
Steps:
|
||||
1. Run `node_app_build.sh` with environment:
|
||||
```
|
||||
NODE_PKG_MANAGER: << parameters.pkg_manager >>
|
||||
BUILD_SCRIPT: << parameters.build_script >>
|
||||
```
|
||||
|
||||
### `src/commands/node_app_e2e.yml`
|
||||
|
||||
Parameters:
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `test_dir` — string, default: `"e2e"`
|
||||
- `browsers` — string, default: `"chromium"`
|
||||
|
||||
Steps:
|
||||
1. Run `node_app_e2e.sh` with environment:
|
||||
```
|
||||
NODE_PKG_MANAGER: << parameters.pkg_manager >>
|
||||
E2E_TEST_DIR: << parameters.test_dir >>
|
||||
PLAYWRIGHT_BROWSERS: << parameters.browsers >>
|
||||
```
|
||||
2. store_test_results: path: `e2e-results`
|
||||
3. store_artifacts: path: `e2e-results`, destination: `e2e-results`
|
||||
4. store_artifacts: path: `test-results/traces`, destination: `playwright-traces`
|
||||
(use `when: always` so traces are captured even on failure)
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Create jobs
|
||||
|
||||
### `src/jobs/node_app_test_only.yml`
|
||||
|
||||
For feature/non-protected branches. Run install + test only (no build).
|
||||
|
||||
Executor: `default` (existing `cimg/node` Docker executor)
|
||||
- Use `default` executor, tag parameter: `<< parameters.node_version >>`
|
||||
|
||||
Parameters:
|
||||
- `node_version` — string, default: `"lts"`
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `type_check_script` — string, default: `"tsc"`
|
||||
- `type_check_args` — string, default: `"--noEmit"`
|
||||
- `lint_script` — string, default: `"lint"`
|
||||
- `test_script` — string, default: `"test"`
|
||||
- `test_commands` — string, default: `""`
|
||||
|
||||
Steps:
|
||||
1. `git_full_checkout`
|
||||
2. `node_app_install` (pass `pkg_manager`)
|
||||
3. `node_app_test` (pass all test params)
|
||||
|
||||
### `src/jobs/node_app_build.yml`
|
||||
|
||||
For protected branches (develop, release/*, main, hotfix/*). Run install +
|
||||
test + production build. Persists workspace for a downstream E2E job.
|
||||
|
||||
Executor: `default` (cimg/node Docker)
|
||||
|
||||
Parameters: (same as node_app_test_only PLUS:)
|
||||
- `branch_type` — string, default: `"snapshot"` — reserved for future deploy hooks
|
||||
- `build_script` — string, default: `"build"`
|
||||
|
||||
Steps:
|
||||
1. `git_full_checkout`
|
||||
2. `node_app_install` (pass `pkg_manager`)
|
||||
3. `node_app_test` (pass all test params)
|
||||
4. `node_app_build_step` (pass `pkg_manager`, `build_script`)
|
||||
5. `persist_to_workspace` — root: `.`, paths: `["."]`
|
||||
(full workspace so E2E job gets built artifacts)
|
||||
|
||||
### `src/jobs/node_app_e2e.yml`
|
||||
|
||||
Separate E2E job. Attaches workspace from `node_app_build` and runs Playwright.
|
||||
|
||||
Executor: `default` (cimg/node Docker) — browsers are installed by the script
|
||||
using `npx playwright install --with-deps`, which works in Docker with root.
|
||||
|
||||
Parameters:
|
||||
- `node_version` — string, default: `"lts"`
|
||||
- `pkg_manager` — string, default: `"pnpm"`
|
||||
- `test_dir` — string, default: `"e2e"`
|
||||
- `browsers` — string, default: `"chromium"`
|
||||
|
||||
Steps:
|
||||
1. `attach_workspace` — at: `.`
|
||||
2. `node_app_e2e` (pass all E2E params)
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Do NOT modify any existing files
|
||||
|
||||
These must be completely unchanged:
|
||||
- All `mvn_*` jobs, commands, and scripts
|
||||
- All `mvn_frontend_*` jobs, commands, and scripts
|
||||
- All `node_*` jobs (node_test_only, node_publish), commands, and scripts
|
||||
- `src/@orb.yml` — do NOT add new orb imports; existing `browser-tools`,
|
||||
`node`, and `slack` orbs are already available
|
||||
- `src/executors/default.yml`
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Validate your work
|
||||
|
||||
After creating all files, run these commands from the orb root:
|
||||
|
||||
```bash
|
||||
make lint # yamllint + shellcheck + circleci validate
|
||||
make pack # pack to target/qqq-orb-packed.yml
|
||||
make validate # pack + validate
|
||||
```
|
||||
|
||||
Fix any yamllint, shellcheck, or orb validation errors before finishing.
|
||||
|
||||
---
|
||||
|
||||
## Target usage (what consuming repos will write)
|
||||
|
||||
After your changes, a pnpm Next.js app at `QRun-IO/qqq-frontend-next` writes:
|
||||
|
||||
```yaml
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: qrun-io/qqq-orb@<new-version>
|
||||
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/node_app_test_only:
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
branch_type: snapshot
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
only: [ develop ]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
branch_type: release_candidate
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
only: [ /release\/.*/ ]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
branch_type: release
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
only: [ main ]
|
||||
tags:
|
||||
only: [ /v.*/ ]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
branch_type: hotfix
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
only: [ /hotfix\/.*/ ]
|
||||
```
|
||||
|
||||
All parameters use defaults that work for any standard pnpm Next.js project
|
||||
with `tsc`, `lint`, `test`, and `build` scripts — zero configuration needed
|
||||
unless the project deviates from those conventions.
|
||||
|
||||
---
|
||||
|
||||
## Engineering quality bar
|
||||
|
||||
Every script must meet the standard set by Munitor's scripts:
|
||||
- `#!/usr/bin/env bash` + `set -euo pipefail`
|
||||
- `qqq_helpers.sh` sourced at top with fallback stub
|
||||
- `qqq_header` called as first real step
|
||||
- `qqq_check_tool` used to validate required tools before using them
|
||||
- Clear, actionable error messages with fix instructions
|
||||
- Summary output at the end (what ran, pass/fail, counts)
|
||||
- shellcheck-clean (no SC warnings)
|
||||
@@ -0,0 +1,73 @@
|
||||
# RecordQuery Feature Gap Analysis
|
||||
_Generated: 2026-02-28_
|
||||
|
||||
## Summary
|
||||
|
||||
The new RecordQuery implementation covers the core query-page surface area well: quick search, advanced filter builder with nested AND/OR groups and possible-value comboboxes, server-side pagination, single-column sort, column show/hide/reorder with drag-and-drop, column resize, saved views, CSV export, bulk process launch, row selection, density picker, grid/card view toggle, variant picker, and mobile filter bottom-sheet. The foundation is solid and the hook architecture (`useRecordQuery`) is clean.
|
||||
|
||||
The primary gaps fall into three categories: (1) **multi-column sort** — the DataGrid sort handler replaces the entire sort array on every click instead of appending to it, so only one column can be sorted at a time; (2) **join-aware filtering** — the FilterBuilder only lists columns from the primary table; joined-table fields exposed via `exposedJoins` cannot be added as filter criteria even though the backend type (`QFilterCriteria`) has no `joinName` field and uses dot-notation `fieldName` instead; (3) **select-all-across-pages / bulk delete** — the selection affordance is page-scoped (only rows visible on the current page can be checked) and the BulkActionBar's delete button is wired up to an `onDeleteSelected` prop that `RecordQuery` never passes, so bulk delete is dead code.
|
||||
|
||||
Several lower-impact features from the legacy implementation are also absent: per-row action buttons (edit/delete inline on each row), inline cell editing, and distinct-count display.
|
||||
|
||||
---
|
||||
|
||||
## Feature Inventory
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Multi-column sort | ⚠️ Partial | State and API types support multiple `orderBys`, but `handleSortColumn` in `DataGrid.tsx` calls `onSortChange([{ fieldName, isAscending: true }])` — a single-element array — on every click, replacing any prior sort. Shift-click to append a secondary sort is not implemented. |
|
||||
| Join filters | ⚠️ Partial | `use-record-query.ts` derives `QueryJoin[]` from `exposedJoins` and sends them to the API. However `FilterBuilder.tsx` builds its field list solely from `tableMetaData.fields` and has no awareness of joined-table fields. Users cannot add filter criteria on joined columns. The `QFilterCriteria` type uses dot-notation `fieldName` (e.g. `"joinedTable.fieldName"`) rather than a separate `joinName` property, so the backend contract is compatible — the UI just never produces such criteria. |
|
||||
| Bulk select across pages | ❌ Not implemented | The select-all checkbox (`grid-select-all`) selects only rows on the current page (TanStack Table `getToggleAllRowsSelectedHandler`). There is no "Select all N matching records" affordance analogous to the legacy "select all" banner. The `BulkActionBar` shows `"{selected} of {total} selected"` but has no way to extend selection beyond the page. |
|
||||
| Export | ✅ Implemented | `ExportButton.tsx` supports three scopes: all records (up to 10,000), current page, and selected records. CSV is built client-side from visible/ordered fields and downloaded via a temporary `<a>` element. No server-side export endpoint is called; this may diverge from the legacy approach for very large datasets or Excel format support. |
|
||||
| Inline edit | ❌ Not implemented | `DataCell.tsx` is read-only. There is no double-click-to-edit or pencil-icon affordance on any cell. The legacy implementation offered inline editing on editable fields. |
|
||||
| Saved views | ✅ Implemented | `SavedViewsMenu.tsx` and the `views` namespace in `use-record-query.ts` fully implement save / load / delete of named views persisted to `localStorage`. Views capture filter, column visibility, column order, and sort order. |
|
||||
| Quick search | ✅ Implemented | Debounced input in `RecordQueryToolbar.tsx` drives an OR-joined `CONTAINS` search across all visible `STRING`/`TEXT` columns. Mutually exclusive with the advanced filter panel. |
|
||||
| Column resizing | ✅ Implemented | `DataGrid.tsx` implements mousedown/mousemove/mouseup column resize with a minimum width of 60 px. Widths are persisted to `localStorage` via `useRecordQuery`. Resize listeners are cleaned up on unmount. |
|
||||
| Column reordering | ✅ Implemented | `ColumnConfig.tsx` supports HTML5 drag-and-drop reordering as well as keyboard Up/Down arrow keys on the grip button. Order is persisted to `localStorage`. Drag-reorder directly in the grid header is not implemented (only via the side panel). |
|
||||
| Pinned columns | ❌ Not implemented | No sticky/pinned column support. TanStack Table supports column pinning but it is not wired up. |
|
||||
| Bulk actions | ⚠️ Partial | Process launch from the bulk bar works (`ProcessLauncherMenu` inside `BulkActionBar`). Bulk delete is dead code — `BulkActionBar` renders a Delete button only when `onDeleteSelected` is provided, but `RecordQueryBulkBar.tsx` never passes that prop. There is no "delete selected" handler anywhere in `RecordQuery.tsx`. |
|
||||
| Row-level actions | ❌ Not implemented | Each row navigates to the record detail view on click but has no per-row action buttons (edit pencil, delete trash, custom process). The legacy implementation rendered per-row action icon buttons in a trailing "actions" column. |
|
||||
| Pagination | ✅ Implemented | Standard offset-based pagination with first/prev/next/last buttons, a page-size selector (10/25/50/100/250), and a "Go to page" input when `totalPages > 5`. Controlled by `use-record-query.ts`. |
|
||||
| Distinct count | ⚠️ Partial | `countRecords` in `tables.ts` accepts `includeDistinct = false` as a third parameter and the `CountRecordsResponse` type includes `distinctCount?`. However `use-record-query.ts` always calls `countRecords(tableName, { filter: countFilter, joins }, false)` (or omits the flag, defaulting to `false`) and the UI never reads or displays `distinctCount`. The infrastructure is there; it is just never invoked or surfaced. |
|
||||
|
||||
---
|
||||
|
||||
## Priority Gaps (implement these next)
|
||||
|
||||
Ranked by user-visible impact on day-to-day admin usage:
|
||||
|
||||
1. **Multi-column sort** — High impact, low effort. Change `handleSortColumn` in `DataGrid.tsx` to detect a Shift-click and append/toggle the clicked column in the existing `sortOrder` array rather than replacing it. Update the column header button to accept a `shiftKey` event attribute. The `QFilterOrderBy[]` structure and API calls already support multiple entries.
|
||||
|
||||
2. **Join filters** — High impact, medium effort. Extend `FilterBuilder.tsx` to accept a `joinedFields` prop (or derive it from `tableMetaData.exposedJoins`). Prefix each joined field's display label with the join table label (e.g. "Order → Customer Name"). Use dot-notation fieldName (e.g. `"customer.name"`) in the emitted `QFilterCriteria`. The backend already expects this format.
|
||||
|
||||
3. **Bulk select across pages** — Medium impact, medium effort. Add a "Select all {N} records" banner that appears below the bulk-action bar when the page-scoped selection equals `pageSize` and `totalCount > pageSize`. Store a `selectAllMode: boolean` flag in state. When `true`, submit the current filter as the selection scope to process launches rather than the explicit ID list.
|
||||
|
||||
4. **Bulk delete** — Medium impact, low effort. Wire up `onDeleteSelected` in `RecordQuery.tsx` (and pass it through `RecordQueryBulkBar`). Implement a confirmation dialog, call `deleteRecord` in a loop (or a future bulk-delete endpoint), then invalidate the records query.
|
||||
|
||||
5. **Row-level actions** — Medium impact, medium effort. Add an "actions" column to `DataGrid.tsx` as the last column. Render icon buttons for Edit (navigate to `/{tableName}/{id}/edit`), Delete (single-record confirmation modal), and optionally a per-row process dropdown if the table's `processes` array contains single-record processes. Guard each button on the respective permission flag from `tableMetaData`.
|
||||
|
||||
6. **Pinned columns** — Low-to-medium impact, medium effort. Enable TanStack Table's `columnPinning` feature. Add a "Pin left" option to the `ColumnConfig` panel or a column header context menu. Apply `position: sticky; left: 0` styling to pinned headers and cells. Persist the pinned set to `localStorage` alongside visibility and order.
|
||||
|
||||
7. **Distinct count display** — Low impact, low effort. Pass `includeDistinct = true` in `countRecords` when `tableMetaData.usesVariants` is true or when the query includes a join with `type: 'LEFT'`. Read `countQuery.data?.distinctCount` and display it alongside or instead of the plain count in the `Pagination` component.
|
||||
|
||||
8. **Inline edit** — Low impact (admin use case), high effort. On double-click of an editable cell, swap `DataCell` for a `DataCellEditor` component that renders an appropriate input (text, number, date, possible-value combobox). On blur or Enter, call `updateRecord` and invalidate the query cache. Requires a new `DataCellEditor` component, field-type-aware input selection, and optimistic update handling.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation Notes
|
||||
|
||||
The following patterns from the legacy `RecordQuery.tsx` (MUI + React) are worth preserving or adapting as the new implementation matures:
|
||||
|
||||
- **Multi-column sort via Shift-click:** The legacy grid accumulates `orderBys` entries when the user holds Shift while clicking a column header. This is the industry-standard UX (matching MUI DataGrid Pro) and should be replicated exactly.
|
||||
|
||||
- **Join filter field grouping:** The legacy FilterBuilder groups fields under their parent table heading in the field selector dropdown (e.g. a separator "— Order fields —" followed by "— Customer fields —"). This makes joined fields discoverable rather than mixed into a flat list.
|
||||
|
||||
- **Select-all banner:** When the user selects all rows on the current page, a blue banner appears: "All 25 records on this page are selected. Select all 1,247 records matching your filter." Clicking the link sets a `filterOnlyMode` flag that replaces the ID list with the filter in process invocations.
|
||||
|
||||
- **Per-row action icons:** The legacy grid renders three small icon buttons at the end of every row (view, edit, delete) that are hidden until hover. They are absolutely positioned so they do not affect column widths. The new DataGrid's trailing-column approach is acceptable but should use `opacity-0 group-hover:opacity-100` CSS to avoid visual noise.
|
||||
|
||||
- **Export via server endpoint (Excel):** The legacy implementation calls a dedicated server-side export endpoint (`POST /table/{name}/export`) that returns an Excel file stream. The current implementation builds a CSV client-side from the raw query results. This has a hard limit of 10,000 rows and produces only CSV. For parity, a server-side export endpoint call should be added; the client-side fallback can remain for smaller datasets.
|
||||
|
||||
- **Distinct count in count query:** The legacy implementation passes `includeDistinctCount=true` for tables that use joins, and displays "N (M distinct)" in the pagination footer when `distinctCount < count`. This helps users understand that a LEFT join is inflating the row count.
|
||||
|
||||
- **Filter variable expressions (NOW, NOW\_WITH\_OFFSET):** The legacy FilterBuilder renders dedicated UI controls for dynamic date expressions so users can save filters like "created in the last 7 days" that remain accurate over time. The types (`NowExpression`, `NowWithOffsetExpression`, `ThisOrLastPeriodExpression`) are already defined in `src/types/query.ts` and `filter-utils.ts` includes type guards and display formatting for them, but the `FilterBuilder` UI never offers these expressions as selectable value options — only static scalar inputs are rendered.
|
||||
+73
-11
@@ -1,16 +1,78 @@
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
})
|
||||
import nextConfig from 'eslint-config-next'
|
||||
import prettierConfig from 'eslint-config-prettier'
|
||||
import jsdoc from 'eslint-plugin-jsdoc'
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
|
||||
...nextConfig,
|
||||
prettierConfig,
|
||||
{
|
||||
rules: {
|
||||
// React Compiler rules — project does not use React Compiler; disable to avoid false positives
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
'react-hooks/incompatible-library': 'off',
|
||||
'react-hooks/globals': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSDoc enforcement — all TypeScript source files except tests and stories
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
ignores: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/**/*.spec.{ts,tsx}',
|
||||
'src/**/*.stories.{ts,tsx}',
|
||||
],
|
||||
plugins: { jsdoc },
|
||||
settings: {
|
||||
jsdoc: {
|
||||
mode: 'typescript',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Require JSDoc on all exported functions, classes, and methods
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
publicOnly: false,
|
||||
require: {
|
||||
FunctionDeclaration: true,
|
||||
MethodDefinition: true,
|
||||
ClassDeclaration: true,
|
||||
ArrowFunctionExpression: false, // too noisy for inline callbacks
|
||||
FunctionExpression: false, // too noisy for inline expressions
|
||||
},
|
||||
checkConstructors: false, // constructors documented on the class
|
||||
contexts: [
|
||||
// Exported arrow functions assigned to a const
|
||||
'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > ArrowFunctionExpression',
|
||||
'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > FunctionExpression',
|
||||
],
|
||||
enableFixer: false,
|
||||
},
|
||||
],
|
||||
|
||||
// Every JSDoc block must have a non-empty description
|
||||
'jsdoc/require-description': ['error', { descriptionStyle: 'any' }],
|
||||
|
||||
// Params and returns
|
||||
'jsdoc/require-param': ['error', { checkDestructured: false }],
|
||||
'jsdoc/require-param-description': 'error',
|
||||
'jsdoc/require-returns': ['error', { forceReturnsWithAsync: false }],
|
||||
'jsdoc/require-returns-description': 'error',
|
||||
|
||||
// Correctness checks
|
||||
'jsdoc/check-param-names': ['error', { checkDestructured: false }],
|
||||
'jsdoc/check-tag-names': ['error', { typed: true }],
|
||||
'jsdoc/no-undefined-types': 'off', // too many false positives with complex TS generics
|
||||
|
||||
// File-level overview
|
||||
'jsdoc/require-file-overview': 'error',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
+28
-3
@@ -1,13 +1,38 @@
|
||||
import type { NextConfig } from 'next'
|
||||
import packageJson from './package.json'
|
||||
|
||||
/**
|
||||
* Two build outputs:
|
||||
* - `QQQ_NEXT_OUTPUT=export`: static files for the QQQ Javalin server to host from its
|
||||
* classpath (the default dashboard). Same origin as the API, so no rewrites.
|
||||
* - otherwise `standalone`: a Node server; `QQQ_BACKEND_URL` bakes same-origin API rewrites
|
||||
* into the build (the quickstart container image).
|
||||
*/
|
||||
const exportBuild = process.env.QQQ_NEXT_OUTPUT === 'export'
|
||||
|
||||
/** Backend route prefixes the frontend calls; forwarded by the standalone server. */
|
||||
const BACKEND_PREFIXES = ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession']
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Reported to the backend as frontendVersion and shown on the developer page.
|
||||
env: { NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version },
|
||||
...(exportBuild
|
||||
? { output: 'export', trailingSlash: true, images: { unoptimized: true } }
|
||||
: {
|
||||
output: 'standalone',
|
||||
async rewrites() {
|
||||
const backend = process.env.QQQ_BACKEND_URL
|
||||
if (!backend) return []
|
||||
return BACKEND_PREFIXES.map((prefix) => ({
|
||||
source: `/${prefix}/:path*`,
|
||||
destination: `${backend}/${prefix}/:path*`,
|
||||
}))
|
||||
},
|
||||
}),
|
||||
typescript: {
|
||||
tsconfigPath: './tsconfig.json',
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
+69
-42
@@ -1,81 +1,108 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"build:export": "QQQ_NEXT_OUTPUT=export NEXT_PUBLIC_MOCK_API=false next build",
|
||||
"build:jar": "pnpm build:export && mvn -B -q package",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src && node scripts/check-license-headers.mjs",
|
||||
"lint:jsdoc": "eslint src --ext .ts,.tsx",
|
||||
"lint:license": "node scripts/check-license-headers.mjs",
|
||||
"lint:license:fix": "node scripts/check-license-headers.mjs --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"tsc": "tsc"
|
||||
"test:acceptance": "node scripts/acceptance.mjs",
|
||||
"tsc": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "git config core.hooksPath .githooks || true",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "^2.2.4",
|
||||
"@auth0/auth0-react": "^2.15.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.8.4",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"dompurify": "^3.4.16",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.485.0",
|
||||
"next": "15.2.3",
|
||||
"oidc-client-ts": "^3.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"recharts": "^2.15.1",
|
||||
"next": "16.3.6",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"ts-md5": "^1.3.1",
|
||||
"universal-cookie": "^7.2.2",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.1",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@storybook/addon-docs": "10.6.0",
|
||||
"@storybook/nextjs": "10.6.0",
|
||||
"@storybook/react": "10.6.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/jest-axe": "^3.5.9",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^22.19.11",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^3.0.9",
|
||||
"@vitest/ui": "^3.0.9",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"@vitest/ui": "^3.2.7",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "16.3.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsdoc": "^62.7.1",
|
||||
"jest-axe": "^10.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
"postcss": "^8.5.28",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "10.6.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"@humanfs/node": ">=0.16.8",
|
||||
"@babel/core": ">=7.29.6 <8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html style='scrollbar-gutter: stable both-edges;'>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='color-scheme' content='dark light'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<title>Playwright Test Report</title>
|
||||
<script type="module">var NA=Object.defineProperty;var BA=(l,u,c)=>u in l?NA(l,u,{enumerable:!0,configurable:!0,writable:!0,value:c}):l[u]=c;var yn=(l,u,c)=>BA(l,typeof u!="symbol"?u+"":u,c);(function(){const u=document.createElement("link").relList;if(u&&u.supports&&u.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))f(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const h of o.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&f(h)}).observe(document,{childList:!0,subtree:!0});function c(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function f(r){if(r.ep)return;r.ep=!0;const o=c(r);fetch(r.href,o)}})();function UA(l){return l&&l.__esModule&&Object.prototype.hasOwnProperty.call(l,"default")?l.default:l}var Mf={exports:{}},Ei={};/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var D1;function QA(){if(D1)return Ei;D1=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.fragment");function c(f,r,o){var h=null;if(o!==void 0&&(h=""+o),r.key!==void 0&&(h=""+r.key),"key"in r){o={};for(var v in r)v!=="key"&&(o[v]=r[v])}else o=r;return r=o.ref,{$$typeof:l,type:f,key:h,ref:r!==void 0?r:null,props:o}}return Ei.Fragment=u,Ei.jsx=c,Ei.jsxs=c,Ei}var M1;function zA(){return M1||(M1=1,Mf.exports=QA()),Mf.exports}var m=zA();const YA=15,xt=0,bn=1,LA=2,ye=-2,Ut=-3,j1=-4,xn=-5,Me=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],L2=1440,GA=0,XA=4,VA=9,ZA=5,qA=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],IA=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],KA=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],kA=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],JA=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,Line truncated
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var T2;function B5(){if(T2)return dt;T2=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),o=Symbol.for("react.consumer"),h=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),A=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),S=Symbol.for("react.activity"),O=Symbol.iterator;function X(C){return C===null||typeof C!="object"?null:(C=O&&C[O]||C["@@iterator"],typeof C=="function"?C:null)}var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},b=Object.assign,p={};function x(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}x.prototype.isReactComponent={},x.prototype.setState=function(C,L){if(typeof C!="object"&&typeof C!="function"&&C!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,C,L,"setState")},x.prototype.forceUpdate=function(C){this.updater.enqueueForceUpdate(this,C,"forceUpdate")};function R(){}R.prototype=x.prototype;function U(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}var Z=U.prototype=new R;Z.constructor=U,b(Z,x.prototype),Z.isPureReactComponent=!0;var F=Array.isArray;function j(){}var D={H:null,A:null,T:null,S:null},N=Object.prototype.hasOwnProperty;function K(C,L,W){var et=W.ref;return{$$typeof:l,type:C,key:L,ref:et!==void 0?et:null,props:W}}function J(C,L){return K(C.type,L,C.props)}function k(C){return typeof C=="object"&&C!==null&&C.$$typeof===l}function nt(C){var L={"=":"=0",":":"=2"};return"$"+C.replace(/[=:]/g,function(W){return L[W]})}var P=/\/+/g;function st(C,L){return typeof C=="object"&&C!==null&&C.key!=null?nt(""+C.key):L.toString(36)}function it(C){switch(C.status){case"fulfilled":return C.value;case"rejected":throw C.reason;default:switch(typeof C.status=="string"?C.then(j,j):(C.status="pending",C.then(function(L){C.status==="pending"&&(C.status="fulfilled",C.value=L)},function(L){C.status==="pending"&&(C.status="rejected",C.reason=L)})),C.status){case"fulfilled":return C.value;case"rejected":throw C.reason}}throw C}function H(C,L,W,et,rt){var ot=typeof C;(ot==="undefined"||ot==="boolean")&&(C=null);var gt=!1;if(C===null)gt=!0;else switch(ot){case"bigint":case"string":case"number":gt=!0;break;case"object":switch(C.$$typeof){case l:case u:gt=!0;break;case E:return gt=C._init,H(gt(C._payload),L,W,et,rt)}}if(gt)return rt=rt(C),gt=et===""?"."+st(C,0):et,F(rt)?(W="",gt!=null&&(W=gt.replace(P,"$&/")+"/"),H(rt,L,W,"",function(On){return On})):rt!=null&&(k(rt)&&(rt=J(rt,W+(rt.key==null||C&&C.key===rt.key?"":(""+rt.key).replace(P,"$&/")+"/")+gt)),L.push(rt)),1;gt=0;var Ft=et===""?".":et+":";if(F(C))for(var Qt=0;Qt<C.length;Qt++)et=C[Qt],ot=Ft+st(et,Qt),gt+=H(et,L,W,ot,rt);else if(Qt=X(C),typeof Qt=="function")for(C=Qt.call(C),Qt=0;!(et=C.next()).done;)et=et.value,ot=Ft+st(et,Qt++),gt+=H(et,L,W,ot,rt);else if(ot==="object"){if(typeof C.then=="function")return H(it(C),L,W,et,rt);throw L=String(C),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(C).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return gt}function _(C,L,W){if(C==null)return C;var et=[],rt=0;return H(C,et,"","",function(ot){return L.call(W,ot,rt++)}),et}function $(C){if(C._status===-1){var L=C._result;L=L(),L.then(function(W){(C._status===0||C._status===-1)&&(C._status=1,C._result=W)},function(W){(C._status===0||C._status===-1)&&(C._status=2,C._result=W)}),C._status===-1&&(C._status=0,C._result=L)}if(C._status===1)return C._result.default;throw C._result}var ht=typeof reportError=="function"?reportError:function(C){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof C=="object"&&C!==null&&typeof C.message=="string"?String(C.message):String(C),error:C});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",C);return}console.error(C)},tt={map:_,forEach:function(C,L,W){_(C,function(){L.apply(this,arguments)},W)},count:function(C){var L=0;return _(C,function(){L++}),L},toArray:function(C){return _(C,function(L){return L})||[]},only:function(C){if(!k(C))throw Error("React.Children.only expected to receive a single React element child.");return C}};return dt.Activity=S,dt.Children=tt,dt.Component=x,dt.Fragment=c,dt.Profiler=r,dt.PureComponent=U,dt.StrictMode=f,dt.Suspense=y,dt.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=D,dt.__COMPILER_RUNTIME={__proto__:null,c:function(C){return D.H.useMemoCache(C)}},dt.cache=function(C){return function(){return C.apply(null,arguments)}},dt.cacheSignal=function(){return null},dt.cloneElement=function(C,L,W){if(C==nullLine truncated
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var O2;function U5(){return O2||(O2=1,(function(l){function u(H,_){var $=H.length;H.push(_);t:for(;0<$;){var ht=$-1>>>1,tt=H[ht];if(0<r(tt,_))H[ht]=_,H[$]=tt,$=ht;else break t}}function c(H){return H.length===0?null:H[0]}function f(H){if(H.length===0)return null;var _=H[0],$=H.pop();if($!==_){H[0]=$;t:for(var ht=0,tt=H.length,C=tt>>>1;ht<C;){var L=2*(ht+1)-1,W=H[L],et=L+1,rt=H[et];if(0>r(W,$))et<tt&&0>r(rt,W)?(H[ht]=rt,H[et]=$,ht=et):(H[ht]=W,H[L]=$,ht=L);else if(et<tt&&0>r(rt,$))H[ht]=rt,H[et]=$,ht=et;else break t}}return _}function r(H,_){var $=H.sortIndex-_.sortIndex;return $!==0?$:H.id-_.id}if(l.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var o=performance;l.unstable_now=function(){return o.now()}}else{var h=Date,v=h.now();l.unstable_now=function(){return h.now()-v}}var y=[],A=[],E=1,S=null,O=3,X=!1,B=!1,b=!1,p=!1,x=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;function Z(H){for(var _=c(A);_!==null;){if(_.callback===null)f(A);else if(_.startTime<=H)f(A),_.sortIndex=_.expirationTime,u(y,_);else break;_=c(A)}}function F(H){if(b=!1,Z(H),!B)if(c(y)!==null)B=!0,j||(j=!0,nt());else{var _=c(A);_!==null&&it(F,_.startTime-H)}}var j=!1,D=-1,N=5,K=-1;function J(){return p?!0:!(l.unstable_now()-K<N)}function k(){if(p=!1,j){var H=l.unstable_now();K=H;var _=!0;try{t:{B=!1,b&&(b=!1,R(D),D=-1),X=!0;var $=O;try{e:{for(Z(H),S=c(y);S!==null&&!(S.expirationTime>H&&J());){var ht=S.callback;if(typeof ht=="function"){S.callback=null,O=S.priorityLevel;var tt=ht(S.expirationTime<=H);if(H=l.unstable_now(),typeof tt=="function"){S.callback=tt,Z(H),_=!0;break e}S===c(y)&&f(y),Z(H)}else f(y);S=c(y)}if(S!==null)_=!0;else{var C=c(A);C!==null&&it(F,C.startTime-H),_=!1}}break t}finally{S=null,O=$,X=!1}_=void 0}}finally{_?nt():j=!1}}}var nt;if(typeof U=="function")nt=function(){U(k)};else if(typeof MessageChannel<"u"){var P=new MessageChannel,st=P.port2;P.port1.onmessage=k,nt=function(){st.postMessage(null)}}else nt=function(){x(k,0)};function it(H,_){D=x(function(){H(l.unstable_now())},_)}l.unstable_IdlePriority=5,l.unstable_ImmediatePriority=1,l.unstable_LowPriority=4,l.unstable_NormalPriority=3,l.unstable_Profiling=null,l.unstable_UserBlockingPriority=2,l.unstable_cancelCallback=function(H){H.callback=null},l.unstable_forceFrameRate=function(H){0>H||125<H?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):N=0<H?Math.floor(1e3/H):5},l.unstable_getCurrentPriorityLevel=function(){return O},l.unstable_next=function(H){switch(O){case 1:case 2:case 3:var _=3;break;default:_=O}var $=O;O=_;try{return H()}finally{O=$}},l.unstable_requestPaint=function(){p=!0},l.unstable_runWithPriority=function(H,_){switch(H){case 1:case 2:case 3:case 4:case 5:break;default:H=3}var $=O;O=H;try{return _()}finally{O=$}},l.unstable_scheduleCallback=function(H,_,$){var ht=l.unstable_now();switch(typeof $=="object"&&$!==null?($=$.delay,$=typeof $=="number"&&0<$?ht+$:ht):$=ht,H){case 1:var tt=-1;break;case 2:tt=250;break;case 5:tt=1073741823;break;case 4:tt=1e4;break;default:tt=5e3}return tt=$+tt,H={id:E++,callback:_,priorityLevel:H,startTime:$,expirationTime:tt,sortIndex:-1},$>ht?(H.sortIndex=$,u(A,H),c(y)===null&&H===c(A)&&(b?(R(D),D=-1):b=!0,it(F,$-ht))):(H.sortIndex=tt,u(y,H),B||X||(B=!0,j||(j=!0,nt()))),H},l.unstable_shouldYield=J,l.unstable_wrapCallback=function(H){var _=O;return function(){var $=O;O=_;try{return H.apply(this,arguments)}finally{O=$}}}})(qf)),qf}var w2;function Q5(){return w2||(w2=1,Zf.exports=U5()),Zf.exports}var If={exports:{}},ce={};/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var R2;function z5(){if(R2)return ce;R2=1;var l=xr();function u(y){var A="https://react.dev/errors/"+y;if(1<arguments.length){A+="?args[]="+encodeURIComponent(arguments[1]);for(var E=2;E<arguments.length;E++)A+="&args[]="+encodeURIComponent(arguments[E])}return"Minified React error #"+y+"; visit "+A+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function c(){}var f={d:{f:c,r:function(){throw Error(u(522))},D:c,C:c,L:c,m:c,X:c,S:c,M:c},p:0,findDOMNode:null},r=Symbol.for("react.portal");function o(y,A,E){var S=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:r,key:S==null?null:""+S,children:y,containerInfo:A,implementation:E}}var h=l.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function v(y,A){if(y==="font")return"";if(typeof A=="string")return A==="use-credentials"?A:""}return ce.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ce.createPortal=function(y,A){var E=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!A||A.nodeType!==1&&A.nodeType!==9&&A.nodeType!==11)throw Error(u(299));return o(y,A,null,E)},ce.flushSync=function(y){var A=h.T,E=f.p;try{if(h.T=null,f.p=2,y)return y()}finally{h.T=A,f.p=E,f.d.f()}},ce.preconnect=function(y,A){typeof y=="string"&&(A?(A=A.crossOrigin,A=typeof A=="string"?A==="use-credentials"?A:"":void 0):A=null,f.d.C(y,A))},ce.prefetchDNS=function(y){typeof y=="string"&&f.d.D(y)},ce.preinit=function(y,A){if(typeof y=="string"&&A&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin),O=typeof A.integrity=="string"?A.integrity:void 0,X=typeof A.fetchPriority=="string"?A.fetchPriority:void 0;E==="style"?f.d.S(y,typeof A.precedence=="string"?A.precedence:void 0,{crossOrigin:S,integrity:O,fetchPriority:X}):E==="script"&&f.d.X(y,{crossOrigin:S,integrity:O,fetchPriority:X,nonce:typeof A.nonce=="string"?A.nonce:void 0})}},ce.preinitModule=function(y,A){if(typeof y=="string")if(typeof A=="object"&&A!==null){if(A.as==null||A.as==="script"){var E=v(A.as,A.crossOrigin);f.d.M(y,{crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0})}}else A==null&&f.d.M(y)},ce.preload=function(y,A){if(typeof y=="string"&&typeof A=="object"&&A!==null&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin);f.d.L(y,E,{crossOrigin:S,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0,type:typeof A.type=="string"?A.type:void 0,fetchPriority:typeof A.fetchPriority=="string"?A.fetchPriority:void 0,referrerPolicy:typeof A.referrerPolicy=="string"?A.referrerPolicy:void 0,imageSrcSet:typeof A.imageSrcSet=="string"?A.imageSrcSet:void 0,imageSizes:typeof A.imageSizes=="string"?A.imageSizes:void 0,media:typeof A.media=="string"?A.media:void 0})}},ce.preloadModule=function(y,A){if(typeof y=="string")if(A){var E=v(A.as,A.crossOrigin);f.d.m(y,{as:typeof A.as=="string"&&A.as!=="script"?A.as:void 0,crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0})}else f.d.m(y)},ce.requestFormReset=function(y){f.d.r(y)},ce.unstable_batchedUpdates=function(y,A){return y(A)},ce.useFormState=function(y,A,E){return h.H.useFormState(y,A,E)},ce.useFormStatus=function(){return h.H.useHostTransitionStatus()},ce.version="19.2.1",ce}var D2;function Y5(){if(D2)return If.exports;D2=1;function l(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(l)}catch(u){console.error(u)}}return l(),If.exports=z5(),If.exports}/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var M2;function L5(){if(M2)return xi;M2=1;var l=Q5(),u=xr(),c=Y5();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)e+="&args[]="+encodeURIComponent(arguments[n])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function r(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function o(t){var e=t,n=t;if(t.alternate)for(;e.return;)e=e.return;else{t=e;do e=t,(e.flags&4098)!==0&&(n=e.return),t=e.return;while(t)}return e.tag===3?n:null}function h(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function v(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function y(t){if(o(t)!==t)throw Error(f(188))}function A(t){var e=t.alternate;if(!e){if(e=o(t),e===null)throw Error(f(188));return e!==t?null:t}for(var n=t,a=e;;){var i=n.return;if(i===null)break;var s=i.alternate;if(s===null){if(a=i.return,a!==null){n=a;continue}break}if(i.child===s.child){for(s=i.child;s;){if(s===n)return y(i),t;if(s===a)return y(i),e;s=s.sibling}throw Error(f(188))}if(n.return!==a.return)n=i,a=s;else{for(var d=!1,g=i.child;g;){if(g===n){d=!0,n=i,a=s;break}if(g===a){d=!0,a=i,n=s;break}g=g.sibling}if(!d){for(g=s.child;g;){if(g===n){d=!0,n=s,a=i;break}if(g===a){d=!0,a=s,n=i;break}g=g.sibling}if(!d)throw Error(f(189))}}if(n.alternate!==a)throw Error(f(190))}if(n.tag!==3)throw Error(f(188));return n.stateNode.current===n?t:e}function E(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=E(t),e!==null)return e;t=t.sibling}return null}var S=Object.assign,O=Symbol.for("react.element"),X=Symbol.for("react.transitional.element"),B=Symbol.for("react.portal"),b=Symbol.for("react.fragment"),p=Symbol.for("react.strict_mode"),x=Symbol.for("react.profiler"),R=Symbol.for("react.consumer"),U=Symbol.for("react.context"),Z=Symbol.for("react.forward_ref"),F=Symbol.for("react.suspense"),j=Symbol.for("react.suspense_list"),D=Symbol.for("react.memo"),N=Symbol.for("react.lazy"),K=Symbol.for("react.activity"),J=Symbol.for("react.memo_cache_sentinel"),k=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=k&&t[k]||t["@@iterator"],typeof t=="function"?t:null)}var P=Symbol.for("react.client.reference");function st(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===P?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case b:return"Fragment";case x:return"Profiler";case p:return"StrictMode";case F:return"Suspense";case j:return"SuspenseList";case K:return"Activity"}if(typeof t=="object")switch(t.$$typeof){case B:return"Portal";case U:return t.displayName||"Context";case R:return(t._context.displayName||"Context")+".Consumer";case Z:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case D:return e=t.displayName||null,e!==null?e:st(t.type)||"Memo";case N:e=t._payload,t=t._init;try{return st(t(e))}catch{}}return null}var it=Array.isArray,H=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,_=c.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,$={pending:!1,data:null,method:null,action:null},ht=[],tt=-1;function C(t){return{current:t}}function L(t){0>tt||(t.current=ht[tt],ht[tt]=null,tt--)}function W(t,e){tt++,ht[tt]=t.current,t.current=e}var et=C(null),rt=C(null),ot=C(null),gt=C(null);function Ft(t,e){switch(W(ot,e),W(rt,t),W(et,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?Pd(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=Pd(e),t=$d(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}L(et),W(et,t)}function Qt(){L(et),L(rt),L(ot)}function On(t){t.memoizedState!==null&&W(gt,t);var e=et.current,n=$d(e,t.type);e!==n&&(W(rt,t),W(et,n))}function ra(t){rt.current===t&&(L(et),L(rt)),gt.current===t&&(L(gt),gi._currentValue=$)}var wn,Ba;function Ee(t){if(wn===void 0)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);wn=e&&e[1]||"",Ba=-1<n.stack.indexOf(`
|
||||
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+wn+t+Ba}var Ua=!1;function Rn(t,e){if(!t||Ua)return"";Ua=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var I=function(){throw Error()};if(Object.defineProperty(I.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(I,[])}catch(G){var Y=G}Reflect.construct(t,[],I)}else{try{I.call()}catch(G){Y=G}t.call(I.prototype)}}else{try{throw Error()}catch(G){Y=G}(I=t())&&typeof I.catch=="function"&&I.catch(function(){})}}catch(G){if(G&&Y&&typeof G.stack=="string")return[G.stack,Y.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var i=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");i&&i.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var s=a.DetermineComponentFrameRoot(),d=s[0],g=s[1];if(d&&g){var T=d.split(`
|
||||
`),z=g.split(`
|
||||
`);for(i=a=0;a<T.length&&!T[a].includes("DetermineComponentFrameRoot");)a++;for(;i<z.length&&!z[i].includes("DetermineComponentFrameRoot");)i++;if(a===T.length||i===z.length)for(a=T.length-1,i=z.length-1;1<=a&&0<=i&&T[a]!==z[i];)i--;for(;1<=a&&0<=i;a--,i--)if(T[a]!==z[i]){if(a!==1||i!==1)do if(a--,i--,0>i||T[a]!==z[i]){var V=`
|
||||
`+T[a].replace(" at new "," at ");return t.displayName&&V.includes("<anonymous>")&&(V=V.replace("<anonymous>",t.displayName)),V}while(1<=a&&0<=i);break}}}finally{Ua=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?Ee(n):""}function Qa(t,e){switch(t.tag){case 26:case 27:case 5:return Ee(t.type);case 16:return Ee("Lazy");case 13:return t.child!==e&&e!==null?Ee("Suspense Fallback"):Ee("Suspense");case 19:return Ee("SuspenseList");case 0:case 15:return Rn(t.type,!1);case 11:return Rn(t.type.render,!1);case 1:return Rn(t.type,!0);case 31:return Ee("Activity");default:return""}}function Bi(t){try{var e="",n=null;do e+=Qa(t,n),n=t,t=t.return;while(t);return e}catch(a){return`
|
||||
Error generating stack: `+a.message+`
|
||||
`+a.stack}}var mc=Object.prototype.hasOwnProperty,gc=l.unstable_scheduleCallback,Ac=l.unstable_cancelCallback,om=l.unstable_shouldYield,dm=l.unstable_requestPaint,pe=l.unstable_now,hm=l.unstable_getCurrentPriorityLevel,Dr=l.unstable_ImmediatePriority,Mr=l.unstable_UserBlockingPriority,Ui=l.unstable_NormalPriority,mm=l.unstable_LowPriority,jr=l.unstable_IdlePriority,gm=l.log,Am=l.unstable_setDisableYieldValue,wl=null,be=null;function Dn(t){if(typeof gm=="function"&&Am(t),be&&typeof be.setStrictMode=="function")try{be.setStrictMode(wl,t)}catch{}}var xe=Math.clz32?Math.clz32:Em,vm=Math.log,ym=Math.LN2;function Em(t){return t>>>=0,t===0?32:31-(vm(t)/ym|0)|0}var Qi=256,zi=262144,Yi=4194304;function oa(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Li(t,e,n){var a=t.pendingLanes;if(a===0)return 0;var i=0,s=t.suspendedLanes,d=t.pingedLanes;t=t.warmLanes;var g=a&134217727;return g!==0?(a=g&~s,a!==0?i=oa(a):(d&=g,d!==0?i=oa(d):n||(n=g&~t,n!==0&&(i=oa(n))))):(g=a&~s,g!==0?i=oa(g):d!==0?i=oa(d):n||(n=a&~t,n!==0&&(i=oa(n)))),i===0?0:e!==0&&e!==i&&(e&s)===0&&(s=i&-i,n=e&-e,s>=n||s===32&&(n&4194048)!==0)?e:i}function Rl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function pm(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hr(){var t=Yi;return Yi<<=1,(Yi&62914560)===0&&(Yi=4194304),t}function vc(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function Dl(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function bm(t,e,n,a,i,s){var d=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var g=t.entanglements,T=t.expirationTimes,z=t.hiddenUpdates;for(n=d&~n;0<n;){var V=31-xe(n),I=1<<V;g[V]=0,T[V]=-1;var Y=z[V];if(Y!==null)for(z[V]=null,V=0;V<Y.length;V++){var G=Y[V];G!==null&&(G.lane&=-536870913)}n&=~I}a!==0&&Nr(t,a,0),s!==0&&i===0&&t.tag!==0&&(t.suspendedLanes|=s&~(d&~e))}function Nr(t,e,n){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-xe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|n&261930}function Br(t,e){var n=t.entangledLanes|=e;for(t=t.entanglements;n;){var a=31-xe(n),i=1<<a;i&e|t[a]&e&&(t[a]|=e),n&=~i}}function Ur(t,e){var n=e&-e;return n=(n&42)!==0?1:yc(n),(n&(t.suspendedLanes|e))!==0?0:n}function yc(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Ec(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function Qr(){var t=_.p;return t!==0?t:(t=window.event,t===void 0?32:x1(t.type))}function zr(t,e){var n=_.p;try{return _.p=t,e()}finally{_.p=n}}var Mn=Math.random().toString(36).slice(2),te="__reactFiber$"+Mn,oe="__reactProps$"+Mn,za="__reactContainer$"+Mn,pc="__reactEvents$"+Mn,xm="__reactListeners$"+Mn,Sm="__reactHandles$"+Mn,Yr="__reactResources$"+Mn,Ml="__reactMarker$"+Mn;function bc(t){delete t[te],delete t[oe],delete t[pc],delete t[xm],delete t[Sm]}function Ya(t){var e=t[te];if(e)return e;for(var n=t.parentNode;n;){if(e=n[za]||n[te]){if(n=e.alternate,e.child!==null||n!==null&&n.child!==null)for(t=u1(t);t!==null;){if(n=t[te])return n;t=u1(t)}return e}t=n,n=t.parentNode}return null}function La(t){if(t=t[te]||t[za]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function jl(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Ga(t){var e=t[Yr];return e||(e=t[Yr]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function Wt(t){t[Ml]=!0}var Lr=new Set,Gr={};function da(t,e){Xa(t,e),Xa(t+"Capture",e)}function Xa(t,e){for(Gr[t]=e,t=0;t<e.length;t++)Lr.add(e[t])}var Tm=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21Line truncated
|
||||
`).replace(eA,"")}function Wd(t,e){return e=Fd(e),Fd(t)===e}function Dt(t,e,n,a,i,s){switch(n){case"children":typeof a=="string"?e==="body"||e==="textarea"&&a===""||Za(t,a):(typeof a=="number"||typeof a=="bigint")&&e!=="body"&&Za(t,""+a);break;case"className":Xi(t,"class",a);break;case"tabIndex":Xi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Xi(t,n,a);break;case"style":Fr(t,a,s);break;case"data":if(e!=="object"){Xi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||n!=="href")){t.removeAttribute(n);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof s=="function"&&(n==="formAction"?(e!=="input"&&Dt(t,e,"name",i.name,i,null),Dt(t,e,"formEncType",i.formEncType,i,null),Dt(t,e,"formMethod",i.formMethod,i,null),Dt(t,e,"formTarget",i.formTarget,i,null)):(Dt(t,e,"encType",i.encType,i,null),Dt(t,e,"method",i.method,i,null),Dt(t,e,"target",i.target,i,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"onClick":a!=null&&(t.onclick=tn);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}n=Zi(""+a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""+a):t.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""):t.removeAttribute(n);break;case"capture":case"download":a===!0?t.setAttribute(n,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,a):t.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(n,a):t.removeAttribute(n);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(n):t.setAttribute(n,a);break;case"popover":yt("beforetoggle",t),yt("toggle",t),Gi(t,"popover",a);break;case"xlinkActuate":$e(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":$e(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":$e(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":$e(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":$e(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":$e(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":$e(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":$e(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":$e(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Gi(t,"is",a);break;case"innerText":case"textContent":break;default:(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")&&(n=Dm.get(n)||n,Gi(t,n,a))}}function df(t,e,n,a,i,s){switch(n){case"style":Fr(t,a,s);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"children":typeof a=="string"?Za(t,a):(typeof a=="number"||typeof a=="bigint")&&Za(t,""+a);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"onClick":a!=null&&(t.onclick=tn);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inneLine truncated
|
||||
linear-gradient(-45deg, #80808020 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #80808020 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #80808020 75%)`,backgroundSize:"20px 20px",backgroundPosition:"0 0, 0 10px, 10px -10px, -10px 0px",boxShadow:`rgb(0 0 0 / 10%) 0px 1.8px 1.9px,
|
||||
rgb(0 0 0 / 15%) 0px 6.1px 6.3px,
|
||||
rgb(0 0 0 / 10%) 0px -2px 4px,
|
||||
rgb(0 0 0 / 15%) 0px -6.1px 12px,
|
||||
rgb(0 0 0 / 25%) 0px 6px 12px`},um=({diff:l,noTargetBlank:u,hideDetails:c})=>{const[f,r]=ct.useState(l.diff?"diff":"actual"),[o,h]=ct.useState(!1),[v,y]=ct.useState(null),[A,E]=ct.useState("Expected"),[S,O]=ct.useState(null),[X,B]=ct.useState(null),[b,p]=Wh();ct.useEffect(()=>{(async()=>{var K,J,k,nt;y(await kf((K=l.expected)==null?void 0:K.attachment.path)),E(((J=l.expected)==null?void 0:J.title)||"Expected"),O(await kf((k=l.actual)==null?void 0:k.attachment.path)),B(await kf((nt=l.diff)==null?void 0:nt.attachment.path))})()},[l]);const x=v&&S&&X,R=x?Math.max(v.naturalWidth,S.naturalWidth,200):500,U=x?Math.max(v.naturalHeight,S.naturalHeight,200):500,Z=Math.min(1,(b.width-30)/R),F=Math.min(1,(b.width-50)/R/2),j=R*Z,D=U*Z,N={flex:"none",margin:"0 10px",cursor:"pointer",userSelect:"none"};return m.jsx("div",{"data-testid":"test-result-image-mismatch",style:{display:"flex",flexDirection:"column",alignItems:"center",flex:"auto"},ref:p,children:x&&m.jsxs(m.Fragment,{children:[m.jsxs("div",{"data-testid":"test-result-image-mismatch-tabs",style:{display:"flex",margin:"10px 0 20px"},children:[l.diff&&m.jsx("div",{style:{...N,fontWeight:f==="diff"?600:"initial"},onClick:()=>r("diff"),children:"Diff"}),m.jsx("div",{style:{...N,fontWeight:f==="actual"?600:"initial"},onClick:()=>r("actual"),children:"Actual"}),m.jsx("div",{style:{...N,fontWeight:f==="expected"?600:"initial"},onClick:()=>r("expected"),children:A}),m.jsx("div",{style:{...N,fontWeight:f==="sxs"?600:"initial"},onClick:()=>r("sxs"),children:"Side by side"}),m.jsx("div",{style:{...N,fontWeight:f==="slider"?600:"initial"},onClick:()=>r("slider"),children:"Slider"})]}),m.jsxs("div",{style:{display:"flex",justifyContent:"center",flex:"auto",minHeight:D+60},children:[l.diff&&f==="diff"&&m.jsx(En,{image:X,alt:"Diff",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="actual"&&m.jsx(En,{image:S,alt:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="expected"&&m.jsx(En,{image:v,alt:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="slider"&&m.jsx(Dv,{expectedImage:v,actualImage:S,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z,expectedTitle:A}),l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:o?X:S,title:o?"Diff":"Actual",onClick:()=>h(!o),hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F})]}),!l.diff&&f==="actual"&&m.jsx(En,{image:S,title:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="expected"&&m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:S,title:"Actual",canvasWidth:F*R,canvasHeight:F*U,scale:F})]})]}),!c&&m.jsxs("div",{style:{alignSelf:"start",lineHeight:"18px",marginLeft:"15px"},children:[m.jsx("div",{children:l.diff&&m.jsx("a",{target:"_blank",href:l.diff.attachment.path,rel:"noreferrer",children:l.diff.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.actual.attachment.path,rel:"noreferrer",children:l.actual.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.expected.attachment.path,rel:"noreferrer",children:l.expected.attachment.name})})]})]})})},Dv=({expectedImage:l,actualImage:u,canvasWidth:c,canvasHeight:f,scale:r,expectedTitle:o,hideSize:h})=>{const v={position:"absolute",top:0,left:0},[y,A]=ct.useState(c/2),E=l.naturalWidth===u.naturalWidth&&l.naturalHeight===u.naturalHeight;return m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column",userSelect:"none"},children:[!h&&m.jsxs("div",{style:{margin:5},children:[!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"Expected "}),m.jsx("span",{children:l.naturalWidth}),m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),m.jsx("span",{children:l.naturalHeight}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px 0 15px"},children:"Actual "}),!E&&m.jsx("span",{children:u.naturalWidth}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),!E&&m.jsx("span",{children:u.naturalHeight})]}),m.jsxs("div",{style:{position:"relative",width:c,height:f,margin:15,...fr},children:[m.jsx(Rv,{orientation:"horizontal",offsets:[y],setOffsets:S=>A(S[0]),resizerColor:"#57606a80",resizerWidth:6}),m.jsx("img",{alt:o,style:{width:l.naturalWidth*r,height:l.naturalHeight*r},draggable:"false",src:l.src}),m.jsx("div",{style:{...v,bottom:0,overflow:"hidden",width:y,...fr},children:m.jsx("img",{alt:"Actual",style:{width:u.naturalWidth*r,height:u.naturalHeight*r},draggable:"false",src:u.src})})]})]})},En=({image:l,title:u,alt:c,hideSize:f,canvasWidth:r,canvasHeight:o,scale:h,onClick:v})=>m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column"},children:[!f&&m.jsxs("div",{style:{margin:5},childrenLine truncated
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
`.trimStart();async function zv({testInfo:l,metadata:u,errorContext:c,errors:f,buildCodeFrame:r,stdout:o,stderr:h}){var S;const v=new Set(f.filter(O=>O.message&&!O.message.includes(`
|
||||
`)).map(O=>O.message));for(const O of f)for(const X of v.keys())(S=O.message)!=null&&S.includes(X)&&v.delete(X);const y=f.filter(O=>!(!O.message||!O.message.includes(`
|
||||
`)&&!v.has(O.message)));if(!y.length)return;const A=[Qv,"# Test info","",l];o&&A.push("","# Stdout","","```",Jf(o),"```"),h&&A.push("","# Stderr","","```",Jf(h),"```"),A.push("","# Error details");for(const O of y)A.push("","```",Jf(O.message||""),"```");c&&A.push(c);const E=await r(y[y.length-1]);return E&&A.push("","# Test source","","```ts",E,"```"),u!=null&&u.gitDiff&&A.push("","# Local changes","","```diff",u.gitDiff,"```"),A.join(`
|
||||
`)}const Yv=new RegExp("([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))","g");function Jf(l){return l.replace(Yv,"")}function Lv(l,u){var f;const c=new Map;for(const r of l){const o=r.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);if(!o)continue;const[,h,v,y=""]=o,A=h+y;let E=c.get(A);E||(E={name:A,anchors:[`attachment-${h}`]},c.set(A,E)),E.anchors.push(`attachment-${u.attachments.indexOf(r)}`),v==="actual"&&(E.actual={attachment:r}),v==="expected"&&(E.expected={attachment:r,title:"Expected"}),v==="previous"&&(E.expected={attachment:r,title:"Previous"}),v==="diff"&&(E.diff={attachment:r})}for(const[r,o]of c)!o.actual||!o.expected?c.delete(r):(l.delete(o.actual.attachment),l.delete(o.expected.attachment),l.delete((f=o.diff)==null?void 0:f.attachment));return[...c.values()]}const Gv=({test:l,result:u,testRunMetadata:c,options:f})=>{const{screenshots:r,videos:o,traces:h,otherAttachments:v,diffs:y,errors:A,otherAttachmentAnchors:E,screenshotAnchors:S,errorContext:O}=ct.useMemo(()=>{const B=u.attachments.filter(N=>!N.name.startsWith("_")),b=new Set(B.filter(N=>N.contentType.startsWith("image/"))),p=[...b].map(N=>`attachment-${B.indexOf(N)}`),x=B.filter(N=>N.contentType.startsWith("video/")),R=B.filter(N=>N.name==="trace"),U=B.find(N=>N.name==="error-context"),Z=new Set(B);[...b,...x,...R].forEach(N=>Z.delete(N));const F=[...Z].map(N=>`attachment-${B.indexOf(N)}`),j=Lv(b,u),D=u.errors.map(N=>N.message);return{screenshots:[...b],videos:x,traces:R,otherAttachments:Z,diffs:j,errors:D,otherAttachmentAnchors:F,screenshotAnchors:p,errorContext:U}},[u]),X=P5(async()=>{if(f!=null&&f.noCopyPrompt)return;const B=u.attachments.find(R=>R.name==="stdout"),b=u.attachments.find(R=>R.name==="stderr"),p=B!=null&&B.body&&B.contentType==="text/plain"?B.body:void 0,x=b!=null&&b.body&&b.contentType==="text/plain"?b.body:void 0;return await zv({testInfo:[`- Name: ${l.path.join(" >> ")} >> ${l.title}`,`- Location: ${l.location.file}:${l.location.line}:${l.location.column}`].join(`
|
||||
`),metadata:c,errorContext:O!=null&&O.path?await fetch(O.path).then(R=>R.text()):O==null?void 0:O.body,errors:u.errors,buildCodeFrame:async R=>R.codeframe,stdout:p,stderr:x})},[l,O,c,u],void 0);return m.jsxs("div",{className:"test-result",children:[!!A.length&&m.jsxs(ke,{header:"Errors",children:[X&&m.jsx("div",{style:{position:"absolute",right:"16px",padding:"10px",zIndex:1},children:m.jsx(Nv,{prompt:X})}),A.map((B,b)=>{const p=Xv(B,y);return m.jsxs(m.Fragment,{children:[m.jsx(wr,{code:B},"test-result-error-message-"+b),p&&m.jsx(Bv,{diff:p})]})})]}),!!u.steps.length&&m.jsx(ke,{header:"Test Steps",children:u.steps.map((B,b)=>m.jsx(cm,{step:B,result:u,test:l,depth:0},`step-${b}`))}),y.map((B,b)=>m.jsx(Si,{id:B.anchors,children:m.jsx(ke,{dataTestId:"test-results-image-diff",header:`Image mismatch: ${B.name}`,revealOnAnchorId:B.anchors,children:m.jsx(um,{diff:B})})},`diff-${b}`)),!!r.length&&m.jsx(ke,{header:"Screenshots",revealOnAnchorId:S,children:r.map((B,b)=>m.jsxs(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:[m.jsx("a",{href:Ve(B.path),children:m.jsx("img",{className:"screenshot",src:Ve(B.path)})}),m.jsx(nc,{attachment:B,result:u})]},`screenshot-${b}`))}),!!h.length&&m.jsx(Si,{id:"attachment-trace",children:m.jsx(ke,{header:"Traces",revealOnAnchorId:"attachment-trace",children:m.jsxs("div",{children:[m.jsx("a",{href:Ve(nm(h)),children:m.jsx("img",{className:"screenshot",src:Cv,style:{width:192,height:117,marginLeft:20}})}),h.map((B,b)=>m.jsx(nc,{attachment:B,result:u,linkName:h.length===1?"trace":`trace-${b+1}`},`trace-${b}`))]})})}),!!o.length&&m.jsx(Si,{id:"attachment-video",children:m.jsx(ke,{header:"Videos",revealOnAnchorId:"attachment-video",children:o.map(B=>m.jsxs("div",{children:[m.jsx("video",{controls:!0,children:m.jsx("source",{src:Ve(B.path),type:B.contentType})}),m.jsx(nc,{attachment:B,result:u})]},B.path))})}),!!v.size&&m.jsx(ke,{header:"Attachments",revealOnAnchorId:E,dataTestId:"attachments",children:[...v].map((B,b)=>m.jsx(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:m.jsx(nc,{attachment:B,result:u,openInNewTab:B.contentType.startsWith("text/html")})},`attachment-link-${b}`))})]})};function Xv(l,u){const c=l.split(`
|
||||
`)[0];if(!(!c.includes("toHaveScreenshot")&&!c.includes("toMatchSnapshot")))return u.find(f=>l.includes(f.name))}const cm=({test:l,step:u,result:c,depth:f})=>{const r=se();return m.jsx(Tv,{title:m.jsxs("div",{"aria-label":u.title,className:"step-title-container",children:[hc(u.error||u.duration===-1?"failed":u.skipped?"skipped":"passed"),m.jsxs("span",{className:"step-title-text",children:[m.jsx("span",{children:u.title}),u.count>1&&m.jsxs(m.Fragment,{children:[" ✕ ",m.jsx("span",{className:"test-result-counter",children:u.count})]}),u.location&&m.jsxs("span",{className:"test-result-path",children:["— ",u.location.file,":",u.location.line]})]}),m.jsx("span",{className:"step-spacer"}),u.attachments.length>0&&m.jsx("a",{className:"step-attachment-link",title:"reveal attachment",href:Ve(Cn({test:l,result:c,anchor:`attachment-${u.attachments[0]}`},r)),onClick:o=>{o.stopPropagation()},children:Ih()}),m.jsx("span",{className:"step-duration",children:Ol(u.duration)})]}),loadChildren:u.steps.length||u.snippet?()=>{const o=u.snippet?[m.jsx(wr,{testId:"test-snippet",code:u.snippet},"line")]:[],h=u.steps.map((v,y)=>m.jsx(cm,{step:v,depth:f+1,result:c,test:l},y));return o.concat(h)}:void 0,depth:f})},Vv=({projectNames:l,test:u,testRunMetadata:c,run:f,next:r,prev:o,options:h})=>{const[v,y]=ct.useState(f),A=se(),E=u.annotations.filter(S=>!S.type.startsWith("_"))??[];return m.jsxs(m.Fragment,{children:[m.jsx(Or,{title:u.title,leftSuperHeader:m.jsx("div",{className:"test-case-path",children:u.path.join(" › ")}),rightSuperHeader:m.jsxs(m.Fragment,{children:[m.jsx("div",{className:Ze(!o&&"hidden"),children:m.jsx(Tn,{href:Cn({test:o},A),children:"« previous"})}),m.jsx("div",{style:{width:10}}),m.jsx("div",{className:Ze(!r&&"hidden"),children:m.jsx(Tn,{href:Cn({test:r},A),children:"next »"})})]})}),m.jsxs("div",{className:"hbox",style:{lineHeight:"24px"},children:[m.jsx("div",{className:"test-case-location",children:m.jsxs(Sr,{value:`${u.location.file}:${u.location.line}`,children:[u.location.file,":",u.location.line]})}),m.jsx("div",{style:{flex:"auto"}}),m.jsx(tm,{test:u,trailingSeparator:!0}),m.jsx("div",{className:"test-case-duration",children:Ol(u.duration)})]}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:l,activeProjectName:u.projectName,otherLabels:u.tags}),u.results.length===0&&E.length!==0&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:E.map((S,O)=>m.jsx(z2,{annotation:S},O))}),m.jsx(Sv,{tabs:u.results.map((S,O)=>({id:String(O),title:m.jsxs("div",{style:{display:"flex",alignItems:"center"},children:[hc(S.status)," ",Zv(O),u.results.length>1&&m.jsx("span",{className:"test-case-run-duration",children:Ol(S.duration)})]}),render:()=>{const X=S.annotations.filter(B=>!B.type.startsWith("_"));return m.jsxs(m.Fragment,{children:[!!X.length&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:X.map((B,b)=>m.jsx(z2,{annotation:B},b))}),m.jsx(Gv,{test:u,result:S,testRunMetadata:c,options:h})]})}}))||[],selectedTab:String(v),setSelectedTab:S=>y(+S)})]})};function z2({annotation:{type:l,description:u}}){return m.jsxs("div",{className:"test-case-annotation",children:[m.jsx("span",{style:{fontWeight:"bold"},children:l}),u&&m.jsxs(Sr,{value:u,children:[": ",Di(u)]})]})}function Zv(l){return l?`Retry #${l}`:"Run"}const sm=({file:l,projectNames:u,isFileExpanded:c,setFileExpanded:f,footer:r})=>{const o=se();return m.jsx(im,{expanded:c?c(l.fileId):void 0,noInsets:!0,setExpanded:f?(h=>f(l.fileId,h)):void 0,header:m.jsx("span",{className:"chip-header-allow-selection",children:l.fileName}),footer:r,children:l.tests.map(h=>m.jsxs("div",{className:Ze("test-file-test","test-file-test-outcome-"+h.outcome),children:[m.jsxs("div",{className:"hbox",style:{alignItems:"flex-start"},children:[m.jsxs("div",{className:"hbox",children:[m.jsx("span",{className:"test-file-test-status-icon",children:hc(h.outcome)}),m.jsxs("span",{children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),children:m.jsx("span",{className:"test-file-title",children:[...h.path,h.title].join(" › ")})}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:u,activeProjectName:h.projectName,otherLabels:h.tags})]})]}),m.jsx("span",{"data-testid":"test-duration",style:{minWidth:"50px",textAlign:"right"},children:Ol(h.duration)})]}),m.jsx("div",{className:"test-file-details-row",children:m.jsxs("div",{className:"test-file-details-row-items",children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),className:"test-file-path-link",children:m.jsxs("span",{className:"test-file-path",children:[h.location.file,":",h.location.line]})}),m.jsx(qv,{test:h}),m.jsx(Iv,{test:h}),m.jsx(tm,{test:h,dim:!0})]})})]},`test-${h.testId}`))})};function qv({test:l}){const u=se();for(const c of l.results)for(const f of c.attachments)if(f.contentType.startsWith("image/")&&f.name.match(/-(expected|actual|diff)/))return m.jsx(Tr,{href:Cn({test:l,result:c,anchor:`attachment-${c.attachments.indexOf(f)}`},u),Line truncated
|
||||
</script>
|
||||
<style type='text/css'>:root{--color-canvas-default-transparent: rgba(255,255,255,0);--color-marketing-icon-primary: #218bff;--color-marketing-icon-secondary: #54aeff;--color-diff-blob-addition-num-text: #24292f;--color-diff-blob-addition-fg: #24292f;--color-diff-blob-addition-num-bg: #CCFFD8;--color-diff-blob-addition-line-bg: #E6FFEC;--color-diff-blob-addition-word-bg: #ABF2BC;--color-diff-blob-deletion-num-text: #24292f;--color-diff-blob-deletion-fg: #24292f;--color-diff-blob-deletion-num-bg: #FFD7D5;--color-diff-blob-deletion-line-bg: #FFEBE9;--color-diff-blob-deletion-word-bg: rgba(255,129,130,.4);--color-diff-blob-hunk-num-bg: rgba(84,174,255,.4);--color-diff-blob-expander-icon: #57606a;--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;--color-diffstat-deletion-border: rgba(27,31,36,.15);--color-diffstat-addition-border: rgba(27,31,36,.15);--color-diffstat-addition-bg: #2da44e;--color-search-keyword-hl: #fff8c5;--color-prettylights-syntax-comment: #6e7781;--color-prettylights-syntax-constant: #0550ae;--color-prettylights-syntax-entity: #8250df;--color-prettylights-syntax-storage-modifier-import: #24292f;--color-prettylights-syntax-entity-tag: #116329;--color-prettylights-syntax-keyword: #cf222e;--color-prettylights-syntax-string: #0a3069;--color-prettylights-syntax-variable: #953800;--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;--color-prettylights-syntax-invalid-illegal-bg: #82071e;--color-prettylights-syntax-carriage-return-text: #f6f8fa;--color-prettylights-syntax-carriage-return-bg: #cf222e;--color-prettylights-syntax-string-regexp: #116329;--color-prettylights-syntax-markup-list: #3b2300;--color-prettylights-syntax-markup-heading: #0550ae;--color-prettylights-syntax-markup-italic: #24292f;--color-prettylights-syntax-markup-bold: #24292f;--color-prettylights-syntax-markup-deleted-text: #82071e;--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;--color-prettylights-syntax-markup-inserted-text: #116329;--color-prettylights-syntax-markup-inserted-bg: #dafbe1;--color-prettylights-syntax-markup-changed-text: #953800;--color-prettylights-syntax-markup-changed-bg: #ffd8b5;--color-prettylights-syntax-markup-ignored-text: #eaeef2;--color-prettylights-syntax-markup-ignored-bg: #0550ae;--color-prettylights-syntax-meta-diff-range: #8250df;--color-prettylights-syntax-brackethighlighter-angle: #57606a;--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;--color-prettylights-syntax-constant-other-reference-link: #0a3069;--color-codemirror-text: #24292f;--color-codemirror-bg: #ffffff;--color-codemirror-gutters-bg: #ffffff;--color-codemirror-guttermarker-text: #ffffff;--color-codemirror-guttermarker-subtle-text: #6e7781;--color-codemirror-linenumber-text: #57606a;--color-codemirror-cursor: #24292f;--color-codemirror-selection-bg: rgba(84,174,255,.4);--color-codemirror-activeline-bg: rgba(234,238,242,.5);--color-codemirror-matchingbracket-text: #24292f;--color-codemirror-lines-bg: #ffffff;--color-codemirror-syntax-comment: #24292f;--color-codemirror-syntax-constant: #0550ae;--color-codemirror-syntax-entity: #8250df;--color-codemirror-syntax-keyword: #cf222e;--color-codemirror-syntax-storage: #cf222e;--color-codemirror-syntax-string: #0a3069;--color-codemirror-syntax-support: #0550ae;--color-codemirror-syntax-variable: #953800;--color-checks-bg: #24292f;--color-checks-run-border-width: 0px;--color-checks-container-border-width: 0px;--color-checks-text-primary: #f6f8fa;--color-checks-text-secondary: #8c959f;--color-checks-text-link: #54aeff;--color-checks-btn-icon: #afb8c1;--color-checks-btn-hover-icon: #f6f8fa;--color-checks-btn-hover-bg: rgba(255,255,255,.125);--color-checks-input-text: #eaeef2;--color-checks-input-placeholder-text: #8c959f;--color-checks-input-focus-text: #8c959f;--color-checks-input-bg: #32383f;--color-checks-input-shadow: none;--color-checks-donut-error: #fa4549;--color-checks-donut-pending: #bf8700;--color-checks-donut-success: #2da44e;--color-checks-donut-neutral: #afb8c1;--color-checks-dropdown-text: #afb8c1;--color-checks-dropdown-bg: #32383f;--color-checks-dropdown-border: #424a53;--color-checks-dropdown-shadow: rgba(27,31,36,.3);--color-checks-dropdown-hover-text: #f6f8fa;--color-checks-dropdown-hover-bg: #424a53;--color-checks-dropdown-btn-hover-text: #f6f8fa;--color-checks-dropdown-btn-hover-bg: #32383f;--color-checks-scrollbar-thumb-bg: #57606a;--color-checks-header-label-text: #d0d7de;--color-checks-header-label-open-text: #f6f8fa;--color-checks-header-border: #32383f;--color-checks-header-icon: #8c959f;--color-checks-line-text: #d0d7de;--color-checks-line-num-text: rgba(140,149,159,.75);--color-checks-line-timestamp-text: #8c959f;--color-checks-line-hover-bg: #32383f;--color-checks-line-selected-bg: rgba(33,139,255,.15);--color-checks-line-selected-num-text: #54aeff;--color-checks-line-dt-fm-text: #24292f;--color-checks-line-dt-fm-bg: #9a6700;--color-checks-gate-bg: rgba(1Line truncated
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id='root'></div>
|
||||
</body>
|
||||
</html>
|
||||
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIAFeOXFyF7ksyOyAAAF5iAgAZAAAAMjRmZGUwMzNlZDRlZmUzZDhmODkuanNvbu2d7W7jOJaGb0XIn0oDFZVISpRUQC/Q3ZgvYHaAmWnsADvdA8i2nHjLsdO20t2F3gL2IvYK90qWpOREOpItUT6KFOfkx0x17NAyX5E65HnOy9+ulqt1+qfF1ccr7i8XqSdEuvDTZSoW0TKKr96b1/+S3KfqHfPt+vF+czPfbparW3f/kM7dbK/ekqV79f8f//mb+dfRxm5iP5rPWBrO4rmczWY88oNQ//kqWz837+TNO7PHLNtunNXeedil+3STOauNk92lTrbdrmfJTv3hw277X+k8O1zd3W57v3q8Vy+st/MkW203Vx9/M9d/4trXq4162Y/eF++4+hh+eX+1eNwVDfA4Vq8lm802M7/R3/NHdc3JbfGv7WM235oLSH9VrWbpQl9Zkt2pl6++M21+Zz70Sr1ZfZPHddFX8DP2WbLLvl+ZprjH5Y3Hb3j0PRcf/fhjIFzP8//zSjeR7T5fffT0H6QPRbcXPfhtutzuUueP2+0n/d3aW5S6xecLCYQQrKnhmWn4d8n8zrlTjfdtu/Gif7/6NXtUV/3D1Wy3/WWf7n646tI+46B9TwSiqf0/J48bdd1F452a9utNl7pF6ZhkmeqLe3VXFr+Ybx832dVH/a5Pq4cHdRN8XCbrffrF6s3vm3pF3bJZ+mvW0iuB99EXrpCg1+OmHvlulyZZ6hQNd2o2rDYbjdYbD8lt2q0rwgCo2HhnF32hm+3UKLyrvRfuib8kP69u9RVnW9UbH5KHhw8P6W6/3XTqFD8Q1etnQsanv4H9XOo9z6VMfjn+tdR/b/R/q9euHMcRsfPfjv5JfklWmbNPs8eHbx5W/76df9pfa32++mGjXvW9ytv0C+7tNttevyt1xrv8veonf2/p5195K6zSiv6f3293f9itFn9Lk8Xn/POuyh3/D/0+NQuqC1uraV79w/TMdnf97p+77Tr9+gfd+9nj/oerH/+Z7FbJzTqZpWv16z9vk8Vqc6t+/+6rVo18N+LgHuMcWaHSw85Cn9D02HOvm36Mim50ix78e9E5Np3y3vkNqJVrFD+Jp9tIPzrv7laLRbp5974izO/MY1cNhmz7bfofq/1qtlYzREmdRZIlNz/99NPNaqE++D5ZmY7JVAd2liQADxvmtzwPrDVhoiQK764K4wcF1AfO766vv3K+/jdn87he50OAiZJseYRibm63c/985ZY69vo3J1P9pIKej47w1I/z5fhQs/vJRWc+uM06D8Km28uxFDqO/aPPTxyZg15jr9YreV8Fx0dfW2+oQVcbVs/SsqBR2kIiefSW6yxW9Z67VfPuzdpmmgwO4fCzVFIga6Xn3R5acdakFefHtTrdGWcoxUU3pWym0H8dLvEQdvz43jn9DZpfT+8fss+dxZbRsOOSy2etAwutDyPw+JSK31+5tPkgPDo588bJubgt9KP8S/UO+DlZP6p7rIsWIVgMYIcnvugXQPqHIfbhg/PdOk3y+Wb9dyWJUkcHzOlmr1cRc/Wi3mcobzeYEZaHhqIeYKZF9xQjqNyuq1vbXX/VGnH69WZ3qVb9ujoY/2Z+2UmIGEQlnh9ia+H31OKcTjzVVW29HEw0rpcu41CuUFJgP2ZgL13hgbksoLD+4sJ6JTMD2x7YoSKF9ThhvZYqhA81CusvM6xXYgsPrOEorD8/rEfpOVGKJSKLTVx9DToL6C7S/Xy3mqXX78pJMHWP53drHhmIPMwwf/CcY7pO9p83c0d9b93/6ts+/0H5eZR/6/Zt4+Ye6bLZbhmB5CnLm0rHdh8KIDpEj+TjUorTIhLxnyW6fmeXolViH1fSr+rWJYY52r/V4YIVuTz/5HdakGcevlQn2W+WWbrrkHItZPbB5n5bTrRjvku3DLZI+mamzkxCNlwJC5u+43fr7b57ElI1GxzPbQ6XerN9c7rbbXfF+/IVkvo6D8l+b7iAGkcA2tYtbD9dfcx2j7kQJ3kKNpulQeIHYTqTC89Ll0JEZZ5ivZp/yuPDhlG7fUg3ezNaH5JNukbFKYJSiFXFKWK95h2WpojzZXUbnhCgwxQgcBVBGDY23AemiGpty8bUfl+YAuylqyEcY8EUcMJTTb8CmIKHbgx7PWicxqxgCtMseNL7E4cp9DXDBbR/LkxhGgV3nf+KYAp9/RIIyf0WLoZYipfcc+WRyxncc8Xe4KMtV5u9OC2JCCCChL3UoU3X0TddldAiFMNu7tCmK8qmq5YqAvtwIW26QqUuY9NVie0HbNj8/ZvcdO3BUhgtQAiMjRUSS9GBpTBCgGUqk9hTIKEUaGG9hElCgc2DUVhvGe3JAEpChPQlRvUygpse2HseFNZjhfUhgxuYhEhDpS4mrA9DRiwFsRRvnqUwQyEadoUrStMeZxaK5vOeanWfObNs43x9YlSc6oRcLV4PXFSjR+931ikaKe6EclCkGnVNMhmsrr7TvxtGQbghFbckyewlLC+MhYWE53T6sV5tVqRQwrcMT9vwmNLFFQ/Gc9YKveSNYG029lpBlNba0kbdUwK9gBJY5FJx6wT1vakXUXfgAqGgrK5FKBJUd1I0AVN+npYSo0F12wRDbEQqrcDRgjNxNCMVWM2VFnNnAAMNDY9DozVdSTPFYQOj6VYjbnWPXz6MtpQLzoVkYeynEU8jPxD+1TFzH4OcOXfJ3kmcuen6PFrApdDCYxSaXhEOjaHlnzEBDs2PAjkUh6baDlA5NLAL74dMYnFo8NJV06+AQ/M9l0VgZc8a5bR09dHtiqOT/yRBNH3NMeiLoJkttnD10Y1CL6mWvZMpgWjq+jkMuHz0fAiBaOcU/3puyOEYptrfUWt/lSQ+2GNgHN3thVJWY6eslNAR3BnnRKLVB+YEUlZaqxiWwVHKCip1GSkrJXYcQqNNSlmNQqJpLSJ/2E07ItG6uPpoIWB5kEck2kTjeuaKGCaqiEQbNa5nrg8lQY/2KKofParXMg9tFENRPU5Uz9ygZrOFHulRVD+NqF6JLWGtMEX1BKK9PRDNDAXo20Ag2isC0bSCIUgNobtME4g2Foim5JUCJpwJRJs+iHZjkJHOGsM8j0Dn0crWahazcBBVnnKneDR7s7RjPTawV1ouuDzbKk3pBqdeVhqcZ0AEumUxBTit6UoQnNJ0q3az2uXDaX7EUpmEzJ+H/mLJlzLwK3BaCUFzFqv9/UpdxVDmaPKoORqLZDg4lmY+ox30irGxNHhem6/WiY13ew8sreZOpdqWpycLOywNbsmLuNkAyx5Lg1yIaroN3pkCliakG8sBsDTTbmC1fzs6lqavGSIGfiNIbYGlmUbhkGkxSpoSliZC14OBVxCTP9qE0ldKolpszLC9uCh9ZZPX0JIE0LJOoFfYUwJr7ASWEjqEqBP62SOUv8LIXympotphxujnj1H+ahL5Ky02PEMZe/p9k/mrHlSa1gISgti770SldaDSjBDQSibC3ionKg0prI9cH6bg0UNICuutor3IDTy7gmEK6q1/xg/qtcxg86qtlJCi+pGieqWVBMFFhO6ES1H9NKL6yJW1g5coqicq7e1RaWYoxESlvV4qTSsYQDSCY8PvhKWNhaUJbWA4MEJNWNoRLO30mO1LoRlJa2V9yJrKnprKzq5osmGzxLarTg/pvkrK6l5M35tvs816TQXq75wBp4MI8ifY6xRZXlPGFrcO9g3xYjriYo/FTSjPhB611DWfmJLWZyAqDS2PAz2aK4FM/dnQo2lVWKVTLx96jNVvgsUymvM0nXHflzKOS9Bj7sG3Xu2z/SE/scrS+72z3G3vnSxRA8e5T7NEDzdcALK03qsCkELoHZNhAcj8M05zedyN8yJiNABStwjvT8HDRu88WwDSpu0+AKRuH6IKnhc3doo9AOlBQzfPi8YzousOb6tLr1UgnzoUtSu+rdsFvT0eD9oRqRcug86QjDf6NtoY8+lWIQvFX8RnFcmZT7gcLuSYQCfsiIE8x8LDdyWcgdDtwShZauXtoCSpnRHrU7r04tKlSujargi6LSalS3FMPJRWMcighegnDFG6dBLpUiV2BGtPyJpvJGs+v36eBkGQY1jzKSFqRuzMR9eCKEicwF66Hq/ZolNgP2ZgrySBPDe6JBTXjx/XS5dx6LlBxU31cTmFuF5pJWENqEee21CqCwnspctr0CsF9sRBvj0OUg+F2uY89tY8gZCD2vMpCWHik3noZ/0SCDmWP590RQxPbSMQ8mVAyL8+prvPjumN49FLlenS9MhNV2V9TwzLtIWlcStCi9X5IbzMZ9+8iT8ZMObULHyqO0wfhwK0q3v26+ImKX2Ka16xBSRzHcPDPk5x05mm8lvpDybxvvv+LtlceycGcul9HTLcWseBj3IOyxtiFmxir/7u3IGn1Cm0OPd4XtO5IOXCGx3TrHkN3XI4BRqw6UqOUDRWHoiqWegJ9+ZxwPl8ufBmfLkQS9VfwuOxtyzhgKnmzUocYHE6b7a9vV0PcjxvePx43oj7w/sgcr8LnTb88byB5I1mcRjH86q2W2YMOx/E+jmjfiOf3ON43gaLxb5z0kvizOSDeNoHsZntPtMH8UXmcjwfRGgyLj06nndCqULjgwiDEPJBHNsHUUJJBLYmlCscPVdozPWCYfd4KFWI5oPI4NF62EWslCmcRqbQWL5xyhROAQHUWkSQpyAEcBwfRHhMcoBuGkUEIKIPIiRnyQdxdB9E6MNFBODlRfVKZx/m99BXbxTWYxkhQhAmwNaKwvqJhPWRKwXcoqWwngDAtwcAmqEwsCco8X9DGyHC84skdoBP+N+YPojw0EDsKhLC/xDwvxwR6Ub+GVGhmRD2DkpUyoYKm2X54bGWT7z59/o223Qm/8o9ka/pvUqLVQjtuf1ezF+uLine truncated
|
||||
Generated
+6742
-1282
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,176 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 QRun.IO, Inc.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
-->
|
||||
<!--
|
||||
Packages the static export (QQQ_NEXT_OUTPUT=export pnpm build, written to out/) as a
|
||||
resource-only jar with the files under next-dashboard/. QQQ's QApplicationJavalinServer
|
||||
serves that folder at "/" when the jar is on the classpath. Build with: pnpm build:jar
|
||||
-->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QQQ Frontend Next</name>
|
||||
<description>Metadata-driven Next.js admin dashboard for QQQ, packaged for the QQQ Javalin server</description>
|
||||
<url>https://github.com/QRun-IO/qqq-frontend-next</url>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/QRun-IO/qqq-frontend-next.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qqq-frontend-next.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qqq-frontend-next</url>
|
||||
</scm>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<name>QRun.IO Development Team</name>
|
||||
<organization>QRun.IO, Inc.</organization>
|
||||
<organizationUrl>https://qrun.io</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>0.2.0</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>out</directory>
|
||||
<targetPath>next-dashboard</targetPath>
|
||||
<excludes>
|
||||
<!-- development-only browser mock worker -->
|
||||
<exclude>mockServiceWorker.js</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>.</directory>
|
||||
<targetPath>META-INF</targetPath>
|
||||
<includes>
|
||||
<include>LICENSE</include>
|
||||
</includes>
|
||||
</resource>
|
||||
</resources>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>require-static-export</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>enforce</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireFilesExist>
|
||||
<files>
|
||||
<file>${project.basedir}/out/index.html</file>
|
||||
<file>${project.basedir}/out/app/_/_/index.html</file>
|
||||
</files>
|
||||
<message>Run "QQQ_NEXT_OUTPUT=export pnpm build" (or pnpm build:jar) before packaging.</message>
|
||||
</requireFilesExist>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.2</version>
|
||||
<executions>
|
||||
<!-- Maven Central requires sources and javadoc jars; this artifact has no Java code. -->
|
||||
<execution>
|
||||
<id>empty-sources</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<classifier>sources</classifier>
|
||||
<classesDirectory>${project.basedir}/docs/maven-artifact</classesDirectory>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>empty-javadoc</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<classifier>javadoc</classifier>
|
||||
<classesDirectory>${project.basedir}/docs/maven-artifact</classesDirectory>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<!-- Release profile for Maven Central: sign and publish (used by qqq-orb mvn_jar_deploy) -->
|
||||
<id>release</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<configuration>
|
||||
<keyname>${gpg.keyname}</keyname>
|
||||
<gpgArguments>
|
||||
<arg>--pinentry-mode</arg>
|
||||
<arg>loopback</arg>
|
||||
</gpgArguments>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.sonatype.central</groupId>
|
||||
<artifactId>central-publishing-maven-plugin</artifactId>
|
||||
<version>0.8.0</version>
|
||||
<extensions>true</extensions>
|
||||
<configuration>
|
||||
<publishingServerId>central</publishingServerId>
|
||||
<autoPublish>true</autoPublish>
|
||||
<waitUntil>uploaded</waitUntil>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
// Compiles the owned acceptance fixture against the sample JAR and runs it on loopback.
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { ACCEPTANCE_MODE, EXPORT_CLASSPATH, resolveSampleJar } from './acceptance-paths.mjs'
|
||||
|
||||
const port = process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'
|
||||
const jar = resolveSampleJar()
|
||||
const classes = path.resolve('test-results/acceptance/fixture-classes')
|
||||
rmSync(classes, { recursive: true, force: true })
|
||||
mkdirSync(classes, { recursive: true })
|
||||
const fixtureDirectory = path.resolve('tests/acceptance/fixture')
|
||||
const sources = readdirSync(fixtureDirectory).filter((name) => name.endsWith('.java')).map((name) => path.join(fixtureDirectory, name))
|
||||
const javac = spawnSync('javac', ['-proc:none', '-encoding', 'UTF-8', '-cp', jar, '-d', classes, ...sources], { stdio: 'inherit' })
|
||||
if (javac.status !== 0) {
|
||||
console.error('Acceptance fixture compilation failed.')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!existsSync(path.join(classes, 'AcceptanceSampleServer.class'))) process.exit(1)
|
||||
|
||||
const server = spawn('java', [
|
||||
'-Dqqq.sample.mockAuthentication=true',
|
||||
'-Dqqq.sample.sharing=true',
|
||||
`-Dqqq.sample.port=${port}`,
|
||||
'-Duser.timezone=UTC',
|
||||
// javalin mode: the fresh export shadows any dashboard bundled in the sample jar
|
||||
'-cp', [...(ACCEPTANCE_MODE === 'javalin' ? [EXPORT_CLASSPATH] : []), classes, jar].join(path.delimiter),
|
||||
...(ACCEPTANCE_MODE === 'javalin' ? ['-Dqqq.javalin.frontend=next'] : ['-Dqqq.javalin.frontend=none']),
|
||||
'AcceptanceSampleServer',
|
||||
], { stdio: 'inherit' })
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
|
||||
server.on('exit', (code) => process.exit(code ?? 1))
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
// Serves the production standalone build exactly as the published image does.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { cpSync, existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { BUILD_MARKER } from './acceptance-paths.mjs'
|
||||
|
||||
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
|
||||
const standalone = path.resolve('.next/standalone')
|
||||
if (!existsSync(path.join(standalone, 'server.js')) || !existsSync(BUILD_MARKER)
|
||||
|| readFileSync(BUILD_MARKER, 'utf8').trim() !== backend) {
|
||||
console.error(`No production build for ${backend}; run node scripts/acceptance.mjs.`)
|
||||
process.exit(1)
|
||||
}
|
||||
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true })
|
||||
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true })
|
||||
const server = spawn('node', ['server.js'], {
|
||||
cwd: standalone,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_ENV: 'production', HOSTNAME: '127.0.0.1', PORT: process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? '13765' },
|
||||
})
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
|
||||
server.on('exit', (code) => process.exit(code ?? 1))
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
// Fails unless every required matrix row has at least one acceptance test and every test for it
|
||||
// passed on the first attempt in every configured browser. Unknown IDs, skips and flakes fail.
|
||||
// --partial checks only the rows exercised by a filtered run and never reports acceptance.
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const partial = process.argv.includes('--partial')
|
||||
const matrixFiles = readdirSync('tests/acceptance/matrix').filter((name) => name.endsWith('.json')).sort()
|
||||
const matrix = { rows: matrixFiles.flatMap((name) => {
|
||||
const area = JSON.parse(readFileSync(`tests/acceptance/matrix/${name}`, 'utf8'))
|
||||
return area.rows.map((row) => {
|
||||
if (!area.prefixes.some((prefix) => row.id.startsWith(prefix + '-'))) throw new Error(`${row.id} does not belong to ${name}`)
|
||||
return { ...row, area: area.area }
|
||||
})
|
||||
}) }
|
||||
const duplicates = matrix.rows.map((row) => row.id).filter((id, index, ids) => ids.indexOf(id) !== index)
|
||||
if (duplicates.length) throw new Error(`Duplicate matrix IDs: ${duplicates.join(', ')}`)
|
||||
const report = JSON.parse(readFileSync('test-results/acceptance/report.json', 'utf8'))
|
||||
const rows = new Map(matrix.rows.map((row) => [row.id, row]))
|
||||
const results = new Map()
|
||||
const problems = []
|
||||
const byProject = Object.fromEntries((report.config?.projects ?? []).map((project) => [project.name, { passed: 0, failed: 0, skipped: 0, flaky: 0 }]))
|
||||
|
||||
function walk(suite, titles = []) {
|
||||
for (const child of suite.suites ?? []) walk(child, [...titles, child.title])
|
||||
for (const spec of suite.specs ?? []) {
|
||||
const ids = [...`${titles.join(' ')} ${spec.title}`.matchAll(/\[([A-Z]{3}-\d{3})\]/g)].map((match) => match[1])
|
||||
if (ids.length === 0) problems.push(`Test without a matrix ID: ${spec.file} ${spec.title}`)
|
||||
for (const test of spec.tests) {
|
||||
const attempts = test.results ?? []
|
||||
const outcome = test.status === 'skipped' ? 'skipped'
|
||||
: attempts.length === 1 && attempts[0].status === 'passed' ? 'passed'
|
||||
: attempts.some((attempt) => attempt.status === 'passed') ? 'flaky' : 'failed'
|
||||
byProject[test.projectName] ??= { passed: 0, failed: 0, skipped: 0, flaky: 0 }
|
||||
byProject[test.projectName][outcome]++
|
||||
for (const id of ids) {
|
||||
if (!rows.has(id)) problems.push(`Unknown matrix ID ${id} in ${spec.file}`)
|
||||
const list = results.get(id) ?? []
|
||||
list.push({ title: spec.title, project: test.projectName, outcome })
|
||||
results.set(id, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites ?? []) walk(suite)
|
||||
|
||||
const summary = { passed: 0, failed: 0, missing: 0, excluded: 0 }
|
||||
for (const row of matrix.rows) {
|
||||
if (row.required === false) {
|
||||
summary.excluded++
|
||||
if (!row.approval) problems.push(`${row.id} is excluded without a recorded approval`)
|
||||
continue
|
||||
}
|
||||
const list = results.get(row.id)
|
||||
if (!list) {
|
||||
if (partial) continue
|
||||
summary.missing++
|
||||
problems.push(`${row.id} ${row.feature}: no acceptance test`)
|
||||
continue
|
||||
}
|
||||
const bad = list.filter(({ outcome }) => outcome !== 'passed')
|
||||
if (bad.length) {
|
||||
summary.failed++
|
||||
for (const { title, project, outcome } of bad) problems.push(`${row.id} ${outcome} [${project}] ${title}`)
|
||||
} else summary.passed++
|
||||
}
|
||||
|
||||
// Every configured project must run something (the phone project runs only @mobile tests).
|
||||
if (!partial) for (const [project, counts] of Object.entries(byProject)) {
|
||||
if (Object.values(counts).every((count) => count === 0)) problems.push(`Project ${project} ran no tests`)
|
||||
}
|
||||
|
||||
const status = { partial, generatedAt: new Date().toISOString(), summary, byProject, problems,
|
||||
rows: matrix.rows.map((row) => ({ id: row.id, results: results.get(row.id) ?? [] })) }
|
||||
writeFileSync('test-results/acceptance/gate.json', JSON.stringify(status, null, 2) + '\n')
|
||||
console.log(`Acceptance gate${partial ? ' (partial run, not an acceptance result)' : ''}:`, JSON.stringify(summary), JSON.stringify(byProject))
|
||||
for (const problem of problems) console.log(` FAIL ${problem}`)
|
||||
process.exit(problems.length ? 1 : 0)
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
// Renders docs/acceptance/feature-matrix.md from tests/acceptance/matrix/*.json and, when
|
||||
// present, the gate results of one or more runs (test-results/acceptance/gate.json or paths
|
||||
// given as arguments, e.g. one per browser). Usage:
|
||||
// node scripts/acceptance-matrix-md.mjs [gate.json ...] [--evidence "<versions and commands>"]
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const evidenceIndex = args.indexOf('--evidence')
|
||||
const evidence = evidenceIndex >= 0 ? args[evidenceIndex + 1] : ''
|
||||
const gateFiles = args.filter((arg, index) => arg.endsWith('.json') && index !== evidenceIndex + 1)
|
||||
if (gateFiles.length === 0 && existsSync('test-results/acceptance/gate.json')) gateFiles.push('test-results/acceptance/gate.json')
|
||||
|
||||
const runs = gateFiles.map((file) => ({ file, gate: JSON.parse(readFileSync(file, 'utf8')) }))
|
||||
const outcomes = new Map()
|
||||
for (const { gate } of runs) {
|
||||
for (const row of gate.rows) {
|
||||
const list = outcomes.get(row.id) ?? []
|
||||
list.push(...row.results)
|
||||
outcomes.set(row.id, list)
|
||||
}
|
||||
}
|
||||
|
||||
const escape = (text) => String(text ?? '').replaceAll('|', '\\|').replaceAll('\n', ' ')
|
||||
const areas = readdirSync('tests/acceptance/matrix').filter((name) => name.endsWith('.json')).sort()
|
||||
.map((name) => JSON.parse(readFileSync(`tests/acceptance/matrix/${name}`, 'utf8')))
|
||||
|
||||
let total = 0
|
||||
let passing = 0
|
||||
const sections = areas.map((area) => {
|
||||
const lines = [`## ${area.description}`, '', '| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |', '|---|---|---|---|---|---|---|']
|
||||
for (const row of area.rows) {
|
||||
total++
|
||||
const results = outcomes.get(row.id) ?? []
|
||||
const projects = [...new Set(results.map((result) => result.project))].sort()
|
||||
const ok = results.length > 0 && results.every((result) => result.outcome === 'passed')
|
||||
if (ok && row.required !== false) passing++
|
||||
const result = row.required === false ? `excluded (${escape(row.approval)})`
|
||||
: results.length === 0 ? 'not run'
|
||||
: ok ? `pass (${projects.join(', ')})`
|
||||
: `FAIL: ${results.filter((r) => r.outcome !== 'passed').map((r) => `${r.project} ${r.outcome}`).join(', ')}`
|
||||
const issues = (row.issues ?? []).map((issue) => {
|
||||
const match = String(issue).match(/^(?:([\w.-]+\/[\w.-]+))?#?(\d+)$/)
|
||||
if (!match) return escape(issue)
|
||||
const repo = match[1] ?? 'QRun-IO/qqq'
|
||||
return `[${repo === 'QRun-IO/qqq' ? '' : repo}#${match[2]}](https://github.com/${repo}/issues/${match[2]})`
|
||||
}).join(' ')
|
||||
lines.push(`| ${row.id} | ${escape(row.feature)} | ${escape((row.scenarios ?? []).join('; '))} | ${escape((row.negative ?? []).join('; '))} | ${escape(row.fixture)} | ${result} | ${issues} |`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
})
|
||||
|
||||
const header = [
|
||||
'# Next UI feature acceptance matrix',
|
||||
'',
|
||||
'Generated by `node scripts/acceptance-matrix-md.mjs` from `tests/acceptance/matrix/*.json` and the',
|
||||
'acceptance gate results. Each row is a supported QQQ UI feature or use case with executable',
|
||||
'real-backend acceptance coverage (QRun-IO/qqq#649). See `tests/acceptance/README.md` to run it.',
|
||||
'',
|
||||
`Rows: ${total}. Required rows passing in the recorded runs: ${passing}.`,
|
||||
'',
|
||||
runs.length ? `Recorded runs: ${runs.map(({ file, gate }) => `\`${file}\` (${gate.generatedAt}${gate.partial ? ', partial' : ''})`).join(', ')}.` : 'No recorded run.',
|
||||
...(evidence ? ['', evidence] : []),
|
||||
'',
|
||||
]
|
||||
mkdirSync('docs/acceptance', { recursive: true })
|
||||
writeFileSync('docs/acceptance/feature-matrix.md', header.join('\n') + '\n' + sections.join('\n\n') + '\n')
|
||||
console.log(`Wrote docs/acceptance/feature-matrix.md (${total} rows, ${passing} passing required rows)`)
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/** Sample JAR from QQQ_SAMPLE_JAR, else the newest jar-with-dependencies in a sibling qqq checkout. */
|
||||
export function resolveSampleJar() {
|
||||
const explicit = process.env.QQQ_SAMPLE_JAR
|
||||
if (explicit) {
|
||||
if (!existsSync(explicit)) throw new Error(`QQQ_SAMPLE_JAR does not exist: ${explicit}`)
|
||||
return path.resolve(explicit)
|
||||
}
|
||||
const target = path.resolve('../qqq/qqq-sample-project/target')
|
||||
const jars = existsSync(target) ? readdirSync(target).filter((name) => name.endsWith('-jar-with-dependencies.jar')) : []
|
||||
if (jars.length !== 1) {
|
||||
throw new Error('Set QQQ_SAMPLE_JAR to the qqq-sample-project jar-with-dependencies (see tests/acceptance/README.md).')
|
||||
}
|
||||
return path.join(target, jars[0])
|
||||
}
|
||||
|
||||
export const BUILD_MARKER = path.resolve('.next/acceptance-backend.txt')
|
||||
|
||||
/** Classpath folder holding a copy of out/ as next-dashboard/, ahead of the sample jar. */
|
||||
export const EXPORT_CLASSPATH = path.resolve('test-results/acceptance/export-classpath')
|
||||
|
||||
export const ACCEPTANCE_MODE = process.env.QQQ_ACCEPTANCE_MODE === 'standalone' ? 'standalone' : 'javalin'
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
// Full real-backend acceptance: production build, Playwright suite, then the matrix gate.
|
||||
// Usage: QQQ_SAMPLE_JAR=<jar> node scripts/acceptance.mjs [--skip-build] [playwright args...]
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar } from './acceptance-paths.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const skipBuild = args.includes('--skip-build')
|
||||
const passthrough = args.filter((arg) => arg !== '--skip-build')
|
||||
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
|
||||
resolveSampleJar()
|
||||
|
||||
function run(command, commandArgs, env = {}) {
|
||||
const result = spawnSync(command, commandArgs, { stdio: 'inherit', env: { ...process.env, ...env } })
|
||||
return result.status ?? 1
|
||||
}
|
||||
|
||||
if (!skipBuild && ACCEPTANCE_MODE === 'javalin') {
|
||||
const status = run('pnpm', ['build:export'], { NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (status !== 0) process.exit(status)
|
||||
}
|
||||
if (ACCEPTANCE_MODE === 'javalin') {
|
||||
if (!existsSync('out/index.html')) throw new Error('No static export in out/; run without --skip-build.')
|
||||
rmSync(EXPORT_CLASSPATH, { recursive: true, force: true })
|
||||
mkdirSync(EXPORT_CLASSPATH, { recursive: true })
|
||||
cpSync('out', path.join(EXPORT_CLASSPATH, 'next-dashboard'), { recursive: true, filter: (source) => !source.endsWith('mockServiceWorker.js') })
|
||||
}
|
||||
if (!skipBuild && ACCEPTANCE_MODE === 'standalone') {
|
||||
const status = run('pnpm', ['build'], { QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (status !== 0) process.exit(status)
|
||||
writeFileSync(BUILD_MARKER, backend + '\n')
|
||||
}
|
||||
const tests = run('pnpm', ['exec', 'playwright', 'test', '-c', 'tests/acceptance/playwright.config.ts', ...passthrough])
|
||||
const filtered = passthrough.some((arg) => !arg.startsWith('--project'))
|
||||
const gate = run('node', ['scripts/acceptance-gate.mjs', ...(filtered ? ['--partial'] : [])])
|
||||
process.exit(tests || gate)
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-license-headers.mjs
|
||||
*
|
||||
* Validates that every TypeScript/TSX source file in src/ begins with the
|
||||
* Apache 2.0 license header required by this project.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-license-headers.mjs # check only (exits 1 on failure)
|
||||
* node scripts/check-license-headers.mjs --fix # auto-prepend missing headers
|
||||
*
|
||||
* Integrated via:
|
||||
* pnpm lint:license → check mode
|
||||
* pnpm lint:license:fix → fix mode
|
||||
* pnpm lint → check mode (enforced in CI)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const SRC = join(ROOT, 'src')
|
||||
const FIX = process.argv.includes('--fix')
|
||||
|
||||
/** Full Apache 2.0 header prepended to files missing it (fix mode). */
|
||||
const HEADER = `/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/`
|
||||
|
||||
/**
|
||||
* Canonical identifier used when scanning for the header.
|
||||
* Matching a substring instead of the full block makes the check robust to
|
||||
* minor formatting differences (trailing spaces, year updates, etc.).
|
||||
*/
|
||||
const HEADER_MARKER = 'Licensed under the Apache License, Version 2.0'
|
||||
|
||||
/** Directories inside src/ that are generated or third-party — skip entirely. */
|
||||
const SKIP_DIRS = new Set(['node_modules', '.next', 'coverage', '__generated__', 'out'])
|
||||
|
||||
/**
|
||||
* Recursively yields every .ts / .tsx file under `dir`, skipping directories
|
||||
* listed in SKIP_DIRS.
|
||||
*
|
||||
* @param {string} dir - Absolute path of the directory to walk.
|
||||
* @yields {string} Absolute path of each matching file.
|
||||
*/
|
||||
function* walkTs(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
yield* walkTs(full)
|
||||
} else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) {
|
||||
yield full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missing = []
|
||||
|
||||
for (const file of walkTs(SRC)) {
|
||||
const content = readFileSync(file, 'utf8')
|
||||
if (!content.includes(HEADER_MARKER)) {
|
||||
missing.push(file)
|
||||
if (FIX) {
|
||||
writeFileSync(file, HEADER + '\n\n' + content, 'utf8')
|
||||
console.log(` fixed ${relative(ROOT, file)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0) {
|
||||
console.log('✓ All source files carry the Apache 2.0 license header.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (FIX) {
|
||||
console.log(`\n✓ Added license header to ${missing.length} file(s).`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Check-only mode: report and fail.
|
||||
console.error(`\n❌ ${missing.length} file(s) are missing the Apache 2.0 license header:\n`)
|
||||
for (const f of missing) {
|
||||
console.error(` ${relative(ROOT, f)}`)
|
||||
}
|
||||
console.error('\nRun pnpm lint:license:fix to add the headers automatically.\n')
|
||||
process.exit(1)
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file CallbackContent — completes the OAuth2 / Auth0 PKCE redirect (`/token`, `/callback`).
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { PKCE_STORAGE } from '@/lib/auth/oidc'
|
||||
import { safeReturnTo } from '@/lib/auth/return-to'
|
||||
|
||||
/**
|
||||
* Inner callback handler — reads `code`, `state`, and `error` from search params,
|
||||
* retrieves the PKCE verifier from sessionStorage, and delegates to
|
||||
* `handleOAuthCallback`. Redirects on success or failure.
|
||||
*
|
||||
* @returns A loading card while the callback is being processed.
|
||||
*/
|
||||
export default function CallbackContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { handleOAuthCallback } = useAuth()
|
||||
const handled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current) return
|
||||
handled.current = true
|
||||
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const returnTo = safeReturnTo(sessionStorage.getItem(PKCE_STORAGE.returnTo))
|
||||
const toLogin = (reason: string) => {
|
||||
sessionStorage.removeItem(PKCE_STORAGE.verifier)
|
||||
router.replace(`/login?error=${encodeURIComponent(reason)}&returnTo=${encodeURIComponent(returnTo)}`)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.warn('[Callback] Identity provider error:', error, searchParams.get('error_description') ?? '')
|
||||
toLogin(error)
|
||||
return
|
||||
}
|
||||
if (!code || !state) {
|
||||
console.warn('[Callback] Missing code or state')
|
||||
toLogin('callback_failed')
|
||||
return
|
||||
}
|
||||
|
||||
// The verifier the login page stored before redirecting to the provider.
|
||||
const codeVerifier = sessionStorage.getItem(PKCE_STORAGE.verifier) ?? undefined
|
||||
sessionStorage.removeItem(PKCE_STORAGE.verifier)
|
||||
|
||||
handleOAuthCallback(code, state, codeVerifier)
|
||||
.then(() => {
|
||||
sessionStorage.removeItem(PKCE_STORAGE.returnTo)
|
||||
router.replace(returnTo)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('[Callback] Sign-in could not be completed:', err instanceof Error ? err.message : err)
|
||||
toLogin('callback_failed')
|
||||
})
|
||||
}, [searchParams, handleOAuthCallback, router])
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Completing sign-in...
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
Please wait while we complete authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file OAuth2 / Auth0 callback page — handles the PKCE redirect from the authorization server.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// OAuth2 callback page — handles the redirect from authorization server
|
||||
// Wrapped in Suspense because it uses useSearchParams()
|
||||
// OAuth2 / Auth0 callback page — handles the redirect from the authorization server.
|
||||
//
|
||||
// After a successful PKCE redirect from the login page the IdP returns the user
|
||||
// here with `?code=...&state=...` query parameters. This page:
|
||||
//
|
||||
// 1. Validates that `state` matches the nonce stored in sessionStorage (CSRF guard).
|
||||
// 2. Retrieves the `pkce_code_verifier` stored by the login page.
|
||||
// 3. Calls `handleOAuthCallback(code, state, codeVerifier)` which:
|
||||
// a. Re-validates the state nonce.
|
||||
// b. OAUTH2: posts code + verifier + redirect URI to the backend, which
|
||||
// exchanges them with the IdP and issues a session cookie.
|
||||
// AUTH_0: exchanges the code with Auth0 (public PKCE client) and posts
|
||||
// the access token to the backend.
|
||||
// 4. Redirects to `oauth2ReturnTo` (set by the login page) or '/'.
|
||||
//
|
||||
// `/token` (the registered redirect URI) renders the same handler.
|
||||
// Wrapped in Suspense because it uses useSearchParams().
|
||||
|
||||
import React, { Suspense, useEffect, useRef } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import React, { Suspense } from 'react'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
|
||||
function CallbackContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { handleOAuthCallback } = useAuth()
|
||||
const handled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current) return
|
||||
handled.current = true
|
||||
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
if (error) {
|
||||
console.error('[Callback] Auth error:', error, errorDescription)
|
||||
router.replace(`/login?error=${encodeURIComponent(error)}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
console.error('[Callback] Missing code or state params')
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
handleOAuthCallback(code, state)
|
||||
.then(() => {
|
||||
// Get stored returnTo from session storage or default to home
|
||||
const returnTo = sessionStorage.getItem('oauth2ReturnTo') ?? '/'
|
||||
sessionStorage.removeItem('oauth2ReturnTo')
|
||||
router.replace(returnTo)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Callback] Failed to handle callback:', err)
|
||||
router.replace('/login?error=callback_failed')
|
||||
})
|
||||
}, [searchParams, handleOAuthCallback, router])
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Completing login...
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please wait while we complete authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import CallbackContent from './CallbackContent'
|
||||
|
||||
/**
|
||||
* OAuth2 / Auth0 callback page exported as the Next.js default for `/callback`.
|
||||
*
|
||||
* Wraps `CallbackContent` in a `<Suspense>` boundary because `useSearchParams()`
|
||||
* requires Suspense in the App Router.
|
||||
*
|
||||
* @returns The callback page wrapped in a full-screen centered container.
|
||||
*/
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
@@ -80,6 +64,6 @@ export default function CallbackPage() {
|
||||
>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+151
-74
@@ -1,97 +1,174 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Login page — signs the user in and returns them to the page they asked for.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// Login page — authenticates user and redirects to dashboard
|
||||
// Wrapped in Suspense because it uses useSearchParams()
|
||||
// States:
|
||||
// signing in — first visit or an expired session: sign in automatically (anonymous
|
||||
// types create a session; OAUTH2 / AUTH_0 redirect to the provider).
|
||||
// signed out — after an explicit logout: stay here until the user chooses Sign in.
|
||||
// failed — sign-in was denied or the provider callback failed: explain why and
|
||||
// offer Try again; never fall back to an anonymous session.
|
||||
// Wrapped in Suspense because it uses useSearchParams().
|
||||
|
||||
import React, { Suspense, useEffect } from 'react'
|
||||
import React, { Suspense, useEffect, useRef } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { recordReauthAttempt, resetReauthAttempts } from '@/lib/auth/auth-storage'
|
||||
import { safeReturnTo } from '@/lib/auth/return-to'
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { isAuthenticated, isLoading, authMetadata } = useAuth()
|
||||
/** Messages for provider callback errors passed back as `?error=`. */
|
||||
const CALLBACK_ERRORS: Record<string, string> = {
|
||||
access_denied: 'Sign-in was denied by the identity provider.',
|
||||
callback_failed: 'Sign-in could not be completed.',
|
||||
login_required: 'The identity provider requires you to sign in again.',
|
||||
}
|
||||
|
||||
const returnTo = searchParams.get('returnTo') ?? '/'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
// Decode and validate the returnTo URL
|
||||
try {
|
||||
const decoded = decodeURIComponent(returnTo)
|
||||
// Only redirect to relative paths (security: prevent open redirect)
|
||||
if (decoded.startsWith('/')) {
|
||||
router.replace(decoded)
|
||||
} else {
|
||||
router.replace('/')
|
||||
}
|
||||
} catch {
|
||||
router.replace('/')
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, returnTo])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Authenticating...
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{authMetadata
|
||||
? `Setting up ${authMetadata.type} session...`
|
||||
: 'Checking authentication...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If not loading and not authenticated, show login state
|
||||
/**
|
||||
* Card wrapper shared by every login state.
|
||||
*
|
||||
* @param props - The card contents.
|
||||
* @param props.children - Card body.
|
||||
* @returns The card.
|
||||
*/
|
||||
function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Q
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground">QQQ Admin</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{authMetadata?.type === 'FULLY_ANONYMOUS' || authMetadata?.type === 'MOCK'
|
||||
? 'Setting up anonymous session...'
|
||||
: 'Redirecting to authentication provider...'}
|
||||
</p>
|
||||
<div
|
||||
className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm" data-qqq-id="login-card">
|
||||
<div className="flex flex-col items-center gap-4 text-center">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner login content — reacts to the auth state and the `returnTo` / `error` params.
|
||||
*
|
||||
* @returns The login card for the current state.
|
||||
*/
|
||||
function LoginContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { isAuthenticated, isLoading, authMetadata, authError, isSignedOut, signIn } = useAuth()
|
||||
const autoSignInStarted = useRef(false)
|
||||
const [loopStopped, setLoopStopped] = React.useState(false)
|
||||
|
||||
const returnTo = safeReturnTo(searchParams.get('returnTo'))
|
||||
const callbackErrorCode = searchParams.get('error')
|
||||
const callbackError = callbackErrorCode
|
||||
? CALLBACK_ERRORS[callbackErrorCode] ?? `Sign-in failed (${callbackErrorCode}).`
|
||||
: null
|
||||
const errorMessage = authError ?? callbackError ?? (loopStopped ? 'Your session could not be re-established. Sign in again to continue.' : null)
|
||||
|
||||
// Signed in: go where the user was headed.
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) router.replace(returnTo)
|
||||
}, [isAuthenticated, isLoading, router, returnTo])
|
||||
|
||||
// Sign in automatically unless the user signed out or a failure needs attention.
|
||||
useEffect(() => {
|
||||
if (isLoading || isAuthenticated || isSignedOut || errorMessage || !authMetadata) return
|
||||
if (autoSignInStarted.current) return
|
||||
autoSignInStarted.current = true
|
||||
if (recordReauthAttempt()) {
|
||||
setLoopStopped(true)
|
||||
return
|
||||
}
|
||||
void signIn(returnTo)
|
||||
}, [authMetadata, errorMessage, isAuthenticated, isLoading, isSignedOut, returnTo, signIn])
|
||||
|
||||
const signInAgain = () => {
|
||||
resetReauthAttempts()
|
||||
setLoopStopped(false)
|
||||
void signIn(returnTo)
|
||||
}
|
||||
|
||||
if (!isLoading && !isAuthenticated && isSignedOut) {
|
||||
return (
|
||||
<LoginCard>
|
||||
<h1 className="text-xl font-semibold text-foreground">You have signed out</h1>
|
||||
<p className="text-sm text-muted-foreground">Sign in again to continue.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={signInAgain}
|
||||
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
data-qqq-id="button-sign-in"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</LoginCard>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && !isAuthenticated && errorMessage) {
|
||||
return (
|
||||
<LoginCard>
|
||||
<h1 className="text-xl font-semibold text-foreground">Unable to sign in</h1>
|
||||
<p role="alert" className="text-sm text-destructive" data-qqq-id="login-error">{errorMessage}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={signInAgain}
|
||||
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
data-qqq-id="button-sign-in-retry"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</LoginCard>
|
||||
)
|
||||
}
|
||||
|
||||
const redirecting = authMetadata?.type === 'OAUTH2' || authMetadata?.type === 'AUTH_0'
|
||||
return (
|
||||
<LoginCard>
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold text-foreground">Signing in...</h1>
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{redirecting ? 'Redirecting to your identity provider...' : 'Setting up your session...'}
|
||||
</p>
|
||||
</LoginCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Login page exported as the Next.js default for `/login`.
|
||||
*
|
||||
* Wraps `LoginContent` in a `<Suspense>` boundary because `useSearchParams()`
|
||||
* requires Suspense in the App Router.
|
||||
*
|
||||
* @returns The login page wrapped in a full-screen centered container.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
<LoginCard>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground" role="status">Loading...</p>
|
||||
</LoginCard>
|
||||
}
|
||||
>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Token page — the OAuth2 / Auth0 redirect URI (`{origin}/token`), matching the
|
||||
* Material dashboard so existing identity-provider registrations keep working.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense } from 'react'
|
||||
|
||||
import CallbackContent from '../callback/CallbackContent'
|
||||
|
||||
/**
|
||||
* `/token` — completes the provider redirect.
|
||||
*
|
||||
* @returns The callback handler in a full-screen container.
|
||||
*/
|
||||
export default function TokenPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
|
||||
<Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading...</p>}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +1,120 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file EntityCopy page — copies a record by pre-populating the create form with existing values.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// EntityCopy — copy a record (pre-populates form with existing values)
|
||||
// Package 3: full implementation using EntityForm with isCopy=true
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { EntityForm } from '@/components/forms/EntityForm'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import type { QTableMetaData } from '@/types'
|
||||
import type { CopyNode } from '@/lib/utils/copy-tree'
|
||||
import { copyTableNames, prepareCopyTree } from '@/lib/utils/copy-tree'
|
||||
import { getErrorStatusCode, recordLoadFailure } from '@/lib/utils/error-utils'
|
||||
import { canInsertRecords, canReadRecords, hasCapability } from '@/lib/auth/permissions'
|
||||
import { EntityForm, type EntityFormProps } from '@/components/forms/EntityForm'
|
||||
import { FullCopyDraft } from '@/components/forms/FullCopyDraft'
|
||||
|
||||
/**
|
||||
* Renders the entity copy form for the record identified by `slug` and `recordId`.
|
||||
*
|
||||
* Fetches full table metadata and the base source record, then renders `<EntityForm>`
|
||||
* in copy mode (`isCopy=true`) so field values are pre-populated but the form
|
||||
* will create a new record on submit. Shows a loading spinner while data is
|
||||
* pending, a permission error when the user lacks `insertPermission`, and a
|
||||
* fetch-error panel when the source record cannot be loaded.
|
||||
*
|
||||
* @returns A composed page that renders one of:
|
||||
* - A full-screen spinner while metadata or record data is loading
|
||||
* - A permission-error banner when the user lacks `insertPermission`
|
||||
* - A destructive error panel when the source record cannot be fetched
|
||||
* - `<EntityForm>` in copy mode (`isCopy=true`, pre-populated with source values)
|
||||
* wrapped in a centered `max-w-4xl` container; submitting creates a new record
|
||||
*/
|
||||
export default function EntityCopyPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
const params = useRouteParams<{ slug: string; recordId: string }>()
|
||||
return <CopyPageContent key={JSON.stringify(params)} slug={params.slug} recordId={params.recordId} />
|
||||
}
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
/**
|
||||
* Own metadata/source loading while rendering components receive ready metadata.
|
||||
* @param props - Exact route identifiers.
|
||||
* @returns Base form and explicit full-copy draft flow.
|
||||
*/
|
||||
function CopyPageContent({ slug, recordId }: { slug: string; recordId: string }) {
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const [mode, setMode] = useState<'base' | 'full'>('base')
|
||||
const [tree, setTree] = useState<CopyNode>()
|
||||
const [copyState, setCopyState] = useState<NonNullable<EntityFormProps['copyAssociations']>>()
|
||||
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const tableMetaData = metaData?.tables?.[slug]
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData),
|
||||
enabled: canInsertRecords(tableMetaData) && canReadRecords(tableMetaData),
|
||||
includeAssociations: false,
|
||||
})
|
||||
|
||||
const expanded = useRecord({ tableName: slug, primaryKey: recordId, includeAssociations: true,
|
||||
enabled: mode === 'full' && !tree && canInsertRecords(tableMetaData) && canReadRecords(tableMetaData),
|
||||
})
|
||||
const sourceTables = useMemo(() => {
|
||||
if (!expanded.record) return { names: [] as string[] }
|
||||
try { return { names: copyTableNames(expanded.record).filter(name => name !== slug) } }
|
||||
catch (failure) { return { names: [] as string[], error: failure instanceof Error ? failure.message : 'Full copy source is invalid.' } }
|
||||
}, [expanded.record, slug])
|
||||
const targetQueries = useQueries({ queries: sourceTables.names.map(name => ({
|
||||
queryKey: queryKeys.tableMetadata(name), queryFn: () => loadTableMetaData(name), staleTime: 1000 * 60 * 30,
|
||||
enabled: mode === 'full' && !tree,
|
||||
})) })
|
||||
let fullError = sourceTables.error
|
||||
if (expanded.isError || targetQueries.some(query => query.isError)) {
|
||||
const accessDenied = (expanded.isError && getErrorStatusCode(expanded.error) === 403) ||
|
||||
targetQueries.some(query => query.isError && getErrorStatusCode(query.error) === 403)
|
||||
fullError = accessDenied
|
||||
? 'You do not have access to all the information needed for Full Copy. Choose Base Copy to copy this record only.'
|
||||
: 'Full Copy could not load all required information. Reload to try again, or choose Base Copy.'
|
||||
}
|
||||
let prepared: CopyNode | undefined
|
||||
if (!tree && !fullError && expanded.record && record && tableMetaData && targetQueries.every(query => Boolean(query.data))) {
|
||||
const tables: Record<string, QTableMetaData> = { [slug]: tableMetaData }
|
||||
targetQueries.forEach((query, index) => { if (query.data) tables[sourceTables.names[index]] = query.data })
|
||||
try { prepared = prepareCopyTree(tableMetaData, { ...expanded.record, values: record.values }, tables) }
|
||||
catch (failure) { fullError = failure instanceof Error ? failure.message : 'Full copy source is invalid.' }
|
||||
}
|
||||
useEffect(() => { if (prepared && !tree) setTree(prepared) }, [prepared, tree])
|
||||
const unavailable = fullError ?? (!tree || !copyState ? 'Loading full copy source and metadata…' : undefined)
|
||||
const fullState = unavailable ? { getRecords: () => { throw new Error(unavailable) }, error: unavailable } : copyState
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Copy ${tableMetaData?.label ?? slug} #${recordId}`)
|
||||
if (tableMetaData) {
|
||||
@@ -39,6 +122,10 @@ export default function EntityCopyPage() {
|
||||
}
|
||||
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
|
||||
@@ -47,14 +134,21 @@ export default function EntityCopyPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!tableMetaData.insertPermission) {
|
||||
if (!tableMetaData.readPermission) {
|
||||
return <div role="alert" data-qqq-id="permission-denied" className="py-12 text-center text-destructive">You do not have permission to read the source {tableMetaData.label} record.</div>
|
||||
}
|
||||
|
||||
if (!canInsertRecords(tableMetaData)) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
|
||||
role="alert"
|
||||
data-qqq-id="permission-denied"
|
||||
>
|
||||
<p className="text-sm text-yellow-700">
|
||||
You do not have permission to create {tableMetaData.label} records.
|
||||
{!hasCapability(tableMetaData, 'TABLE_INSERT')
|
||||
? `${tableMetaData.label} records cannot be created.`
|
||||
: `You do not have permission to create ${tableMetaData.label} records.`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -67,7 +161,7 @@ export default function EntityCopyPage() {
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
|
||||
{recordLoadFailure(tableMetaData.label, recordId, error)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -75,11 +169,21 @@ export default function EntityCopyPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-copy-${slug}-${recordId}`}>
|
||||
<fieldset className="mb-4 flex flex-wrap gap-4">
|
||||
<legend className="mb-2 font-medium">Copy scope</legend>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="copy-mode" checked={mode === 'base'} onChange={() => setMode('base')} data-qqq-id="copy-mode-base" />Base copy</label>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="copy-mode" checked={mode === 'full'} onChange={() => setMode('full')} data-qqq-id="copy-mode-full" />Full copy</label>
|
||||
</fieldset>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{mode === 'base' ? 'Copy this record’s editable fields. Associated records are not copied.' : 'Copy editable fields and every loaded named association. A record reached through two named paths is copied twice. Normal insert defaults and validation apply. Limited to 64 association levels and 1000 associated records in this form.'}</p>
|
||||
<EntityForm
|
||||
tableMetaData={tableMetaData}
|
||||
widgets={metaData?.widgets}
|
||||
record={record}
|
||||
isCopy={true}
|
||||
/>
|
||||
copyAssociations={mode === 'full' ? fullState : undefined}
|
||||
>
|
||||
{tree && <fieldset hidden={mode !== 'full'} disabled={mode !== 'full'} className="min-w-0"><FullCopyDraft tree={tree} onChange={setCopyState} /></fieldset>}
|
||||
</EntityForm>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RecordDeveloperView page — shows raw record data and table metadata as formatted JSON for debugging.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// RecordDeveloperView — shows raw record data and table metadata as formatted JSON
|
||||
// Useful for debugging field values, types, and backend responses
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
/**
|
||||
* Renders a developer debug view for a single record, showing all field values
|
||||
* in a table and the raw record and table metadata as collapsible JSON blocks.
|
||||
*
|
||||
* @returns A composed page that assembles:
|
||||
* - A field-values summary table (field name, type, raw value for every field in the record)
|
||||
* - A `<JsonBlock>` for the raw record (expanded by default)
|
||||
* - A `<JsonBlock>` for the full table metadata (collapsed by default)
|
||||
* - A loading spinner while either query is in-flight, and an error panel if the record fetch fails
|
||||
*/
|
||||
export default function RecordDeveloperViewPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const params = useRouteParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
@@ -122,6 +149,15 @@ export default function RecordDeveloperViewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible card that renders a JSON value as a formatted `<pre>` block.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - Heading displayed in the toggle button.
|
||||
* @param props.value - The value to serialize and display as JSON.
|
||||
* @param props.defaultOpen - Whether the panel starts expanded. Defaults to `true`.
|
||||
* @returns A collapsible JSON inspector card.
|
||||
*/
|
||||
function JsonBlock({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -1,35 +1,81 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file EntityEdit page — serves the route `/app/[slug]/[recordId]/edit` and renders the edit form.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// EntityEdit — edit an existing record
|
||||
// Package 3: full implementation using EntityForm + useRecord
|
||||
/**
|
||||
* EntityEdit page — serves the route `/app/[slug]/[recordId]/edit` and renders the
|
||||
* edit form for an existing QQQ record.
|
||||
*
|
||||
* Route params:
|
||||
* - `slug` — the QQQ table name.
|
||||
* - `recordId` — the primary-key value of the record to edit.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { recordLoadFailure } from '@/lib/utils/error-utils'
|
||||
import { EntityForm } from '@/components/forms/EntityForm'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { canEditRecords, hasCapability } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Renders the record-edit form for the record identified by `slug` and `recordId`.
|
||||
*
|
||||
* Fetches both application metadata and the existing record data via `useRecord`.
|
||||
* Shows a loading spinner while either is pending. Renders a permission error
|
||||
* when the user lacks `editPermission` on the table, and a fetch-error panel
|
||||
* when the record cannot be loaded. On success renders `<EntityForm>` in edit
|
||||
* mode (pre-populated with the existing record values).
|
||||
*
|
||||
* @returns A composed page that renders one of:
|
||||
* - A full-screen spinner while metadata or record data is loading
|
||||
* - A permission-error banner when the user lacks `editPermission`
|
||||
* - A destructive error panel when the record cannot be fetched
|
||||
* - `<EntityForm>` in edit mode (pre-populated with the existing record values)
|
||||
* wrapped in a centered `max-w-4xl` container
|
||||
*/
|
||||
export default function EntityEditPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const params = useRouteParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const tableMetaData = metaData?.tables?.[slug]
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData),
|
||||
enabled: canEditRecords(tableMetaData),
|
||||
includeAssociations: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,6 +85,10 @@ export default function EntityEditPage() {
|
||||
}
|
||||
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
|
||||
@@ -47,14 +97,17 @@ export default function EntityEditPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!tableMetaData.editPermission) {
|
||||
if (!canEditRecords(tableMetaData)) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
|
||||
role="alert"
|
||||
data-qqq-id="permission-denied"
|
||||
>
|
||||
<p className="text-sm text-yellow-700">
|
||||
You do not have permission to edit {tableMetaData.label} records.
|
||||
{!hasCapability(tableMetaData, 'TABLE_UPDATE')
|
||||
? `${tableMetaData.label} records cannot be edited.`
|
||||
: `You do not have permission to edit ${tableMetaData.label} records.`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -67,7 +120,7 @@ export default function EntityEditPage() {
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
|
||||
{recordLoadFailure(tableMetaData.label, recordId, error)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -75,7 +128,7 @@ export default function EntityEditPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
|
||||
<EntityForm tableMetaData={tableMetaData} record={record} />
|
||||
<EntityForm tableMetaData={tableMetaData} record={record} widgets={metaData?.widgets} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Prerenders this dynamic segment once for the Javalin-served static export.
|
||||
* The server returns that page for every concrete value; see use-route-params.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
|
||||
|
||||
/**
|
||||
* Static export placeholder params for this segment.
|
||||
*
|
||||
* @returns One placeholder entry.
|
||||
*/
|
||||
export function generateStaticParams() {
|
||||
return [{ recordId: EXPORT_PLACEHOLDER }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through layout that exists to carry generateStaticParams.
|
||||
*
|
||||
* @param props - Layout props.
|
||||
* @param props.children - Route content.
|
||||
* @returns The children unchanged.
|
||||
*/
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { deserializeFilter } from '@/lib/utils/filter-utils'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import RecordViewPage from './page'
|
||||
|
||||
const navigation = vi.hoisted(() => ({ search: 'tab=related' }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ slug: 'person', recordId: '1' }),
|
||||
usePathname: () => '/app/person/1',
|
||||
useSearchParams: () => new URLSearchParams(navigation.search),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
}))
|
||||
|
||||
const baseRecord = { tableName: 'person', values: { id: 1, firstName: 'Avery' }, recordLabel: 'Avery Sample' }
|
||||
|
||||
function fixture(childRead = false) {
|
||||
const person = structuredClone(qInstance.tables.person)
|
||||
const pet = { ...structuredClone(qInstance.tables.company), name: 'pet', label: 'Pet', readPermission: childRead, insertPermission: true }
|
||||
pet.fields.personId = { ...person.fields.id, name: 'personId', label: 'Person', isEditable: true, isHidden: false }
|
||||
person.exposedJoins = []
|
||||
person.associations = [{ name: 'pets', associatedTableName: 'pet', join: {
|
||||
name: 'personJoinPet', type: 'ONE_TO_MANY', leftTable: 'person', rightTable: 'pet',
|
||||
joinOns: [{ leftField: 'id', rightField: 'personId' }],
|
||||
} }]
|
||||
const registry = { ...qInstance, widgets: { ...qInstance.widgets }, tables: { person: { name: 'person', label: 'Person', readPermission: true }, pet: { name: 'pet', readPermission: childRead, insertPermission: true } } }
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)),
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(person)),
|
||||
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json(pet)),
|
||||
)
|
||||
return { person, pet, registry }
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><QContextProvider><RecordViewPage /></QContextProvider></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('RecordViewPage independent association loading', () => {
|
||||
beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); navigation.search = 'tab=related' })
|
||||
|
||||
it('keeps the base and child Add, and requests no expansion, when the child table is listed without read permission (QRun-IO/qqq#671)', async () => {
|
||||
fixture()
|
||||
const requests: string[] = []
|
||||
server.use(http.get('/data/person/1', ({ request }) => {
|
||||
requests.push(new URL(request.url).searchParams.get('includeAssociations') ?? '')
|
||||
return HttpResponse.json(baseRecord)
|
||||
}))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
expect(await screen.findByText('Related records are unavailable.')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
|
||||
expect(requests).toEqual(['false'])
|
||||
})
|
||||
|
||||
it('keeps the readable base and child Add when whole association expansion is denied', async () => {
|
||||
const { person: metadata, pet } = fixture(true)
|
||||
pet.readPermission = false
|
||||
const before = structuredClone(metadata)
|
||||
const requests: string[] = []
|
||||
server.use(http.get('/data/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
requests.push(mode)
|
||||
return mode === 'true'
|
||||
? HttpResponse.json({ error: 'Pet read denied' }, { status: 403 })
|
||||
: HttpResponse.json(baseRecord)
|
||||
}))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
expect(await screen.findByText('Related records could not be loaded.')).toBeVisible()
|
||||
const add = screen.getByRole('button', { name: '+ Add Pet' })
|
||||
expect(add).toBeEnabled()
|
||||
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
fireEvent.click(add)
|
||||
expect(await screen.findByRole('dialog', { name: 'Add Pet' })).toBeVisible()
|
||||
expect(requests).toEqual(['false', 'true'])
|
||||
expect(metadata).toEqual(before)
|
||||
})
|
||||
|
||||
it('neither requests nor shows an association whose table is hidden from the user (QRun-IO/qqq#671)', async () => {
|
||||
const { registry } = fixture()
|
||||
const { pet: _hidden, ...visibleTables } = registry.tables
|
||||
void _hidden
|
||||
const requests: string[] = []
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData', () => HttpResponse.json({ ...registry, tables: visibleTables })),
|
||||
http.get('/qqq/v1/metaData/table/pet', () => { requests.push('pet metadata'); return HttpResponse.json({ error: 'not found' }, { status: 404 }) }),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
requests.push(`includeAssociations=${new URL(request.url).searchParams.get('includeAssociations')}`)
|
||||
return HttpResponse.json(baseRecord)
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
await waitFor(() => expect(requests).toContain('includeAssociations=false'))
|
||||
expect(requests).toEqual(['includeAssociations=false'])
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Related records could not be loaded.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not rename association keys or present an unmatched association as empty', async () => {
|
||||
fixture(true)
|
||||
server.use(http.get('/data/person/1', ({ request }) => HttpResponse.json({
|
||||
...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
|
||||
associatedRecords: { pet: [{ tableName: 'pet', values: { id: 2, name: 'Actual pet' }, recordLabel: 'Actual pet' }] },
|
||||
} : {}),
|
||||
})))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
await waitFor(() => expect(screen.queryByText('Loading related records...')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
})
|
||||
it('retains explicitly keyed related records when the response binding is available', async () => {
|
||||
fixture(true)
|
||||
server.use(http.get('/data/person/1', ({ request }) => HttpResponse.json({
|
||||
...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
|
||||
associatedRecords: { pets: [{ tableName: 'pet', values: { id: 2, name: 'Mapped pet' }, recordLabel: 'Mapped pet' }] },
|
||||
} : {}),
|
||||
})))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Mapped pet')).toBeVisible()
|
||||
expect(screen.queryByText('Related records are unavailable.')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps arbitrary aliases distinct and deduplicates target metadata loads', async () => {
|
||||
const { person, pet } = fixture(true)
|
||||
person.associations = ['care / primary', 'scheduled reviews'].map((name) => ({ ...person.associations![0], name }))
|
||||
const metadataCalls: string[] = []
|
||||
let releaseMetadata!: () => void
|
||||
const ready = new Promise<void>((resolve) => { releaseMetadata = resolve })
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/pet', async () => { metadataCalls.push('pet'); await ready; return HttpResponse.json(pet) }),
|
||||
http.get('/data/person/1', ({ request }) => HttpResponse.json({ ...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true'
|
||||
? { associatedRecords: { 'care / primary': [], 'scheduled reviews': [{ tableName: 'pet', values: { id: 4, name: 'Review pet' } }] } } : {}),
|
||||
})),
|
||||
)
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
await waitFor(() => expect(screen.getAllByText('Loading related metadata...')).toHaveLength(2))
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
releaseMetadata()
|
||||
expect(await screen.findByText('No care / primary records')).toBeVisible()
|
||||
expect(await screen.findByText('Review pet')).toBeVisible()
|
||||
expect(metadataCalls).toEqual(['pet'])
|
||||
expect(screen.getAllByRole('button', { name: '+ Add Pet' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each(['tab=overview', 'view=list'])('places explicitly bound groups in their sections (%s)', async (view) => {
|
||||
navigation.search = view
|
||||
const { person, registry } = fixture(true)
|
||||
person.associations = [{ ...person.associations![0], name: 'care / primary' }, { ...person.associations![0], name: 'scheduled reviews' }]
|
||||
person.sections = [
|
||||
{ name: 'fieldGuide', label: 'Companions', tier: 'T2', isHidden: false, fieldNames: [], widgetName: 'companionPanel' },
|
||||
{ name: 'reviewSchedule', label: 'Reviews', tier: 'T2', isHidden: false, fieldNames: [], widgetName: 'reviewEditor' },
|
||||
]
|
||||
registry.widgets.companionPanel = { name: 'companionPanel', label: 'Different widget title', type: 'childRecordList', hasPermission: true, defaultValues: { manageAssociationName: 'care / primary' } }
|
||||
registry.widgets.reviewEditor = { name: 'reviewEditor', label: 'Another title', type: 'rowBuilder', hasPermission: true, defaultValues: { associationName: 'scheduled reviews' } }
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { 'care / primary': [], 'scheduled reviews': [] } })))
|
||||
const { container } = renderPage()
|
||||
const byId = (id: string) => Array.from(container.querySelectorAll('[data-qqq-id]')).find((element) => element.getAttribute('data-qqq-id') === id)
|
||||
await waitFor(() => expect(byId('button-create-association-care%20%2F%20primary')).toBeEnabled())
|
||||
expect(byId('associated-records-care%20%2F%20primary')).toHaveTextContent('Companions')
|
||||
expect(byId('associated-records-scheduled%20reviews')).toHaveTextContent('Reviews')
|
||||
expect(screen.queryByRole('tab', { name: 'Related' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes target metadata failure without hiding the base or claiming an empty group', async () => {
|
||||
fixture(true)
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json({ error: 'Unavailable' }, { status: 500 })),
|
||||
http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })),
|
||||
)
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
expect(await screen.findByText('Related table metadata is unavailable.')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits every composite non-PK relationship field despite their exclusion from editable inputs', async () => {
|
||||
const { person, pet } = fixture(false)
|
||||
person.fields.schedule = { ...person.fields.id, name: 'schedule' }
|
||||
person.fields.visitDate = { ...person.fields.firstName, name: 'visitDate', type: 'DATE' }
|
||||
pet.fields.birthDate = { ...person.fields.visitDate, name: 'birthDate', isEditable: true }
|
||||
pet.fields.personId.possibleValueSourceName = undefined
|
||||
person.associations![0].join.joinOns = [{ leftField: 'schedule', rightField: 'personId' }, { leftField: 'visitDate', rightField: 'birthDate' }]
|
||||
server.use(http.get('/data/person/1', ({ request }) => new URL(request.url).searchParams.get('includeAssociations') === 'true'
|
||||
? HttpResponse.json({ error: 'Denied' }, { status: 403 })
|
||||
: HttpResponse.json({ ...baseRecord, values: { ...baseRecord.values, schedule: 2, visitDate: '1990-01-15' } })))
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'pet', values: { id: 8, personId: 2, birthDate: '1990-01-15' } }] })
|
||||
const before = structuredClone({ person, pet })
|
||||
renderPage()
|
||||
fireEvent.click(await screen.findByRole('button', { name: '+ Add Pet' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Add Pet' })
|
||||
expect(within(dialog).getAllByRole('heading', { name: 'Add Pet' })).toHaveLength(1)
|
||||
expect(within(dialog).queryByLabelText('Person')).not.toBeInTheDocument()
|
||||
fireEvent.change(within(dialog).getByLabelText(/Name/), { target: { value: 'Created child' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalled())
|
||||
expect(post.mock.calls[0][0]).toBe('/data/pet')
|
||||
const values = post.mock.calls[0][1] as FormData
|
||||
expect(values.get('personId')).toBe('2')
|
||||
expect(values.get('birthDate')).toBe('1990-01-15')
|
||||
expect(values.get('name')).toBe('Created child')
|
||||
expect(values.has('associations')).toBe(false)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect({ person, pet }).toEqual(before)
|
||||
})
|
||||
|
||||
it('round-trips Unicode and plus-bearing base64 View All filters through URLSearchParams', async () => {
|
||||
const { person, pet } = fixture(true)
|
||||
person.associations![0].join.joinOns = [{ leftField: 'firstName', rightField: 'personId' }]
|
||||
pet.fields.personId.type = 'STRING'
|
||||
const value = '>>>漢'
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, values: { id: 1, firstName: value }, associatedRecords: { pets: [] } })))
|
||||
renderPage()
|
||||
const link = await screen.findByRole('link', { name: 'View All' })
|
||||
const url = new URL(link.getAttribute('href')!, 'http://localhost')
|
||||
expect(url.search).toContain('%2B')
|
||||
expect(deserializeFilter(url.searchParams.get('filter')!)).toMatchObject({ criteria: [{ fieldName: 'personId', operator: 'EQUALS', values: [value] }], booleanOperator: 'AND' })
|
||||
})
|
||||
|
||||
it('allows correctly oriented direct Add for reverse joins without claiming expanded absence', async () => {
|
||||
const { person } = fixture(true)
|
||||
person.associations![0].join = { ...person.associations![0].join, leftTable: 'pet', rightTable: 'person', type: 'MANY_TO_ONE', joinOns: [{ leftField: 'personId', rightField: 'id' }] }
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Related record loading for reverse associations is not supported.')).toBeVisible()
|
||||
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('retains a panel with no visible columns while denying an unavailable relationship write', async () => {
|
||||
const { person, pet } = fixture(false)
|
||||
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
|
||||
delete person.fields.id
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json(baseRecord)))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Relationship values are unavailable; this record cannot be linked.')).toBeVisible()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps permitted Add available when there are no visible child columns', async () => {
|
||||
const { pet } = fixture(false)
|
||||
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json(baseRecord)))
|
||||
renderPage()
|
||||
expect(await screen.findByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps readable data and View All when INSERT alone is denied', async () => {
|
||||
const { pet } = fixture(true)
|
||||
pet.insertPermission = false
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [{ tableName: 'pet', values: { id: 'one/two?', name: 'Readable pet' } }] } })))
|
||||
renderPage()
|
||||
const recordLinks = await screen.findAllByRole('link', { name: 'View Pet record one/two?' })
|
||||
for (const link of recordLinks) expect(link.getAttribute('href')).toContain('/app/pet/one%2Ftwo%3F?')
|
||||
expect(screen.getByRole('link', { name: 'View All' })).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,39 +1,116 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RecordView page — serves the route `/app/[slug]/[recordId]` and renders the record detail view.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// RecordView page — view a single record
|
||||
// Package 3: full implementation using RecordView component + useRecord hook
|
||||
/**
|
||||
* RecordView page — serves the route `/app/[slug]/[recordId]` and renders a
|
||||
* detailed view of a single QQQ record.
|
||||
*
|
||||
* Route params:
|
||||
* - `slug` — the QQQ table name.
|
||||
* - `recordId` — the primary-key value of the record to display.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { addRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { auditSource } from '@/lib/api/audits'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import type { AssociationTableState } from '@/lib/utils/association-utils'
|
||||
import { canReadRecords } from '@/lib/auth/permissions'
|
||||
import { RecordView } from '@/components/records/RecordView'
|
||||
|
||||
/**
|
||||
* Renders the detail view for a single record identified by `slug` (table name)
|
||||
* and `recordId` (primary key).
|
||||
*
|
||||
* Loads the base record and optional whole-association expansion independently. Once
|
||||
* the record loads it is registered in the recently-viewed history (via
|
||||
* `addRecentRecord`) so it surfaces in the GlobalSearch and SearchDialog.
|
||||
* The page header in QContext is updated to the record label.
|
||||
*
|
||||
* @returns A composed view that assembles:
|
||||
* - A full-screen spinner while table metadata resolves
|
||||
* - `<RecordView>` wired with the fetched record, loading state, error state,
|
||||
* a refetch callback, the list of available processes, and all table definitions
|
||||
* (needed to render association sub-tables)
|
||||
*/
|
||||
export default function RecordViewPage() {
|
||||
const params = useParams<{ slug: string; recordId: string }>()
|
||||
const params = useRouteParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const tableMetaData = metaData?.tables?.[slug]
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
const { record, isLoading, isError, error, refetch } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(tableMetaData),
|
||||
enabled: canReadRecords(tableMetaData),
|
||||
includeAssociations: false,
|
||||
})
|
||||
|
||||
const targetNames = [...new Set(tableMetaData?.associations?.map((association) => association.associatedTableName) ?? [])]
|
||||
// Associated tables hidden from the user (absent from their metadata) are neither requested nor shown;
|
||||
// disabled ones (listed, readPermission false) keep their panel so permitted actions such as Add remain.
|
||||
const deniedTargets = new Set(targetNames.filter((name) => metaData?.tables && !metaData.tables[name]))
|
||||
const readableTargets = targetNames.filter((name) => !deniedTargets.has(name))
|
||||
const targetQueries = useQueries({ queries: readableTargets.map((name) => ({
|
||||
queryKey: queryKeys.tableMetadata(name),
|
||||
queryFn: () => loadTableMetaData(name),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})) })
|
||||
const associationTables: Record<string, AssociationTableState> = Object.fromEntries(targetNames.map((name) => {
|
||||
if (deniedTargets.has(name)) return [name, { isLoading: false, isError: false, isDenied: true }]
|
||||
const target = targetQueries[readableTargets.indexOf(name)]
|
||||
return [name, { table: target.data, isLoading: target.isLoading, isError: target.isError }]
|
||||
}))
|
||||
|
||||
// The legacy endpoint expands every association (all or nothing), so it is not requested
|
||||
// when an associated table is hidden from the user or listed without read permission
|
||||
// (DenyBehavior.DISABLED; its panel says the records are unavailable). A denial must not
|
||||
// hide the base record.
|
||||
const unreadableTargets = readableTargets.filter((name) => metaData?.tables?.[name]?.readPermission === false)
|
||||
const associations = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: Boolean(record) && Boolean(tableMetaData?.associations?.length) && Boolean(metaData)
|
||||
&& deniedTargets.size === 0 && unreadableTargets.length === 0,
|
||||
includeAssociations: true,
|
||||
})
|
||||
const displayRecord = record && associations.record && !associations.isError
|
||||
? { ...record, associatedRecords: associations.record.associatedRecords }
|
||||
: record
|
||||
|
||||
useEffect(() => {
|
||||
if (record?.recordLabel) {
|
||||
@@ -59,6 +136,10 @@ export default function RecordViewPage() {
|
||||
}
|
||||
}, [record, tableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
|
||||
@@ -67,18 +148,38 @@ export default function RecordViewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!canReadRecords(tableMetaData)) {
|
||||
return (
|
||||
<div className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center" role="alert" data-qqq-id="permission-denied">
|
||||
<p className="text-sm text-yellow-700">You do not have permission to view {tableMetaData.label} records.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
|
||||
|
||||
return (
|
||||
<RecordView
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={refetch}
|
||||
processes={tableProcesses}
|
||||
allTables={metaData?.tables}
|
||||
/>
|
||||
<>
|
||||
{record && associations.isLoading && <p role="status">Loading related records...</p>}
|
||||
{record && associations.isError && (
|
||||
<p role="alert" className="mb-4 text-destructive">Related records could not be loaded.</p>
|
||||
)}
|
||||
<RecordView
|
||||
tableMetaData={tableMetaData}
|
||||
record={displayRecord}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={() => {
|
||||
refetch()
|
||||
if (tableMetaData.associations?.length) associations.refetch()
|
||||
}}
|
||||
processes={tableProcesses}
|
||||
allTables={metaData?.tables}
|
||||
widgetMetaDataMap={metaData?.widgets}
|
||||
associationTables={associationTables}
|
||||
auditSource={auditSource(metaData)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,69 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file EntityCreate page — serves the route `/app/[slug]/create` and renders the record-creation form.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// EntityCreate — create a new record
|
||||
// Package 3: full implementation using EntityForm component
|
||||
/**
|
||||
* EntityCreate page — serves the route `/app/[slug]/create` and renders the
|
||||
* record-creation form for the table identified by `slug`.
|
||||
*
|
||||
* Route params:
|
||||
* - `slug` — the QQQ table name for which a new record should be created.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { EntityForm } from '@/components/forms/EntityForm'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { canInsertRecords, hasCapability } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Renders the record-creation form for the table identified by `slug`.
|
||||
*
|
||||
* Fetches application metadata to resolve the table definition, then renders
|
||||
* `<EntityForm>` with no initial record (create mode). Shows a permission
|
||||
* error banner when the user lacks `insertPermission` on the table, and a
|
||||
* loading spinner while metadata is being fetched.
|
||||
*
|
||||
* @returns A composed page that renders one of:
|
||||
* - A full-screen spinner while metadata resolves
|
||||
* - A permission-error banner when the user lacks `insertPermission`
|
||||
* - `<EntityForm>` in create mode (no `record` prop) wrapped in a centered `max-w-4xl` container
|
||||
*/
|
||||
export default function EntityCreatePage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const params = useRouteParams<{ slug: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const tableMetaData = metaData?.tables?.[slug]
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Create ${tableMetaData?.label ?? slug}`)
|
||||
@@ -32,6 +72,10 @@ export default function EntityCreatePage() {
|
||||
}
|
||||
}, [tableMetaData?.label, slug, tableMetaData, setPageHeader, setTableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
|
||||
@@ -40,14 +84,17 @@ export default function EntityCreatePage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!tableMetaData.insertPermission) {
|
||||
if (!canInsertRecords(tableMetaData)) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
|
||||
role="alert"
|
||||
data-qqq-id="permission-denied"
|
||||
>
|
||||
<p className="text-sm text-yellow-700">
|
||||
You do not have permission to create {tableMetaData.label} records.
|
||||
{!hasCapability(tableMetaData, 'TABLE_INSERT')
|
||||
? `${tableMetaData.label} records cannot be created.`
|
||||
: `You do not have permission to create ${tableMetaData.label} records.`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -55,7 +102,7 @@ export default function EntityCreatePage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-create-${slug}`}>
|
||||
<EntityForm tableMetaData={tableMetaData} />
|
||||
<EntityForm tableMetaData={tableMetaData} widgets={metaData?.widgets} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,45 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file TableDeveloperView page — shows raw table metadata as formatted JSON for debugging field definitions and permissions.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// TableDeveloperView — shows raw table metadata as formatted JSON
|
||||
// Useful for debugging field definitions, sections, and permissions
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
/**
|
||||
* Renders a developer debug view for a QQQ table, showing summary statistics
|
||||
* and the full table metadata as a collapsible JSON block.
|
||||
*
|
||||
* @returns A composed page that assembles:
|
||||
* - A 4-column stats grid (`<MetaStat>` cards: field count, section count, permissions, primary key)
|
||||
* - A collapsible `<JsonBlock>` panel rendering the full table metadata as formatted JSON
|
||||
* - A loading spinner while metadata is fetching, and a destructive error panel on failure
|
||||
*/
|
||||
export default function TableDeveloperViewPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const params = useRouteParams<{ slug: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
@@ -83,6 +109,14 @@ export default function TableDeveloperViewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single labeled statistic card.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - The stat label displayed above the value.
|
||||
* @param props.value - The numeric or string value to display prominently.
|
||||
* @returns A bordered card showing the label and value.
|
||||
*/
|
||||
function MetaStat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
@@ -96,6 +130,15 @@ function MetaStat({ label, value }: { label: string; value: string | number }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible card that renders a JSON value as a formatted `<pre>` block.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - Heading displayed in the toggle button.
|
||||
* @param props.value - The value to serialize and display as JSON.
|
||||
* @param props.defaultOpen - Whether the panel starts expanded. Defaults to `true`.
|
||||
* @returns A collapsible JSON inspector card.
|
||||
*/
|
||||
function JsonBlock({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -1,26 +1,101 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Record-by-key page — `/app/{table}/key?{field}={value}...` opens the one record matching the given field values.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// RecordViewByUniqueKey — view a record by unique key
|
||||
// Full implementation in Package 3
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { queryRecords } from '@/lib/api/tables'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
/**
|
||||
* Looks up a record by field values from the query string, as Material
|
||||
* Dashboard's `RecordViewByUniqueKey` does (typically a unique key, e.g.
|
||||
* `/app/person/key?email=avery@example.invalid`).
|
||||
*
|
||||
* Each parameter becomes an `EQUALS` criterion. Exactly one match replaces the
|
||||
* URL with the record's view page; no match, several matches, an unknown field
|
||||
* or no parameters show an error.
|
||||
*
|
||||
* @returns A loading state, or an alert explaining why no single record matched.
|
||||
*/
|
||||
export default function RecordViewByKeyPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const { slug } = useRouteParams<{ slug: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { setPageHeader } = useQContext()
|
||||
const slug = params.slug
|
||||
const { data: table, isError: tableError } = useTableMetaData(slug)
|
||||
|
||||
const criteria = useMemo(() => [...searchParams.entries()], [searchParams])
|
||||
const unknownField = table ? criteria.find(([fieldName]) => !table.fields?.[fieldName])?.[0] : undefined
|
||||
|
||||
const lookup = useQuery({
|
||||
queryKey: [...queryKeys.tableRecords(slug), 'byKey', searchParams.toString()],
|
||||
queryFn: () => queryRecords(slug, {
|
||||
filter: {
|
||||
criteria: criteria.map(([fieldName, value]) => ({ fieldName, operator: 'EQUALS' as const, values: [value] })),
|
||||
booleanOperator: 'AND',
|
||||
limit: 2,
|
||||
},
|
||||
}),
|
||||
enabled: Boolean(table) && criteria.length > 0 && !unknownField,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`View ${slug} by Key`)
|
||||
}, [slug, setPageHeader])
|
||||
setPageHeader(`View ${table?.label ?? slug} by Key`)
|
||||
}, [slug, table?.label, setPageHeader])
|
||||
|
||||
const records = lookup.data?.records
|
||||
const match = records?.length === 1 ? records[0] : undefined
|
||||
const primaryKey = match && table ? match.values[table.primaryKeyField] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (primaryKey !== undefined && primaryKey !== null) {
|
||||
router.replace(`/app/${encodeURIComponent(slug)}/${encodeURIComponent(String(primaryKey))}`)
|
||||
}
|
||||
}, [primaryKey, router, slug])
|
||||
|
||||
let error: string | undefined
|
||||
if (tableError) error = `Could not load the ${slug} table.`
|
||||
else if (table && criteria.length === 0) error = `Add field values to the address to look up a ${table.label} record, for example ?${table.primaryKeyField}=1.`
|
||||
else if (table && unknownField) error = `Query-string parameter [${unknownField}] is not a defined field on the ${table.label} table.`
|
||||
else if (lookup.isError) error = lookup.error instanceof Error ? lookup.error.message : 'Unexpected error running query'
|
||||
else if (table && records?.length === 0) error = `No ${table.label} record was found matching the given values.`
|
||||
else if (table && records && records.length > 1) error = `More than one ${table.label} record was found matching the given values.`
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-destructive/40 bg-destructive/10 p-6 text-sm text-destructive" data-qqq-id={`record-view-key-error-${slug}`}>
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-qqq-id={`record-view-key-${slug}`}>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
|
||||
<p className="text-muted-foreground">Record view by key -- implemented in Package 3</p>
|
||||
</div>
|
||||
<div role="status" aria-label="Looking up record" className="flex items-center justify-center py-12" data-qqq-id={`record-view-key-${slug}`}>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Prerenders this dynamic segment once for the Javalin-served static export.
|
||||
* The server returns that page for every concrete value; see use-route-params.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
|
||||
|
||||
/**
|
||||
* Static export placeholder params for this segment.
|
||||
*
|
||||
* @returns One placeholder entry.
|
||||
*/
|
||||
export function generateStaticParams() {
|
||||
return [{ slug: EXPORT_PLACEHOLDER }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through layout that exists to carry generateStaticParams.
|
||||
*
|
||||
* @param props - Layout props.
|
||||
* @param props.children - Route content.
|
||||
* @returns The children unchanged.
|
||||
*/
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import * as processesApi from '@/lib/api/processes'
|
||||
import SlugPage from './page'
|
||||
|
||||
const { recordQuery, params, location } = vi.hoisted(() => ({
|
||||
recordQuery: vi.fn(() => <div data-testid="record-query" />),
|
||||
params: { slug: 'person' },
|
||||
location: { search: '' },
|
||||
}))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => params,
|
||||
usePathname: () => '/app/' + params.slug,
|
||||
useSearchParams: () => new URLSearchParams(location.search),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/components/query', () => ({ RecordQuery: recordQuery }))
|
||||
vi.mock('@/components/widgets', () => ({ AppHome: () => <div>App home</div> }))
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<QContextProvider><SlugPage /></QContextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The V1 registry deliberately omits fields, sections, primary keys and joins.
|
||||
const registry = {
|
||||
...qInstance,
|
||||
tables: Object.fromEntries(Object.entries(qInstance.tables).map(([name, table]) => [name, {
|
||||
name, label: table.label, readPermission: table.readPermission,
|
||||
insertPermission: table.insertPermission,
|
||||
}])),
|
||||
}
|
||||
|
||||
describe('SlugPage table metadata readiness', () => {
|
||||
beforeEach(() => {
|
||||
params.slug = 'person'
|
||||
location.search = ''
|
||||
recordQuery.mockClear()
|
||||
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)))
|
||||
})
|
||||
|
||||
it('waits for full active metadata and passes the light permission registry separately', async () => {
|
||||
let releaseTable!: () => void
|
||||
const tableReady = new Promise<void>((resolve) => { releaseTable = resolve })
|
||||
let requested = false
|
||||
server.use(http.get('/qqq/v1/metaData/table/person', async () => {
|
||||
requested = true
|
||||
await tableReady
|
||||
return HttpResponse.json(qInstance.tables.person)
|
||||
}))
|
||||
renderPage()
|
||||
try {
|
||||
await waitFor(() => expect(requested).toBe(true))
|
||||
expect(screen.queryByTestId('record-query')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status', { name: 'Loading table metadata' })).toBeInTheDocument()
|
||||
} finally {
|
||||
await act(async () => { releaseTable() })
|
||||
}
|
||||
await screen.findByTestId('record-query')
|
||||
expect(recordQuery).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
tableMetaData: qInstance.tables.person, allTables: registry.tables,
|
||||
}), undefined)
|
||||
expect(registry.tables.person).not.toHaveProperty('fields')
|
||||
})
|
||||
|
||||
it('shows full-table failure without rendering a query from registry metadata', async () => {
|
||||
server.use(http.get('/qqq/v1/metaData/table/person', () =>
|
||||
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })))
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load table metadata')
|
||||
expect(recordQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fetch full table metadata when an app takes priority over a same-name table', async () => {
|
||||
const metadata = { ...registry, apps: { person: { name: 'person', label: 'People app' } } }
|
||||
let requested = false
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData', () => HttpResponse.json(metadata)),
|
||||
http.get('/qqq/v1/metaData/table/person', () => {
|
||||
requested = true
|
||||
return HttpResponse.json(qInstance.tables.person)
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
await screen.findByText('App home')
|
||||
expect(requested).toBe(false)
|
||||
expect(recordQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const greeting = {
|
||||
name: 'greetInteractive', label: 'Greet Interactive', hasPermission: true,
|
||||
tableName: 'person', minInputRecords: 1, maxInputRecords: 2,
|
||||
frontendSteps: [{
|
||||
name: 'setup', label: 'Setup', components: [{ type: 'EDIT_FORM' }],
|
||||
formFields: [{ name: 'greetingPrefix', label: 'Greeting Prefix', type: 'STRING', isEditable: true, isHidden: false, isRequired: false }],
|
||||
}],
|
||||
}
|
||||
|
||||
describe('SlugPage process initialization', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
params.slug = greeting.name
|
||||
location.search = 'recordsParam=recordIds&recordIds=1,3'
|
||||
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json({
|
||||
...registry, processes: { [greeting.name]: { name: greeting.name, label: greeting.label } },
|
||||
})))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['recordIds', 'recordIds', '1,3', 'recordIds'],
|
||||
['filterJSON', 'filterJSON', '{"criteria":[]}', 'filterJSON'],
|
||||
['queryFilter', 'filterJSON', '{"criteria":[]}', 'filterJSON'],
|
||||
])('loads full metadata before initializing with %s selection', async (selector, field, value, expectedSelector) => {
|
||||
location.search = new URLSearchParams({ recordsParam: selector, [field]: value }).toString()
|
||||
let releaseMetadata!: () => void
|
||||
const metadataReady = new Promise<void>((resolve) => { releaseMetadata = resolve })
|
||||
const initialize = vi.spyOn(processesApi, 'processInit').mockResolvedValue({
|
||||
type: 'COMPLETE', processUUID: 'selected-run', nextStep: 'setup', values: {},
|
||||
})
|
||||
server.use(
|
||||
http.get('/metaData/process/greetInteractive', async () => {
|
||||
await metadataReady
|
||||
return HttpResponse.json({ process: greeting })
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
await screen.findByRole('status', { name: 'Loading process metadata' })
|
||||
expect(initialize).not.toHaveBeenCalled()
|
||||
await act(async () => { releaseMetadata() })
|
||||
await screen.findByRole('textbox', { name: 'Greeting Prefix' })
|
||||
expect(initialize).toHaveBeenCalledWith(greeting.name, {
|
||||
recordsParam: expectedSelector, [field]: value, tableName: 'person',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not initialize when full process metadata is denied', async () => {
|
||||
let initialized = false
|
||||
server.use(
|
||||
http.get('/metaData/process/greetInteractive', () =>
|
||||
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })),
|
||||
http.post('/processes/greetInteractive/init', () => {
|
||||
initialized = true
|
||||
return HttpResponse.json({ processUUID: 'unexpected', values: {} })
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load process metadata')
|
||||
expect(initialized).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SlugPage unknown names', () => {
|
||||
beforeEach(() => {
|
||||
location.search = ''
|
||||
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)))
|
||||
})
|
||||
|
||||
it('shows the not-found state with a link to the dashboard (regression: bare "Unknown resource")', async () => {
|
||||
params.slug = 'noSuchThing'
|
||||
renderPage()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Page not found' })).toBeInTheDocument()
|
||||
expect(screen.getByText('noSuchThing')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Go to the dashboard' })).toHaveAttribute('href', '/app')
|
||||
expect(screen.queryByText(/Unknown resource/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,113 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Unified slug page — dispatches to AppHome, RecordQuery, ProcessRun, or ReportRun based on the slug.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// Unified slug page — routes to AppHome, RecordQuery, or ProcessRun
|
||||
// based on what the slug resolves to in the app metadata
|
||||
/**
|
||||
* Unified slug page — serves the route `/app/[slug]` and dispatches to
|
||||
* `AppHome`, `RecordQuery`, or `ProcessRun` based on how the slug resolves
|
||||
* against the QQQ application metadata.
|
||||
*
|
||||
* Route params:
|
||||
* - `slug` — a QQQ app name, table name, process name, or report name.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileBarChart } from 'lucide-react'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { useProcessMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import type { ProcessInitRequest } from '@/lib/api/processes'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { canAccessProcess, canReadRecords } from '@/lib/auth/permissions'
|
||||
import { RecordQuery } from '@/components/query'
|
||||
import { ProcessRun } from '@/components/process'
|
||||
import { AppHome } from '@/components/widgets'
|
||||
import { ReportRun } from '@/components/reports'
|
||||
import { NotFoundState } from '@/components/layout/NotFoundState'
|
||||
|
||||
/**
|
||||
* Resolves a URL slug to its QQQ resource type and name.
|
||||
*
|
||||
* Checks the slug against apps, tables, processes, and reports in priority order.
|
||||
* Returns a discriminated union describing the match, or `null` when the slug is
|
||||
* not found in any category.
|
||||
*
|
||||
* Extracted as a named function for testability — the caller (`SlugPage`) passes
|
||||
* the already-fetched metadata so this function remains a pure mapping with no
|
||||
* side effects or data fetching.
|
||||
*
|
||||
* @param slug - The URL path segment to resolve (e.g. `"orders"` or `"importData"`).
|
||||
* @param metaData - The full QQQ instance metadata fetched from the backend.
|
||||
* @returns A `{ type, name }` object describing the resolved resource, or `null`.
|
||||
*/
|
||||
export function resolveSlugTarget(
|
||||
slug: string,
|
||||
metaData: QInstance
|
||||
): { type: 'app' | 'table' | 'process' | 'report'; name: string } | null {
|
||||
if (metaData.apps?.[slug]) {
|
||||
return { type: 'app', name: slug }
|
||||
}
|
||||
if (metaData.tables?.[slug]) {
|
||||
return { type: 'table', name: slug }
|
||||
}
|
||||
if (metaData.processes?.[slug]) {
|
||||
return { type: 'process', name: slug }
|
||||
}
|
||||
if (metaData.reports?.[slug]) {
|
||||
return { type: 'report', name: slug }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the appropriate page component for a given `slug` URL segment.
|
||||
*
|
||||
* Resolution priority:
|
||||
* 1. If the slug matches a QQQ **app** → renders `<AppHome>` (dashboard widgets).
|
||||
* 2. If the slug matches a **table** → renders `<RecordQuery>` (data grid + filters).
|
||||
* 3. If the slug matches a **process** → renders `<ProcessRun>` (step wizard).
|
||||
* 4. If the slug matches a **report** → renders `<ReportRun>` (format selector + download).
|
||||
* 5. Otherwise → renders a not-found state (the backend omits objects the user may not access).
|
||||
*
|
||||
* The page header in QContext is updated whenever the resolution changes.
|
||||
*
|
||||
* @returns A composed page selected by slug resolution:
|
||||
* - `<AppHome>` (dashboard widgets) when the slug matches a QQQ app
|
||||
* - `<RecordQuery>` (data grid + filters + pagination) when the slug matches a table
|
||||
* - `<ProcessRun>` (step wizard) when the slug matches a process
|
||||
* - `<ReportRun>` (format selector + download) when the slug matches a report
|
||||
* - A full-screen spinner while metadata is loading or the resource is resolving
|
||||
* - A not-found state when the slug matches nothing the user can access
|
||||
*/
|
||||
export default function SlugPage() {
|
||||
const params = useParams<{ slug: string }>()
|
||||
const params = useRouteParams<{ slug: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const slug = params.slug
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
@@ -34,8 +120,12 @@ export default function SlugPage() {
|
||||
const isReport = Boolean(metaData?.reports?.[slug])
|
||||
|
||||
const app = metaData?.apps?.[slug]
|
||||
const table = metaData?.tables?.[slug]
|
||||
const process = metaData?.processes?.[slug]
|
||||
// Objects the user may not use (DenyBehavior.DISABLED) are listed in metadata with
|
||||
// their permission flag false: show why, and do not load or start them.
|
||||
const tableDenied = isTable && !isApp && !canReadRecords(metaData?.tables?.[slug])
|
||||
const processDenied = isProcess && !isApp && !isTable && !canAccessProcess(metaData?.processes?.[slug])
|
||||
const { data: table, isError: tableError } = useTableMetaData(isTable && !isApp && !tableDenied ? slug : undefined)
|
||||
const { data: process, isError: processError } = useProcessMetaData(isProcess && !isApp && !isTable && !processDenied ? slug : undefined)
|
||||
const report = metaData?.reports?.[slug]
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,11 +137,30 @@ export default function SlugPage() {
|
||||
} else if (isProcess) {
|
||||
setPageHeader(process?.label ?? slug)
|
||||
} else if (isReport) {
|
||||
setPageHeader((report as { label?: string })?.label ?? slug)
|
||||
} else {
|
||||
setPageHeader(slug)
|
||||
setPageHeader(report?.label ?? slug)
|
||||
} else if (metaData) {
|
||||
setPageHeader('Not Found')
|
||||
}
|
||||
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, setPageHeader, setTableMetaData])
|
||||
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, metaData, setPageHeader, setTableMetaData])
|
||||
|
||||
if (tableDenied || processDenied) {
|
||||
const label = (tableDenied ? metaData?.tables?.[slug]?.label : metaData?.processes?.[slug]?.label) ?? slug
|
||||
return (
|
||||
<div className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center" role="alert" data-qqq-id="permission-denied">
|
||||
<p className="text-sm text-yellow-700">
|
||||
{tableDenied ? `You do not have permission to view ${label} records.` : `You do not have permission to run ${label}.`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (metadataError || (isTable && !isApp && tableError) || (isProcess && !isApp && !isTable && processError)) {
|
||||
return (
|
||||
<div role="alert" className="py-12 text-center text-destructive">
|
||||
Failed to load {metadataError ? 'application' : isTable ? 'table' : 'process'} metadata.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!metaData) {
|
||||
return (
|
||||
@@ -66,6 +175,7 @@ export default function SlugPage() {
|
||||
return (
|
||||
<AppHome
|
||||
appMetaData={app}
|
||||
instance={metaData}
|
||||
widgetRegistry={metaData.widgets ?? {}}
|
||||
/>
|
||||
)
|
||||
@@ -74,13 +184,13 @@ export default function SlugPage() {
|
||||
// Table record query — Package 2 implementation
|
||||
if (isTable && table) {
|
||||
const tableProcesses = getProcessesForTable(metaData, slug)
|
||||
return <RecordQuery tableName={slug} tableMetaData={table} processes={tableProcesses} />
|
||||
return <RecordQuery key={slug} tableName={slug} tableMetaData={table} allTables={metaData.tables} processes={tableProcesses} metaData={metaData} />
|
||||
}
|
||||
|
||||
// Table loading state (table found but metadata not yet available)
|
||||
if (isTable && !table) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div role="status" aria-label="Loading table metadata" className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
@@ -88,11 +198,45 @@ export default function SlugPage() {
|
||||
|
||||
// Process run — Package 4 implementation
|
||||
if (isProcess && process) {
|
||||
return <ProcessRun processName={slug} processMetaData={process} />
|
||||
const initialRequest: ProcessInitRequest = {}
|
||||
const selection = searchParams.get('recordsParam')
|
||||
if (selection === 'recordIds' || (!selection && searchParams.get('recordIds'))) {
|
||||
initialRequest.recordsParam = 'recordIds'
|
||||
initialRequest.recordIds = searchParams.get('recordIds') ?? ''
|
||||
} else if (selection === 'filterJSON' || selection === 'queryFilter' || (!selection && searchParams.get('filterJSON'))) {
|
||||
initialRequest.recordsParam = 'filterJSON'
|
||||
initialRequest.filterJSON = searchParams.get('filterJSON') ?? ''
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// links may preset process inputs, as in the Material dashboard: //
|
||||
// ?defaultProcessValues={"name":"value"} //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
let initialValues: Record<string, unknown> | undefined
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(searchParams.get('defaultProcessValues') ?? 'null')
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) initialValues = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
initialValues = undefined
|
||||
}
|
||||
return <ProcessRun key={`${slug}?${searchParams}`} processName={slug} processMetaData={process} initialRequest={initialRequest} initialValues={initialValues} />
|
||||
}
|
||||
|
||||
// Process loading state (process found but metadata not yet available)
|
||||
if (isProcess && !process) {
|
||||
return (
|
||||
<div role="status" aria-label="Loading process metadata" className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Report run
|
||||
if (isReport && report) {
|
||||
return <ReportRun reportName={slug} reportMetaData={report} />
|
||||
}
|
||||
|
||||
// Report loading state (report found but metadata not yet available)
|
||||
if (isReport && !report) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
@@ -100,34 +244,6 @@ export default function SlugPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Report run
|
||||
if (isReport) {
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`report-run-${slug}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileBarChart className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{(report as { label?: string })?.label ?? slug}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
Report — implemented in a future package
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Unknown slug
|
||||
return (
|
||||
<div className="space-y-4" data-qqq-id={`unknown-slug-${slug}`}>
|
||||
<h2 className="text-2xl font-semibold text-foreground">{slug}</h2>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Unknown resource: <code className="font-mono">{slug}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
// Unknown, hidden-and-denied, or unpermitted slug: the backend exposes nothing by this name
|
||||
return <NotFoundState name={slug} />
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Prerenders this dynamic segment once for the Javalin-served static export.
|
||||
* The server returns that page for every concrete value; see use-route-params.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
|
||||
|
||||
/**
|
||||
* Static export placeholder params for this segment.
|
||||
*
|
||||
* @returns One placeholder entry.
|
||||
*/
|
||||
export function generateStaticParams() {
|
||||
return [{ viewId: EXPORT_PLACEHOLDER }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through layout that exists to carry generateStaticParams.
|
||||
*
|
||||
* @param props - Layout props.
|
||||
* @param props.children - Route content.
|
||||
* @returns The children unchanged.
|
||||
*/
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -1,31 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file SavedView page — the record query screen opened with a backend saved view
|
||||
* (`/app/{table}/savedView/{id}`), as in the Material dashboard.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// RecordQuery with Saved View — loads a saved filter/view configuration
|
||||
// Full implementation in Package 2
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { RecordQuery } from '@/components/query'
|
||||
|
||||
/**
|
||||
* Saved view route: resolves the table and renders its query screen with the view applied.
|
||||
*
|
||||
* @returns The query screen, a loading indicator, or an error for unknown tables or ids.
|
||||
*/
|
||||
export default function SavedViewPage() {
|
||||
const params = useParams<{ slug: string; viewId: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const { slug, viewId } = params
|
||||
const { slug, viewId } = useRouteParams<{ slug: string; viewId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
const isTable = Boolean(metaData?.tables?.[slug])
|
||||
const { data: table, isError: tableError } = useTableMetaData(isTable ? slug : undefined)
|
||||
const id = /^\d+$/.test(viewId) ? Number(viewId) : NaN
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`${slug} — Saved View`)
|
||||
}, [slug, setPageHeader])
|
||||
setPageHeader(table?.label ?? slug)
|
||||
if (table) setTableMetaData(table)
|
||||
}, [slug, table, setPageHeader, setTableMetaData])
|
||||
|
||||
if (metadataError || tableError || (metaData && !isTable) || !Number.isFinite(id)) {
|
||||
return (
|
||||
<div role="alert" className="py-12 text-center text-destructive" data-qqq-id={`saved-view-${slug}-${viewId}`}>
|
||||
{metaData && !isTable ? <>Unknown table: <code className="font-mono">{slug}</code></> : !Number.isFinite(id) ? 'The requested view was not found.' : 'Failed to load table metadata.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!metaData || !table) {
|
||||
return (
|
||||
<div role="status" aria-label="Loading saved view" className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-qqq-id={`saved-view-${slug}-${viewId}`}>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Saved view -- implemented in Package 2
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
View ID: <code className="font-mono">{viewId}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<RecordQuery
|
||||
key={`${slug}-${id}`}
|
||||
tableName={slug}
|
||||
tableMetaData={table}
|
||||
allTables={metaData.tables}
|
||||
processes={getProcessesForTable(metaData, slug)}
|
||||
metaData={metaData}
|
||||
savedViewId={id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import EntityCreatePage from './create/page'
|
||||
import EntityEditPage from './[recordId]/edit/page'
|
||||
import EntityCopyPage from './[recordId]/copy/page'
|
||||
|
||||
const { push } = vi.hoisted(() => ({ push: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ slug: 'person', recordId: '1' }),
|
||||
usePathname: () => '/app/person/1',
|
||||
useRouter: () => ({ push, replace: vi.fn(), back: vi.fn() }),
|
||||
}))
|
||||
|
||||
function fixture() {
|
||||
const person = structuredClone(qInstance.tables.person)
|
||||
person.fields = Object.fromEntries(['id', 'firstName', 'lastName', 'email'].map((name) => [name, person.fields[name]]))
|
||||
person.fields.firstName.label = 'Preferred given name'
|
||||
person.sections = [{ name: 'basic', label: 'Person', isHidden: false, fieldNames: ['firstName', 'lastName', 'email'] }]
|
||||
const registry = { ...qInstance, tables: { person: { name: 'person', label: 'Person', readPermission: true, insertPermission: true, editPermission: true } } }
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)),
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(person)),
|
||||
)
|
||||
return person
|
||||
}
|
||||
|
||||
function renderPage(page: React.ReactNode) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><QContextProvider>{page}</QContextProvider></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('Create and base edit use full metadata and actual write contracts', () => {
|
||||
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
|
||||
|
||||
it('edits the base without requesting denied children or replacing associations', async () => {
|
||||
const reads: string[] = []
|
||||
server.use(
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
reads.push(mode)
|
||||
return mode === 'false' ? HttpResponse.json({ tableName: 'person', values: {
|
||||
id: 1, firstName: 'Avery', lastName: 'Sample', email: 'avery@example.invalid', unknown: 'must not echo',
|
||||
} }) : HttpResponse.json({ error: 'Children denied' }, { status: 403 })
|
||||
}),
|
||||
)
|
||||
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 1, firstName: 'Updated' } }] })
|
||||
renderPage(<EntityEditPage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Updated' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Email/ }), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/1'))
|
||||
expect(reads.length).toBeGreaterThan(0)
|
||||
expect(new Set(reads)).toEqual(new Set(['false']))
|
||||
expect(put.mock.calls[0][0]).toBe('/data/person/1')
|
||||
const submitted = put.mock.calls[0][1] as FormData
|
||||
expect(submitted?.get('firstName')).toBe('Updated')
|
||||
expect(submitted?.get('email')).toBe('')
|
||||
expect(submitted?.has('associations')).toBe(false)
|
||||
expect(submitted?.has('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates from full fields and unwraps the legacy envelope before navigating', async () => {
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 7 } }] })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Created' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/7'))
|
||||
expect(post.mock.calls[0][0]).toBe('/data/person')
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted?.get('firstName')).toBe('Created')
|
||||
expect(submitted?.has('associations')).toBe(false)
|
||||
})
|
||||
|
||||
it('copies only base fields after full metadata is ready, without requiring child READ', async () => {
|
||||
const person = fixture()
|
||||
const source = { tableName: 'person', values: {
|
||||
id: 1, firstName: 'Avery', lastName: 'Sample', email: 'avery@example.invalid', unknown: 'must not echo',
|
||||
} }
|
||||
const sourceSnapshot = structuredClone(source)
|
||||
let releaseMetadata: () => void = () => {}
|
||||
const pendingMetadata = new Promise<void>((resolve) => { releaseMetadata = resolve })
|
||||
let requestedMetadata = false
|
||||
const reads: string[] = []
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/person', async () => {
|
||||
requestedMetadata = true
|
||||
await pendingMetadata
|
||||
return HttpResponse.json(person)
|
||||
}),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
reads.push(mode)
|
||||
return mode === 'false' ? HttpResponse.json(source) : HttpResponse.json({ error: 'Children denied' }, { status: 403 })
|
||||
}),
|
||||
)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 8 } }] })
|
||||
renderPage(<EntityCopyPage />)
|
||||
await waitFor(() => expect(requestedMetadata).toBe(true))
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
expect(reads).toEqual([])
|
||||
await act(async () => { releaseMetadata() })
|
||||
expect(await screen.findByLabelText(/Preferred given name/)).toHaveValue('Avery')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/8'))
|
||||
expect(new Set(reads)).toEqual(new Set(['false']))
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted.get('firstName')).toBe('Avery')
|
||||
expect(submitted.has('id')).toBe(false)
|
||||
expect(submitted.has('associations')).toBe(false)
|
||||
expect(submitted.has('unknown')).toBe(false)
|
||||
expect(source).toEqual(sourceSnapshot)
|
||||
})
|
||||
|
||||
it('retains the populated form after a failed save without an unhandled rejection', async () => {
|
||||
vi.spyOn(apiClient, 'post').mockRejectedValue(new Error('Write denied'))
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Keep input' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Write denied')
|
||||
expect(screen.getByLabelText(/Preferred given name/)).toHaveValue('Keep input')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('submits untouched optional numeric inputs as blanks and preserves typed zero', async () => {
|
||||
const person = fixture()
|
||||
for (const [name, type] of [['quantity', 'INTEGER'], ['price', 'DECIMAL'], ['total', 'LONG']] as const) {
|
||||
person.fields[name] = { ...person.fields.firstName, name, label: name, type, isRequired: false }
|
||||
person.sections[0].fieldNames.push(name)
|
||||
}
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Numeric' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.change(screen.getByLabelText('total'), { target: { value: '0' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/9'))
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted.get('quantity')).toBe('')
|
||||
expect(submitted.get('price')).toBe('')
|
||||
expect(submitted.get('total')).toBe('0')
|
||||
})
|
||||
|
||||
it('keeps the form disabled for the complete pending mutation', async () => {
|
||||
let finish: (response: unknown) => void = () => {}
|
||||
const pending = new Promise((resolve) => { finish = resolve })
|
||||
const post = vi.spyOn(apiClient, 'post').mockReturnValue(pending)
|
||||
renderPage(<EntityCreatePage />)
|
||||
const firstName = await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.change(firstName, { target: { value: 'Pending' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Save' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
const saving = await screen.findByRole('button', { name: 'Saving...' })
|
||||
expect(saving).toBeDisabled()
|
||||
expect(screen.getByLabelText(/Preferred given name/)).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
|
||||
fireEvent.click(saving)
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { finish({ records: [{ tableName: 'person', values: { id: 8 } }] }) })
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/8'))
|
||||
})
|
||||
|
||||
it('navigates with the metadata-defined string primary key as one encoded segment', async () => {
|
||||
const person = fixture()
|
||||
person.primaryKeyField = 'recordCode'
|
||||
person.fields.recordCode = { ...person.fields.firstName, name: 'recordCode', label: 'Code', isEditable: false }
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { recordCode: 'A/B#1' } }] })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Natural' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/A%2FB%231'))
|
||||
})
|
||||
|
||||
it('does not announce a saved record when the response has no usable primary key', async () => {
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: {} }] })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Keep input' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('The server did not return a valid record identifier.')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['create', 'edit', 'copy'])('shows a full-metadata failure for %s without rendering an unusable form', async (mode) => {
|
||||
server.use(http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json({ error: 'Metadata failed' }, { status: 500 })))
|
||||
renderPage(mode === 'create' ? <EntityCreatePage /> : mode === 'edit' ? <EntityEditPage /> : <EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Table metadata is unavailable.')
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['missing', 'error'])('shows unavailable copy registry metadata for %s', async (mode) => {
|
||||
server.use(http.get('/qqq/v1/metaData', () => mode === 'missing'
|
||||
? HttpResponse.json({ ...qInstance, tables: {} }) : HttpResponse.json({ error: 'Unavailable' }, { status: 500 })))
|
||||
renderPage(<EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Table metadata is unavailable.')
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['readPermission', 'insertPermission'] as const)('does not load a copy source without %s', async (permission) => {
|
||||
const person = fixture()
|
||||
person[permission] = false
|
||||
const read = vi.fn()
|
||||
server.use(http.get('/data/person/1', () => {
|
||||
read()
|
||||
return HttpResponse.json({ tableName: 'person', values: { id: 1, firstName: 'Must not load' } })
|
||||
}))
|
||||
renderPage(<EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(permission === 'readPermission'
|
||||
? 'You do not have permission to read the source People record.' : 'You do not have permission to create People records.')
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a failed base-copy source read without offering a blank create form', async () => {
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ error: 'Source denied' }, { status: 403 })))
|
||||
renderPage(<EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('You do not have permission to view People records')
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Explicit full copy', () => {
|
||||
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
|
||||
it('keeps child edits in one root submission with exact names and fresh manual keys', async () => {
|
||||
const parent = fixture()
|
||||
parent.associations = [{ name: 'care / primary', associatedTableName: 'child', join: {
|
||||
name: 'personToChild', leftTable: 'person', rightTable: 'child', type: 'ONE_TO_MANY', joinOns: [{ leftField: 'id', rightField: 'owner' }],
|
||||
} }]
|
||||
const child = { ...structuredClone(parent), name: 'child', label: 'Child', primaryKeyField: 'manualKey', associations: [], fields: {
|
||||
manualKey: { ...parent.fields.firstName, name: 'manualKey', label: 'New child key', isRequired: true },
|
||||
owner: { ...parent.fields.id, name: 'owner', isEditable: true, isRequired: true },
|
||||
firstName: { ...parent.fields.firstName, label: 'Child name' },
|
||||
}, sections: [] }
|
||||
const values = { id: 1, firstName: 'Parent', lastName: 'Source', email: 'copy@example.invalid' }
|
||||
const originalChild = { tableName: 'child', values: { manualKey: 'old-child', owner: 1, firstName: 'Original child' } }
|
||||
const reads: string[] = []
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(parent)),
|
||||
http.get('/qqq/v1/metaData/table/child', () => HttpResponse.json(child)),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
const expanded = new URL(request.url).searchParams.get('includeAssociations') === 'true'; reads.push(String(expanded))
|
||||
return HttpResponse.json({ tableName: 'person', values, ...(expanded ? { associatedRecords: { 'care / primary': [originalChild] } } : {}) })
|
||||
}),
|
||||
)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 }, associatedRecords: { 'care / primary': [{ tableName: 'child', values: { manualKey: 'fresh-child', owner: 9 } }] } }] })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Edited parent' } })
|
||||
expect(reads).toEqual(['false'])
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
const key = await screen.findByLabelText(/New child key/)
|
||||
expect(key).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.change(key, { target: { value: 'fresh-child' } })
|
||||
fireEvent.change(screen.getByLabelText(/Child name/), { target: { value: 'Edited child' } })
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
expect(screen.getByLabelText(/New child key/)).toHaveValue('fresh-child')
|
||||
expect(screen.getByLabelText(/Preferred given name/)).toHaveValue('Edited parent')
|
||||
expect(view.container.querySelectorAll('form')).toHaveLength(1)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
const body = post.mock.calls[0][1] as FormData
|
||||
expect(body.get('firstName')).toBe('Edited parent')
|
||||
expect(body.has('id')).toBe(false)
|
||||
expect(JSON.parse(body.get('associations') as string)).toEqual({ 'care / primary': [{ values: { manualKey: 'fresh-child', firstName: 'Edited child' } }] })
|
||||
expect(originalChild.values).toEqual({ manualKey: 'old-child', owner: 1, firstName: 'Original child' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Full copy failures stay visible and cannot save a base-only fallback', () => {
|
||||
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
|
||||
function sources(problem: string) {
|
||||
const parent = fixture()
|
||||
parent.associations = [{ name: 'children', associatedTableName: 'child', join: { name: 'join', leftTable: 'person', rightTable: 'child', type: 'ONE_TO_MANY', joinOns: [{ leftField: 'id', rightField: 'owner' }] } }]
|
||||
const child = { ...structuredClone(parent), name: 'child', label: 'Child', associations: [], fields: { id: parent.fields.id, firstName: problem === 'numeric' ? { ...parent.fields.firstName, type: 'INTEGER' as const, label: 'Child amount' } : parent.fields.firstName, owner: { ...parent.fields.id, name: 'owner' } }, sections: [] }
|
||||
const values = { id: 1, firstName: 'Parent', lastName: 'Source', email: 'copy@example.invalid' }
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(parent)),
|
||||
http.get('/qqq/v1/metaData/table/child', () => problem.startsWith('metadata') ? HttpResponse.json({ error: 'Metadata unavailable' }, { status: problem === 'metadata-server' ? 500 : 403 }) : HttpResponse.json(child)),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
if (new URL(request.url).searchParams.get('includeAssociations') !== 'true') return HttpResponse.json({ tableName: 'person', values })
|
||||
if (problem.startsWith('read')) return HttpResponse.json({ error: 'Read unavailable' }, { status: problem === 'read-server' ? 500 : 403 })
|
||||
return HttpResponse.json({ tableName: 'person', values, ...(problem === 'group' ? {} : { associatedRecords: { children: [{ tableName: 'child', values: { id: 2, owner: 1, ...(problem === 'value' ? {} : { firstName: problem === 'numeric' ? 5 : 'Child' }) } }] } }) })
|
||||
}),
|
||||
)
|
||||
}
|
||||
it.each(['read', 'metadata', 'group', 'value'])('blocks %s failure even on forced form submit', async problem => {
|
||||
sources(problem)
|
||||
const post = vi.spyOn(apiClient, 'post')
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).not.toHaveTextContent('Loading full copy'))
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.submit(view.container.querySelector('form')!)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not announce success when a saved response omits a nonempty requested group', async () => {
|
||||
sources('none')
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('did not confirm every copied association')
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('ignores a disabled hidden numeric child draft in Base mode while Full still checks it', async () => {
|
||||
sources('numeric')
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
const amount = await screen.findByRole('spinbutton', { name: /Child amount/ }) as HTMLInputElement
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
// jsdom does not implement partial numeric entry; supply the browser's validity result.
|
||||
Object.defineProperty(amount, 'validity', { configurable: true, value: { badInput: true } })
|
||||
const report = vi.spyOn(amount, 'reportValidity')
|
||||
fireEvent.submit(view.container.querySelector('form')!)
|
||||
expect(report).toHaveBeenCalledTimes(1)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
|
||||
expect(amount.disabled).toBe(false)
|
||||
expect(amount.matches(':disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
expect((post.mock.calls[0][1] as FormData).has('associations')).toBe(false)
|
||||
expect(report).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['read', 'metadata'])('explains %s denial and lets the user explicitly save a Base Copy', async problem => {
|
||||
sources(problem)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('You do not have access to all the information needed for Full Copy. Choose Base Copy to copy this record only.'))
|
||||
expect(screen.getByRole('alert')).not.toHaveTextContent('status code')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.submit(view.container.querySelector('form')!)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
const body = post.mock.calls[0][1] as FormData
|
||||
expect(body.has('associations')).toBe(false)
|
||||
expect(body.get('firstName')).toBe('Parent')
|
||||
})
|
||||
it.each(['read-server', 'metadata-server'])('offers a retry for %s failure without claiming permission denial or silently saving', async problem => {
|
||||
sources(problem)
|
||||
const post = vi.spyOn(apiClient, 'post')
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Full Copy could not load all required information. Reload to try again, or choose Base Copy.'))
|
||||
expect(screen.getByRole('alert')).not.toHaveTextContent('do not have access')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.submit(view.container.querySelector('form')!)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('radio', { name: 'Base copy' })).toBeEnabled()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Developer page — shows debug information including session info, metadata stats, and API health.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Developer page — shows debug information useful during development and support.
|
||||
*
|
||||
* Fixed route at `/app/developer` — takes priority over the dynamic `[slug]` route.
|
||||
*
|
||||
* Displays:
|
||||
* - Session info (auth type, user ID)
|
||||
* - Environment values from backend metadata
|
||||
* - Metadata stats (table / process / report / widget / app counts)
|
||||
* - API health check with response time
|
||||
* - Build info placeholder
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Copy, Check, Activity } from 'lucide-react'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/** Duration in milliseconds before the "copied!" feedback resets to the copy icon. */
|
||||
const COPY_RESET_MS = 2000
|
||||
|
||||
/**
|
||||
* A section card with a heading, optional copy button, and arbitrary content.
|
||||
*/
|
||||
interface SectionCardProps {
|
||||
/** Section heading text. */
|
||||
title: string
|
||||
/** Content to render inside the card. */
|
||||
children: React.ReactNode
|
||||
/** When provided, the "Copy JSON" button copies this value to the clipboard. */
|
||||
copyData?: unknown
|
||||
/** data-qqq-id value for the card wrapper. */
|
||||
dataQqqId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a card section with a header and optional "Copy JSON" button.
|
||||
*
|
||||
* @param props - {@link SectionCardProps}
|
||||
* @returns A bordered card containing the section heading and children.
|
||||
*/
|
||||
function SectionCard({ title, children, copyData, dataQqqId }: SectionCardProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (copyData === undefined) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), COPY_RESET_MS)
|
||||
} catch {
|
||||
// Clipboard API unavailable — silent fail
|
||||
}
|
||||
}, [copyData])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-border bg-card p-6 space-y-4"
|
||||
data-qqq-id={dataQqqId}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
{copyData !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={`Copy ${title} as JSON`}
|
||||
data-qqq-id={`button-copy-${title.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1 text-xs font-medium',
|
||||
'text-muted-foreground bg-background hover:bg-accent',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-green-600" aria-hidden="true" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Copy JSON
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a simple key/value table.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.rows - Array of `[key, value]` tuples to display.
|
||||
* @returns A definition list styled as a bordered table, or a "No data" message when empty.
|
||||
*/
|
||||
function KeyValueTable({ rows }: { rows: Array<[string, React.ReactNode]> }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">No data available.</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<dl className="divide-y divide-border rounded-lg border border-border text-sm">
|
||||
{rows.map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 px-4 py-2.5 sm:flex-row sm:items-baseline sm:gap-4"
|
||||
>
|
||||
<dt className="font-medium text-muted-foreground sm:w-1/3 sm:flex-shrink-0 font-mono">
|
||||
{key}
|
||||
</dt>
|
||||
<dd className="text-foreground sm:flex-1 break-all">{val}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer debug page rendered at the fixed route `/app/developer`.
|
||||
*
|
||||
* Uses `useQContext()` for auth-related state and `useQuery` for metadata.
|
||||
* Provides an API health check that measures round-trip time to the metadata endpoint.
|
||||
*
|
||||
* @returns The developer page content wrapped in a container.
|
||||
*/
|
||||
export default function DeveloperPage() {
|
||||
const { userId, setPageHeader } = useQContext()
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader('Developer')
|
||||
}, [setPageHeader])
|
||||
const { authMetadata } = useAuth()
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
// API health check state
|
||||
const [healthStatus, setHealthStatus] = useState<
|
||||
'idle' | 'loading' | 'ok' | 'error'
|
||||
>('idle')
|
||||
const [healthMs, setHealthMs] = useState<number | null>(null)
|
||||
const [healthError, setHealthError] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* Fires a GET request to `/metaData` and measures the round-trip time.
|
||||
* Updates `healthStatus`, `healthMs`, and `healthError` accordingly.
|
||||
*/
|
||||
const handleHealthCheck = useCallback(async () => {
|
||||
setHealthStatus('loading')
|
||||
setHealthMs(null)
|
||||
setHealthError(null)
|
||||
const start = performance.now()
|
||||
try {
|
||||
await apiClient.get('/metaData')
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setHealthMs(elapsed)
|
||||
setHealthStatus('ok')
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
setHealthMs(elapsed)
|
||||
setHealthStatus('error')
|
||||
setHealthError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Versions reported by the running bundle (Next.js publishes its version on window.next)
|
||||
const [nextVersion, setNextVersion] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
setNextVersion((window as Window & { next?: { version?: string } }).next?.version)
|
||||
}, [])
|
||||
|
||||
// Counts derived from metadata
|
||||
const tableCount = Object.keys(metaData?.tables ?? {}).length
|
||||
const processCount = Object.keys(metaData?.processes ?? {}).length
|
||||
const reportCount = Object.keys(metaData?.reports ?? {}).length
|
||||
const widgetCount = Object.keys(metaData?.widgets ?? {}).length
|
||||
const appCount = Object.keys(metaData?.apps ?? {}).length
|
||||
|
||||
const metaStatsRows: Array<[string, React.ReactNode]> = [
|
||||
['apps', appCount],
|
||||
['tables', tableCount],
|
||||
['processes', processCount],
|
||||
['reports', reportCount],
|
||||
['widgets', widgetCount],
|
||||
]
|
||||
|
||||
const sessionRows: Array<[string, React.ReactNode]> = [
|
||||
['userId', userId ?? '(not set)'],
|
||||
['authType', authMetadata?.type ?? '(unknown)'],
|
||||
['authName', authMetadata?.name ?? '(unknown)'],
|
||||
]
|
||||
|
||||
const envRows: Array<[string, React.ReactNode]> = Object.entries(
|
||||
metaData?.environmentValues ?? {}
|
||||
)
|
||||
|
||||
const buildRows: Array<[string, React.ReactNode]> = [
|
||||
['framework', nextVersion ? `Next.js ${nextVersion}` : 'Next.js'],
|
||||
['react', React.version],
|
||||
['appVersion', process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown'],
|
||||
['apiBaseUrl', process.env.NEXT_PUBLIC_API_BASE_URL ?? '/qqq/v1'],
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-qqq-id="developer-page"
|
||||
>
|
||||
{/* Page heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Developer</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Debug information for developers and support staff.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session Info */}
|
||||
<SectionCard
|
||||
title="Session Info"
|
||||
copyData={{ userId, authType: authMetadata?.type, authName: authMetadata?.name }}
|
||||
dataQqqId="developer-section-session"
|
||||
>
|
||||
<KeyValueTable rows={sessionRows} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Environment Values */}
|
||||
<SectionCard
|
||||
title="Environment Values"
|
||||
copyData={metaData?.environmentValues}
|
||||
dataQqqId="developer-section-environment"
|
||||
>
|
||||
{envRows.length > 0 ? (
|
||||
<KeyValueTable rows={envRows} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No environment values returned by the backend.
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Metadata Stats */}
|
||||
<SectionCard
|
||||
title="Metadata Stats"
|
||||
copyData={{ apps: appCount, tables: tableCount, processes: processCount, reports: reportCount, widgets: widgetCount }}
|
||||
dataQqqId="developer-section-metadata"
|
||||
>
|
||||
<KeyValueTable rows={metaStatsRows} />
|
||||
</SectionCard>
|
||||
|
||||
{/* API Health */}
|
||||
<SectionCard
|
||||
title="API Health"
|
||||
dataQqqId="developer-section-health"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHealthCheck}
|
||||
disabled={healthStatus === 'loading'}
|
||||
aria-label="Run API health check"
|
||||
data-qqq-id="button-api-health-check"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
|
||||
'text-primary-foreground bg-primary hover:bg-primary/90',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
{healthStatus === 'loading' ? (
|
||||
<>
|
||||
<span
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||
Check API Health
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{healthStatus === 'ok' && healthMs !== null && (
|
||||
<p
|
||||
className="text-sm text-green-700 dark:text-green-400"
|
||||
aria-live="polite"
|
||||
data-qqq-id="developer-health-ok"
|
||||
>
|
||||
API responded in <strong>{healthMs} ms</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{healthStatus === 'error' && (
|
||||
<div
|
||||
className="text-sm text-destructive"
|
||||
aria-live="assertive"
|
||||
data-qqq-id="developer-health-error"
|
||||
>
|
||||
<p>
|
||||
Health check failed
|
||||
{healthMs !== null ? ` after ${healthMs} ms` : ''}.
|
||||
</p>
|
||||
{healthError && (
|
||||
<p className="mt-1 font-mono text-xs">{healthError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Build Info */}
|
||||
<SectionCard
|
||||
title="Build Info"
|
||||
copyData={Object.fromEntries(buildRows.map(([k, v]) => [k, v]))}
|
||||
dataQqqId="developer-section-build"
|
||||
>
|
||||
<KeyValueTable rows={buildRows} />
|
||||
</SectionCard>
|
||||
|
||||
{/* Raw metadata JSON */}
|
||||
{metaData && (
|
||||
<SectionCard
|
||||
title="Raw Metadata (JSON)"
|
||||
copyData={metaData}
|
||||
dataQqqId="developer-section-raw-metadata"
|
||||
>
|
||||
<pre
|
||||
className="overflow-auto rounded-lg bg-muted p-4 text-xs text-muted-foreground max-h-64"
|
||||
aria-label="Raw metadata JSON"
|
||||
>
|
||||
{JSON.stringify(
|
||||
{
|
||||
apps: Object.keys(metaData.apps ?? {}),
|
||||
tables: Object.keys(metaData.tables ?? {}),
|
||||
processes: Object.keys(metaData.processes ?? {}),
|
||||
reports: Object.keys(metaData.reports ?? {}),
|
||||
widgets: Object.keys(metaData.widgets ?? {}),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+170
-165
@@ -1,14 +1,29 @@
|
||||
'use client'
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// Dashboard — top-level home page showing system overview
|
||||
// Demonstrates: stats, recent records, quick actions, app navigation, widget rendering
|
||||
/**
|
||||
* @file Dashboard home page — navigation overview: counts, recently viewed records, quick actions and applications.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Table2,
|
||||
Workflow,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
@@ -25,12 +40,57 @@ import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { canInsertRecords, canRunProcess } from '@/lib/auth/permissions'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import type { SidebarRoute } from '@/lib/hooks/use-routes'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import type { QWidgetMetaData } from '@/types'
|
||||
import { ConnectedWidget } from '@/components/widgets/ConnectedWidget'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/**
|
||||
* Flattens an app route's descendants into leaf (non-app) routes.
|
||||
*
|
||||
* @param route - App route.
|
||||
* @returns Every table, process and report under the app, in tree order.
|
||||
*/
|
||||
function leafRoutes(route: SidebarRoute): SidebarRoute[] {
|
||||
return (route.children ?? []).flatMap((child) => (child.nodeType === 'APP' ? leafRoutes(child) : [child]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Unix millisecond timestamp as a human-readable relative time string.
|
||||
*
|
||||
* @param timestamp - Milliseconds since epoch (e.g. from `Date.now()`).
|
||||
* @returns A string such as "Just now", "5m ago", "3h ago", or "2d ago".
|
||||
*/
|
||||
function timeAgo(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'Just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level dashboard home page rendered at `/app`.
|
||||
*
|
||||
* Everything shown is derived from the navigable app tree, so hidden and
|
||||
* unpermitted objects never appear and all text uses metadata labels.
|
||||
* App dashboards (widgets) live on each app's own home page.
|
||||
*
|
||||
* @returns A composed page that assembles:
|
||||
* - A time-based greeting header with the authenticated user's first name
|
||||
* - Counts of navigable tables, processes and apps, and of widgets on those apps
|
||||
* - A recently-viewed records list (up to 8, sourced from localStorage)
|
||||
* - Quick actions: create links for up to 6 insertable tables; run links for up to 4 processes
|
||||
* - An applications overview linking to each top-level app's home
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const { setPageHeader } = useQContext()
|
||||
const { user } = useAuth()
|
||||
@@ -42,8 +102,8 @@ export default function DashboardPage() {
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { sidebarRoutes } = useAppTreeRoutes(metaData)
|
||||
const appRoutes = sidebarRoutes.filter((r) => r.path !== '/app')
|
||||
const { sidebarRoutes, navTargets } = useAppTreeRoutes(metaData)
|
||||
const appRoutes = sidebarRoutes.filter((route) => route.nodeType === 'APP')
|
||||
|
||||
const appName = metaData?.branding?.appName || 'QQQ Admin'
|
||||
|
||||
@@ -56,56 +116,34 @@ export default function DashboardPage() {
|
||||
setRecentRecords(getRecentRecords().slice(0, 8))
|
||||
}, [])
|
||||
|
||||
// Compute system stats from metadata
|
||||
const tableCount = metaData ? Object.keys(metaData.tables).length : 0
|
||||
const processCount = metaData ? Object.keys(metaData.processes).length : 0
|
||||
const appCount = metaData ? Object.keys(metaData.apps).length : 0
|
||||
const widgetCount = metaData ? Object.keys(metaData.widgets).length : 0
|
||||
const tableTargets = navTargets.filter((target) => target.nodeType === 'TABLE')
|
||||
const processTargets = navTargets.filter((target) => target.nodeType === 'PROCESS')
|
||||
const appTargets = navTargets.filter((target) => target.nodeType === 'APP')
|
||||
const widgetNames = new Set(appTargets.flatMap((target) => (metaData?.apps?.[target.key]?.widgets ?? [])
|
||||
.filter((widgetName) => metaData?.widgets?.[widgetName]?.hasPermission)))
|
||||
|
||||
// Gather tables that support create for quick actions
|
||||
const creatableTables = metaData
|
||||
? Object.values(metaData.tables)
|
||||
.filter((t) => !t.isHidden && t.insertPermission && t.capabilities?.includes('TABLE_INSERT'))
|
||||
.slice(0, 6)
|
||||
: []
|
||||
// Tables in navigation the user may insert into (permission and TABLE_INSERT capability)
|
||||
const creatableTables = tableTargets
|
||||
.filter((target) => canInsertRecords(metaData?.tables?.[target.key]))
|
||||
.slice(0, 6)
|
||||
|
||||
// Gather runnable processes for quick actions
|
||||
const runnableProcesses = metaData
|
||||
? Object.values(metaData.processes)
|
||||
.filter((p) => !p.isHidden)
|
||||
.slice(0, 4)
|
||||
: []
|
||||
|
||||
// Gather home-level widgets (widgets that are on any app's home)
|
||||
const homeWidgets: QWidgetMetaData[] = []
|
||||
if (metaData) {
|
||||
const seen = new Set<string>()
|
||||
for (const app of Object.values(metaData.apps)) {
|
||||
for (const wName of app.widgets) {
|
||||
if (!seen.has(wName) && metaData.widgets[wName]?.hasPermission) {
|
||||
seen.add(wName)
|
||||
homeWidgets.push(metaData.widgets[wName])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Processes in navigation the user may run: DenyBehavior.DISABLED processes are
|
||||
// listed with hasPermission false and must not be offered (QRun-IO/qqq#671)
|
||||
const runnableProcesses = processTargets
|
||||
.filter((target) => canRunProcess(metaData?.processes?.[target.key]))
|
||||
.slice(0, 4)
|
||||
|
||||
// Time-based greeting
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening'
|
||||
const firstName = user?.name?.split(' ')[0] || ''
|
||||
|
||||
// Format relative time
|
||||
function timeAgo(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'Just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
const stats: Array<{ label: string; value: number; hint: string; icon: React.ReactNode; id: string }> = [
|
||||
{ id: 'tables', label: 'Tables', value: tableTargets.length, hint: 'Data tables available', icon: <Database className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
|
||||
{ id: 'processes', label: 'Processes', value: processTargets.length, hint: 'Automated workflows', icon: <Workflow className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
|
||||
{ id: 'apps', label: 'Applications', value: appTargets.length, hint: 'App modules', icon: <Layers className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
|
||||
{ id: 'widgets', label: 'Widgets', value: widgetNames.size, hint: 'Dashboard components', icon: <TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8" data-qqq-id="dashboard-home">
|
||||
@@ -121,48 +159,26 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Tables</span>
|
||||
<Database className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.id} className="rounded-xl border border-border bg-card p-5 shadow-sm" data-qqq-id={`dashboard-stat-${stat.id}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">{stat.label}</span>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground" data-qqq-id={`dashboard-stat-${stat.id}-value`}>{stat.value}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{stat.hint}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{tableCount}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Data tables available</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Processes</span>
|
||||
<Workflow className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{processCount}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Automated workflows</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Applications</span>
|
||||
<Layers className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{appCount}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">App modules</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Widgets</span>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-foreground">{widgetCount}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Dashboard components</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-column layout: Recent Records + Quick Actions */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Recent Records — takes 2 columns */}
|
||||
<div className="lg:col-span-2 rounded-xl border border-border bg-card shadow-sm">
|
||||
<section aria-labelledby="dashboard-recent-heading" className="lg:col-span-2 rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-lg font-bold text-foreground">Recently Viewed</h2>
|
||||
<h2 id="dashboard-recent-heading" className="text-lg font-bold text-foreground">Recently Viewed</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
@@ -175,7 +191,7 @@ export default function DashboardPage() {
|
||||
data-qqq-id={`dashboard-recent-${record.recordId}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground" aria-hidden="true">
|
||||
{record.recordLabel.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -198,38 +214,38 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Actions — takes 1 column */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<section aria-labelledby="dashboard-actions-heading" className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-bold text-foreground">Quick Actions</h2>
|
||||
<h2 id="dashboard-actions-heading" className="text-lg font-bold text-foreground">Quick Actions</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{creatableTables.map((table) => (
|
||||
{creatableTables.map((target) => (
|
||||
<Link
|
||||
key={table.name}
|
||||
href={`/app/${table.name}/create`}
|
||||
key={target.key}
|
||||
href={`${target.path}/create`}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
data-qqq-id={`dashboard-create-${table.name}`}
|
||||
data-qqq-id={`dashboard-create-${target.key}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
Create {table.label}
|
||||
Create {target.label}
|
||||
</Link>
|
||||
))}
|
||||
{runnableProcesses.map((process) => (
|
||||
{runnableProcesses.map((target) => (
|
||||
<Link
|
||||
key={process.name}
|
||||
href={`/app/${process.name}`}
|
||||
key={target.key}
|
||||
href={target.path}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
data-qqq-id={`dashboard-process-${process.name}`}
|
||||
data-qqq-id={`dashboard-process-${target.key}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-chart-2/10 text-chart-2">
|
||||
<Workflow className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<MetadataIcon icon={target.icon} kind="process" className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{process.label}
|
||||
{target.label}
|
||||
</Link>
|
||||
))}
|
||||
{creatableTables.length === 0 && runnableProcesses.length === 0 && (
|
||||
@@ -238,91 +254,80 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Home Widgets — rendered from metadata */}
|
||||
{homeWidgets.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-foreground">Widgets</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{homeWidgets.slice(0, 6).map((widget) => (
|
||||
<ConnectedWidget key={widget.name} widgetMetaData={widget} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Applications overview */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-foreground">Applications</h2>
|
||||
</div>
|
||||
<section aria-labelledby="dashboard-apps-heading" className="space-y-4">
|
||||
<h2 id="dashboard-apps-heading" className="text-lg font-bold text-foreground">Applications</h2>
|
||||
{appRoutes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{appRoutes.map((route) => (
|
||||
<Link
|
||||
key={route.path}
|
||||
href={route.children?.[0]?.path ?? route.path}
|
||||
className="group rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id={`dashboard-app-${route.name}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutDashboard className="h-5 w-5" aria-hidden="true" />
|
||||
{appRoutes.map((route) => {
|
||||
const leaves = leafRoutes(route)
|
||||
return (
|
||||
<Link
|
||||
key={route.path}
|
||||
href={route.path}
|
||||
className="group rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id={`dashboard-app-${route.key}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<MetadataIcon icon={route.icon} kind="app" className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{route.name}
|
||||
</h3>
|
||||
{leaves.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{leaves.length} {leaves.length === 1 ? 'item' : 'items'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{route.name}
|
||||
</h3>
|
||||
{route.children && route.children.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{route.children.length} {route.children.length === 1 ? 'item' : 'items'}
|
||||
</p>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
|
||||
</div>
|
||||
{leaves.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{leaves.slice(0, 5).map((leaf) => (
|
||||
<span
|
||||
key={leaf.path}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<MetadataIcon icon={leaf.icon} kind={leaf.nodeType ? ICON_KIND[leaf.nodeType] : 'table'} className="h-3 w-3" />
|
||||
{leaf.name}
|
||||
</span>
|
||||
))}
|
||||
{leaves.length > 5 && (
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
+{leaves.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
|
||||
</div>
|
||||
{/* Show child items preview */}
|
||||
{route.children && route.children.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{route.children.slice(0, 5).map((child) => (
|
||||
<span
|
||||
key={child.path}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Table2 className="h-3 w-3" aria-hidden="true" />
|
||||
{child.name}
|
||||
</span>
|
||||
))}
|
||||
{route.children.length > 5 && (
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
+{route.children.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 py-16 text-center">
|
||||
<p className="text-muted-foreground">No applications available</p>
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 py-16 text-center" data-qqq-id="dashboard-no-apps">
|
||||
{/* Material Dashboard's NoApps message */}
|
||||
<p className="text-muted-foreground">You do not have permission to access any apps.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* System info footer */}
|
||||
<div className="flex items-center gap-6 rounded-xl border border-border bg-card px-6 py-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-6 rounded-xl border border-border bg-card px-6 py-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{tableCount} tables</span>
|
||||
<span>{tableTargets.length} tables</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Workflow className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{processCount} processes</span>
|
||||
<span>{processTargets.length} processes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" aria-hidden="true" />
|
||||
|
||||
@@ -1,238 +1,159 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Search page — every page (app, table, process, report) and recently viewed record matching `?q=`.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// Search results page — displays global search results grouped by table
|
||||
// Reads search query from URL params: /app/search?q=searchTerm
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { globalSearch, type GlobalSearchResult } from '@/lib/api/tables'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
/** Group search results by tableLabel */
|
||||
function groupByTable(
|
||||
results: GlobalSearchResult[]
|
||||
): Map<string, { tableName: string; records: GlobalSearchResult[] }> {
|
||||
const groups = new Map<string, { tableName: string; records: GlobalSearchResult[] }>()
|
||||
for (const result of results) {
|
||||
const existing = groups.get(result.tableLabel)
|
||||
if (existing) {
|
||||
existing.records.push(result)
|
||||
} else {
|
||||
groups.set(result.tableLabel, {
|
||||
tableName: result.tableName,
|
||||
records: [result],
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/**
|
||||
* Renders the search page at `/app/search`.
|
||||
*
|
||||
* Matching is local (labels of navigable app-tree nodes and recently viewed
|
||||
* records); QQQ has no global record-search endpoint. The `q` parameter is kept
|
||||
* in the URL so results are shareable and survive refresh.
|
||||
*
|
||||
* @returns The search input and matching pages and records.
|
||||
*/
|
||||
export default function SearchResultsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { setPageHeader } = useQContext()
|
||||
const term = searchParams.get('q') ?? ''
|
||||
const [inputValue, setInputValue] = useState(term)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
|
||||
const queryParam = searchParams.get('q') ?? ''
|
||||
const [inputValue, setInputValue] = useState(queryParam)
|
||||
const [debouncedTerm, setDebouncedTerm] = useState(queryParam)
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
const { navTargets } = useAppTreeRoutes(metaData)
|
||||
|
||||
// Keep input value in sync when URL param changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(queryParam)
|
||||
setDebouncedTerm(queryParam)
|
||||
}, [queryParam])
|
||||
setInputValue(term)
|
||||
}, [term])
|
||||
|
||||
// Debounce the input value by 300ms
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedTerm(inputValue)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [inputValue])
|
||||
setRecentRecords(getRecentRecords())
|
||||
}, [])
|
||||
|
||||
// Update URL when debounced term changes (without full navigation)
|
||||
useEffect(() => {
|
||||
if (debouncedTerm !== queryParam) {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (debouncedTerm) {
|
||||
params.set('q', debouncedTerm)
|
||||
} else {
|
||||
params.delete('q')
|
||||
}
|
||||
router.replace(`/app/search?${params.toString()}`, { scroll: false })
|
||||
}
|
||||
}, [debouncedTerm, queryParam, searchParams, router])
|
||||
|
||||
// Set page header
|
||||
useEffect(() => {
|
||||
setPageHeader('Search Results')
|
||||
setPageHeader('Search')
|
||||
}, [setPageHeader])
|
||||
|
||||
const { data: results, isLoading } = useQuery({
|
||||
queryKey: queryKeys.globalSearch(debouncedTerm),
|
||||
queryFn: () => globalSearch(debouncedTerm),
|
||||
enabled: debouncedTerm.length > 0,
|
||||
})
|
||||
|
||||
const grouped = useMemo<
|
||||
Map<string, { tableName: string; records: GlobalSearchResult[] }>
|
||||
>(() => {
|
||||
if (!results || results.length === 0) return new Map()
|
||||
return groupByTable(results)
|
||||
}, [results])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
},
|
||||
[]
|
||||
const items = useMemo(
|
||||
() => (term.trim() ? buildNavigationSearchItems(navTargets, recentRecords, term, 50) : []),
|
||||
[navTargets, recentRecords, term]
|
||||
)
|
||||
const pages = items.filter((item) => item.kind === 'page')
|
||||
const records = items.filter((item) => item.kind === 'record')
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
setDebouncedTerm(inputValue)
|
||||
}
|
||||
},
|
||||
[inputValue]
|
||||
)
|
||||
|
||||
const hasResults = results && results.length > 0
|
||||
const hasSearched = debouncedTerm.length > 0
|
||||
const showEmpty = hasSearched && !isLoading && !hasResults
|
||||
/**
|
||||
* Writes the input value to the `q` URL parameter.
|
||||
*
|
||||
* @param event - Form submit event.
|
||||
*/
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const next = inputValue.trim()
|
||||
router.replace(next ? `/app/search?q=${encodeURIComponent(next)}` : '/app/search')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id="search-results-page">
|
||||
{/* Page heading */}
|
||||
{debouncedTerm ? (
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Search Results for “{debouncedTerm}”
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Search
|
||||
</h1>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{term ? <>Search results for “{term}”</> : 'Search'}
|
||||
</h1>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<form role="search" onSubmit={handleSubmit} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search across all tables..."
|
||||
aria-label="Search across all tables"
|
||||
className="rounded-xl border border-input bg-card pl-10 pr-4 py-3 text-sm w-full focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
placeholder="Find an app, table, process or recent record..."
|
||||
aria-label="Search pages and recent records"
|
||||
className="w-full rounded-xl border border-input bg-card py-3 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id="search-input"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="space-y-6" aria-busy="true" aria-live="polite">
|
||||
{/* Skeleton group 1 */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-3 border-b border-border">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="px-6 py-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Skeleton group 2 */}
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-3 border-b border-border">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="px-6 py-3">
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!term && (
|
||||
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-prompt-state">
|
||||
Enter text above to find apps, tables, processes and recently viewed records.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmpty && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16 shadow-sm"
|
||||
data-qqq-id="search-empty-state"
|
||||
>
|
||||
<Search className="h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
No results found for “{debouncedTerm}”
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Try adjusting your search terms or check for typos.
|
||||
</p>
|
||||
</div>
|
||||
{term && items.length === 0 && (
|
||||
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-empty-state">
|
||||
No pages or recent records match “{term}”.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* No query state */}
|
||||
{!hasSearched && !isLoading && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16 shadow-sm"
|
||||
data-qqq-id="search-prompt-state"
|
||||
>
|
||||
<Search className="h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Enter a search term above to find records across all tables.
|
||||
</p>
|
||||
</div>
|
||||
{pages.length > 0 && (
|
||||
<section aria-label="Pages" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-pages">
|
||||
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Pages</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{pages.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-page-${item.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={item.icon} kind={item.nodeType ? ICON_KIND[item.nodeType] : 'app'} className="text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">{item.label}</span>
|
||||
{item.context && <span className="text-xs text-muted-foreground">{item.context}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Results grouped by table */}
|
||||
{!isLoading && hasResults && (
|
||||
<div className="space-y-6">
|
||||
{Array.from(grouped.entries()).map(([tableLabel, group]) => (
|
||||
<div
|
||||
key={tableLabel}
|
||||
className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"
|
||||
data-qqq-id={`search-group-${group.tableName}`}
|
||||
>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-border">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{tableLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{group.records.length} {group.records.length === 1 ? 'result' : 'results'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Result rows */}
|
||||
<div className="divide-y divide-border">
|
||||
{group.records.map((result) => (
|
||||
<Link
|
||||
key={`${result.tableName}-${result.recordId}`}
|
||||
href={`/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`}
|
||||
className="block px-6 py-3 hover:bg-accent cursor-pointer transition-colors text-sm text-foreground"
|
||||
data-qqq-id={`search-result-${result.tableName}-${result.recordId}`}
|
||||
>
|
||||
{result.recordLabel}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{records.length > 0 && (
|
||||
<section aria-label="Recently viewed" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-records">
|
||||
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Recently viewed</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{records.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent">
|
||||
<span className="font-medium text-foreground">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.context}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+178
-89
@@ -1,30 +1,66 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// Dashboard layout — authenticated route group layout
|
||||
// Renders sidebar, header, breadcrumbs, and banner zones
|
||||
// Includes: mobile sidebar drawer, command palette (Cmd+K), skip link, customCss injection
|
||||
/** Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts. */
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { useQContext, QContextProvider } from '@/lib/context/q-context'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { useDocumentTitle } from '@/lib/hooks/use-document-title'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import Header from '@/components/layout/Header'
|
||||
import BannerComponent from '@/components/layout/Banner'
|
||||
import { buildBreadcrumbs, buildDocumentTitle } from '@/components/layout/Breadcrumbs'
|
||||
import { CommandMenu } from '@/components/feedback/CommandMenu'
|
||||
import { SearchDialog } from '@/components/feedback/SearchDialog'
|
||||
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
|
||||
|
||||
// Inner layout content — needs QContextProvider to be set up first
|
||||
/**
|
||||
* Inner layout that renders the full dashboard chrome.
|
||||
*
|
||||
* Must be a child of `QContextProvider` in order to read and write QContext.
|
||||
* Responsibilities:
|
||||
* - Fetches full application metadata via TanStack Query (30-minute stale time).
|
||||
* - Generates sidebar routes and path-to-label map from the app tree.
|
||||
* - Syncs branding, accent color, favicon, and document title to the DOM.
|
||||
* - Sanitizes and injects `customCss` from branding metadata.
|
||||
* - Redirects unauthenticated users to `/login`.
|
||||
* - Registers global keyboard shortcuts: Cmd+K (command palette), `/` (search), `?` (help).
|
||||
* - Renders the desktop sidebar, mobile sidebar drawer, banners, header, and main content slot.
|
||||
*
|
||||
* @param children - The authenticated page content to render in the main slot.
|
||||
* @returns The full dashboard layout, a loading spinner, or `null` during redirect.
|
||||
*/
|
||||
function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { isAuthenticated, isLoading: authLoading, user, logout } = useAuth()
|
||||
const { setPathToLabelMap, setBranding, setAccentColor, setUserId } = useQContext()
|
||||
const { setPathToLabelMap, setBranding, setAccentColor, setUserId, pageHeader } = useQContext()
|
||||
|
||||
// Mobile sidebar drawer state
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
@@ -51,7 +87,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
|
||||
// Generate sidebar routes and path map from app tree
|
||||
const { sidebarRoutes, pathToLabelMap, parentAppMap } = useAppTreeRoutes(metaData)
|
||||
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets } = useAppTreeRoutes(metaData)
|
||||
|
||||
// Sync pathToLabelMap to QContext
|
||||
useEffect(() => {
|
||||
@@ -65,7 +101,9 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
if (metaData?.branding) {
|
||||
setBranding(metaData.branding)
|
||||
|
||||
if (metaData.branding.accentColor) {
|
||||
// MED-3: validate color format before applying to CSS custom properties
|
||||
const ACCENT_COLOR_RE = /^#[0-9a-fA-F]{3,8}$|^rgb\(|^rgba\(|^hsl\(|^hsla\(/
|
||||
if (metaData.branding.accentColor && ACCENT_COLOR_RE.test(metaData.branding.accentColor)) {
|
||||
setAccentColor(metaData.branding.accentColor)
|
||||
document.documentElement.style.setProperty(
|
||||
'--qqq-accent-color',
|
||||
@@ -89,32 +127,54 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Update favicon from branding
|
||||
if (metaData.branding.icon) {
|
||||
const favicon = document.querySelector("link[rel~='icon']") as HTMLLinkElement | null
|
||||
if (favicon) {
|
||||
favicon.href = metaData.branding.icon
|
||||
}
|
||||
if (metaData.branding.accentColorLight && ACCENT_COLOR_RE.test(metaData.branding.accentColorLight)) {
|
||||
document.documentElement.style.setProperty('--qqq-accent-color-light', metaData.branding.accentColorLight)
|
||||
}
|
||||
|
||||
// Update document title
|
||||
if (metaData.branding.appName) {
|
||||
document.title = metaData.branding.appName
|
||||
// Favicon and touch icon from branding (as Material Dashboard does)
|
||||
if (metaData.branding.icon) {
|
||||
for (const selector of ["link[rel~='icon']", "link[rel~='apple-touch-icon']"]) {
|
||||
const linkEl = document.querySelector(selector)
|
||||
if (linkEl instanceof HTMLLinkElement) {
|
||||
linkEl.href = metaData.branding.icon
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [metaData?.branding, setBranding, setAccentColor])
|
||||
|
||||
// Document title: current page, enclosing breadcrumbs, then the app name
|
||||
const documentTitle = metaData
|
||||
? buildDocumentTitle(
|
||||
buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap),
|
||||
typeof pageHeader === 'string' ? pageHeader : undefined,
|
||||
metaData.branding?.appName || 'QQQ'
|
||||
)
|
||||
: undefined
|
||||
useDocumentTitle(documentTitle)
|
||||
|
||||
// Inject customCss from branding metadata
|
||||
// MED-2: strip known CSS injection vectors before applying
|
||||
useEffect(() => {
|
||||
if (metaData?.branding?.customCss) {
|
||||
const safe = metaData.branding.customCss
|
||||
.replace(/<\/?\s*style[^>]*>/gi, '') // no embedded style tags
|
||||
.replace(/expression\s*\(/gi, '') // no IE CSS expressions
|
||||
.replace(/@import\b/gi, '') // no @import directives
|
||||
.replace(/javascript\s*:/gi, '') // no javascript: scheme
|
||||
.replace(/url\s*\(\s*["']?\s*data:/gi, '') // no data: URLs
|
||||
|
||||
const styleId = 'qqq-custom-css'
|
||||
let styleTag = document.getElementById(styleId) as HTMLStyleElement | null
|
||||
if (!styleTag) {
|
||||
const existingStyleEl = document.getElementById(styleId)
|
||||
let styleTag: HTMLStyleElement
|
||||
if (existingStyleEl instanceof HTMLStyleElement) {
|
||||
styleTag = existingStyleEl
|
||||
} else {
|
||||
styleTag = document.createElement('style')
|
||||
styleTag.id = styleId
|
||||
document.head.appendChild(styleTag)
|
||||
}
|
||||
styleTag.textContent = metaData.branding.customCss
|
||||
styleTag.textContent = safe
|
||||
}
|
||||
}, [metaData?.branding?.customCss])
|
||||
|
||||
@@ -133,15 +193,32 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router])
|
||||
|
||||
// Guard: check if focus is in a text input, textarea, select, or contentEditable
|
||||
/**
|
||||
* Returns `true` when focus is in a text input, textarea, select, or contentEditable element.
|
||||
*
|
||||
* Used to suppress single-key shortcuts (`.`, `/`, `?`) while the user is typing.
|
||||
*
|
||||
* @returns Whether the currently focused element is a text-entry control.
|
||||
*/
|
||||
const isInputFocused = useCallback(() => {
|
||||
const tag = (document.activeElement?.tagName || '').toLowerCase()
|
||||
const type = (document.activeElement as HTMLInputElement)?.type || ''
|
||||
const isEditable = (document.activeElement as HTMLElement)?.isContentEditable
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select' || type === 'search' || isEditable
|
||||
const active = document.activeElement
|
||||
const tag = (active?.tagName ?? '').toLowerCase()
|
||||
const inputType = active instanceof HTMLInputElement ? active.type : ''
|
||||
const isEditable = active instanceof HTMLElement ? active.isContentEditable : false
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select' || inputType === 'search' || isEditable
|
||||
}, [])
|
||||
|
||||
// Global keyboard shortcut: Cmd+K / Ctrl+K → command palette, "." → quick nav, "?" → help
|
||||
/**
|
||||
* Global `keydown` handler that drives the application-level keyboard shortcuts.
|
||||
*
|
||||
* - Cmd+K / Ctrl+K — toggles the command palette.
|
||||
* - Escape — closes all overlays (command palette, search dialog, help dialog, mobile sidebar).
|
||||
* - `.` — opens the command palette (only when not in a text field).
|
||||
* - `/` — opens the search dialog (only when not in a text field).
|
||||
* - `?` — opens the keyboard shortcuts dialog (only when not in a text field).
|
||||
*
|
||||
* @param e - The native DOM keyboard event.
|
||||
*/
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
@@ -209,76 +286,80 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Skip to main content — accessibility */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
data-qqq-id="skip-to-content"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background">
|
||||
{/* Top-of-site banner spans the whole window, above the sidebar */}
|
||||
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_SITE" className="border-x-0 border-t-0" />
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{/* Skip to main content — accessibility */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
data-qqq-id="skip-to-content"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
data-qqq-id="sidebar-container"
|
||||
/>
|
||||
|
||||
{/* Mobile Sidebar Drawer */}
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
data-qqq-id="sidebar-mobile"
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Environment/status banners */}
|
||||
{metaData?.branding?.banners &&
|
||||
Object.keys(metaData.branding.banners).length > 0 && (
|
||||
<BannerComponent banners={metaData.branding.banners} />
|
||||
)}
|
||||
|
||||
{/* Header — breadcrumbs + search in one row */}
|
||||
<Header
|
||||
appName={metaData?.branding?.appName}
|
||||
onMenuOpen={() => setSidebarOpen(true)}
|
||||
pathToLabelMap={pathToLabelMap}
|
||||
parentAppMap={parentAppMap}
|
||||
{/* Desktop Sidebar */}
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
data-qqq-id="sidebar-container"
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
|
||||
{metaLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-label="Loading content"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</main>
|
||||
{/* Mobile Sidebar Drawer */}
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
data-qqq-id="sidebar-mobile"
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header — breadcrumbs + search in one row */}
|
||||
<Header
|
||||
appName={metaData?.branding?.appName}
|
||||
onMenuOpen={() => setSidebarOpen(true)}
|
||||
onSearchOpen={() => setSearchOpen(true)}
|
||||
onHelpOpen={() => setHelpOpen(true)}
|
||||
pathToLabelMap={pathToLabelMap}
|
||||
ancestorAppMap={ancestorAppMap}
|
||||
navTargets={navTargets}
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
|
||||
{metaLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-label="Loading content"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_BODY" className="mb-4 rounded-lg" />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} />
|
||||
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} navTargets={navTargets} />
|
||||
|
||||
{/* Search Dialog */}
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} navTargets={navTargets} />
|
||||
|
||||
{/* Keyboard Shortcuts Help Dialog */}
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
@@ -286,7 +367,15 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Outer layout — provides QContext to inner layout
|
||||
/**
|
||||
* Outer dashboard layout exported as the Next.js `(dashboard)` route group layout.
|
||||
*
|
||||
* Wraps the inner `DashboardLayoutContent` with `QContextProvider` so that all
|
||||
* authenticated pages share a single QContext instance.
|
||||
*
|
||||
* @param children - The page component rendered inside the authenticated shell.
|
||||
* @returns A `QContextProvider` wrapping the full dashboard layout.
|
||||
*/
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QContextProvider>
|
||||
|
||||
+40
-2
@@ -1,12 +1,50 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Global error boundary — Next.js `error.tsx` component that catches unhandled route-level exceptions.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// Global error boundary — catches unhandled exceptions at route level
|
||||
// Detects 401 errors and provides appropriate messaging
|
||||
/**
|
||||
* Global error boundary — Next.js `error.tsx` component that catches unhandled
|
||||
* exceptions at the route level.
|
||||
*
|
||||
* Detects 401 / session-expired conditions and renders a "Session Expired"
|
||||
* card with a login link. All other errors render a generic error card with
|
||||
* a "Try Again" button (which invokes `reset()` to re-render the segment)
|
||||
* and a "Go Home" link.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Renders the global route-level error UI.
|
||||
*
|
||||
* Next.js automatically passes `error` and `reset` when any Server or Client
|
||||
* Component in the route segment throws an unhandled error.
|
||||
*
|
||||
* @param props - The Next.js error boundary props.
|
||||
* @param props.error - The thrown error, optionally decorated with a `digest` ID from Next.js.
|
||||
* @param props.reset - Next.js callback that retries rendering the failed route segment.
|
||||
* @returns A full-screen card describing the error with recovery actions.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
|
||||
+43
-2
@@ -1,16 +1,47 @@
|
||||
// Root layout — wraps entire application with providers
|
||||
// This is a Server Component that sets up client-side providers
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Root layout — Server Component that wraps the entire Next.js application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Root layout — Server Component that wraps the entire Next.js application.
|
||||
*
|
||||
* Sets up the Inter font variable, global CSS, Next.js metadata defaults, and
|
||||
* mounts the `<Providers>` client component that supplies TanStack Query,
|
||||
* authentication, and toast infrastructure to all pages.
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import Providers from './providers'
|
||||
import '@/styles/globals.css'
|
||||
|
||||
/** Inter font configuration with the CSS variable `--font-inter`. */
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
})
|
||||
|
||||
/**
|
||||
* Default Next.js metadata applied to every page in the application.
|
||||
*
|
||||
* Individual pages may override `title` via their own `export const metadata`.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'QQQ Admin',
|
||||
@@ -22,6 +53,16 @@ export const metadata: Metadata = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* The root HTML layout for the entire Next.js application.
|
||||
*
|
||||
* Renders the `<html>` and `<body>` elements, applies the Inter font variable,
|
||||
* suppresses hydration warnings caused by browser extensions, and wraps all
|
||||
* page content in `<Providers>`.
|
||||
*
|
||||
* @param children - The active page or nested layout content.
|
||||
* @returns The fully-structured HTML document shell.
|
||||
*/
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
|
||||
+32
-1
@@ -1,8 +1,39 @@
|
||||
// 404 Not Found page
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Not Found page — Next.js 404 page rendered when a route segment cannot be matched.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Not Found page — Next.js `not-found.tsx` rendered automatically when
|
||||
* `notFound()` is called or a route segment cannot be matched.
|
||||
*
|
||||
* Displays a large "404" heading, a brief explanation, and a "Go Home" button
|
||||
* that returns the user to the application root.
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SearchX } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Full-screen 404 page shown for unknown routes or when `notFound()` is called.
|
||||
*
|
||||
* @returns A centered card containing the 404 error message and a home navigation link.
|
||||
*/
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
|
||||
+37
-4
@@ -1,8 +1,41 @@
|
||||
// Root page — redirects to dashboard or login
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
/**
|
||||
* @file Root page — redirects the application root to the dashboard.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* Root page that immediately redirects to `/app`.
|
||||
*
|
||||
* Client-side so the same page works in the static export hosted by the QQQ server.
|
||||
* The `(dashboard)` layout handles authentication and will redirect to `/login`
|
||||
* if the user is not authenticated.
|
||||
*
|
||||
* @returns Nothing; navigation replaces this page.
|
||||
*/
|
||||
export default function RootPage() {
|
||||
// Redirect to the dashboard — the (dashboard) layout handles auth
|
||||
redirect('/app')
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
router.replace('/app')
|
||||
}, [router])
|
||||
return null
|
||||
}
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Client-side providers wrapper — supplies TanStack Query, authentication, theme, and toast infrastructure.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// Client-side providers wrapper
|
||||
@@ -19,6 +39,17 @@ if (typeof window !== 'undefined') {
|
||||
|
||||
const isMockMode = process.env.NEXT_PUBLIC_MOCK_API === 'true'
|
||||
|
||||
/**
|
||||
* Root client-side providers wrapper.
|
||||
*
|
||||
* In mock mode (`NEXT_PUBLIC_MOCK_API=true`) this component blocks rendering
|
||||
* until the MSW service worker has fully started so that `AuthProvider` does not
|
||||
* fire API calls before the mock handler is registered.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.children - The application tree to wrap with all providers.
|
||||
* @returns The full provider tree, or a mock-loading spinner while MSW initializes.
|
||||
*/
|
||||
export default function Providers({ children }: { children: ReactNode }) {
|
||||
// When mock mode is enabled, block rendering until the MSW service worker
|
||||
// is fully started — otherwise AuthProvider fires its first API call before
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// Tests for CommandMenu: typed entries from navigation targets
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
|
||||
const pushMock = vi.fn()
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock }) }))
|
||||
|
||||
import { CommandMenu } from './CommandMenu'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
|
||||
const app = { label: 'People App', path: '/app/peopleApp' }
|
||||
const targets: NavTarget[] = [
|
||||
{ key: 'peopleApp', label: 'People App', path: '/app/peopleApp', nodeType: 'APP', icon: { name: 'person' }, ancestors: [] },
|
||||
{ key: 'pet', label: 'Pet', path: '/app/pet', nodeType: 'TABLE', icon: { name: 'pets' }, ancestors: [app] },
|
||||
{ key: 'clonePeople', label: 'Clone People', path: '/app/clonePeople', nodeType: 'PROCESS', ancestors: [app] },
|
||||
]
|
||||
|
||||
describe('CommandMenu', () => {
|
||||
beforeAll(() => {
|
||||
// cmdk scrolls the selected item into view and observes list size
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<CommandMenu open={false} onClose={vi.fn()} navTargets={targets} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('lists each target with its type, app context and declared icon (regression: every entry was typed as an app)', () => {
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} />)
|
||||
const pet = document.querySelector('[data-qqq-id="command-item-pet"]')!
|
||||
expect(pet).toHaveTextContent('Pet')
|
||||
expect(pet).toHaveTextContent('Table · People App')
|
||||
expect(pet).toHaveAttribute('data-node-type', 'TABLE')
|
||||
expect(pet.querySelector('svg')).toHaveAttribute('data-qqq-icon', 'pets')
|
||||
expect(document.querySelector('[data-qqq-id="command-item-clonePeople"]')).toHaveTextContent('Process · People App')
|
||||
expect(document.querySelector('[data-qqq-id="command-item-clonePeople"] svg')).toHaveClass('lucide-workflow')
|
||||
expect(document.querySelector('[data-qqq-id="command-item-peopleApp"]')).toHaveTextContent(/App$/)
|
||||
})
|
||||
|
||||
it('filters and navigates to the chosen target, then closes', async () => {
|
||||
const onClose = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<CommandMenu open onClose={onClose} navTargets={targets} />)
|
||||
await user.type(screen.getByRole('combobox'), 'clone')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/clonePeople')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,84 +1,106 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file CommandMenu — Cmd+K / Ctrl+K command palette that fuzzy-searches every navigable app, table, process and report.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// CommandMenu — Cmd+K / Ctrl+K command palette
|
||||
// Fuzzy-searches all sidebar routes from QContext
|
||||
// Navigate with arrow keys, Enter to navigate, Escape to close
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Search, Table2, Workflow, BarChart3, LayoutGrid, X } from 'lucide-react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
interface CommandMenuItem {
|
||||
id: string
|
||||
label: string
|
||||
path: string
|
||||
type: 'app' | 'table' | 'process' | 'report'
|
||||
breadcrumb?: string
|
||||
}
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
function buildMenuItems(pathToLabelMap: Record<string, string>): CommandMenuItem[] {
|
||||
const items: CommandMenuItem[] = []
|
||||
|
||||
for (const [path, label] of Object.entries(pathToLabelMap)) {
|
||||
// Skip parametric paths and dev/utility pages
|
||||
if (path.includes(':') || path.includes('/create') || path.includes('/dev') || path.includes('/key') || path.includes('/savedView')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = path.split('/').filter(Boolean) // e.g. ['app', 'employees'] or ['app', 'crm', 'employees']
|
||||
|
||||
let type: CommandMenuItem['type'] = 'table'
|
||||
let breadcrumb: string | undefined
|
||||
|
||||
if (parts.length === 2 && parts[0] === 'app') {
|
||||
// Could be app home or table
|
||||
type = 'app'
|
||||
} else if (parts.length === 3) {
|
||||
type = 'table'
|
||||
const parentPath = `/${parts[0]}/${parts[1]}`
|
||||
breadcrumb = pathToLabelMap[parentPath]
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: path,
|
||||
label,
|
||||
path,
|
||||
type,
|
||||
breadcrumb,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function TypeIcon({ type }: { type: CommandMenuItem['type'] }) {
|
||||
switch (type) {
|
||||
case 'app':
|
||||
return <LayoutGrid className="h-4 w-4 text-blue-500" aria-hidden="true" />
|
||||
case 'table':
|
||||
return <Table2 className="h-4 w-4 text-green-500" aria-hidden="true" />
|
||||
case 'process':
|
||||
return <Workflow className="h-4 w-4 text-purple-500" aria-hidden="true" />
|
||||
case 'report':
|
||||
return <BarChart3 className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
}
|
||||
}
|
||||
/** Human-readable type shown for each entry. */
|
||||
const TYPE_LABEL: Record<string, string> = { APP: 'App', TABLE: 'Table', PROCESS: 'Process', REPORT: 'Report' }
|
||||
|
||||
/**
|
||||
* Props for the CommandMenu component.
|
||||
*/
|
||||
interface CommandMenuProps {
|
||||
/** Whether the command palette is currently open. */
|
||||
open: boolean
|
||||
/** Called when the palette should close (backdrop click, Escape, or item selected). */
|
||||
onClose: () => void
|
||||
/** Navigable app-tree nodes (hidden objects already excluded). */
|
||||
navTargets: NavTarget[]
|
||||
}
|
||||
|
||||
export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
/**
|
||||
* Renders the Cmd+K command palette overlay.
|
||||
*
|
||||
* Uses the `cmdk` Command primitive for fuzzy search and keyboard navigation
|
||||
* over every navigable app, table, process and report (by metadata label, with
|
||||
* its type and enclosing apps as context). Selecting an item navigates and closes.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A fixed full-screen backdrop with the command palette dialog, or
|
||||
* `null` when `open` is `false`. Keyboard: ↑↓ navigate, ↵ open, Esc close.
|
||||
*/
|
||||
export function CommandMenu({ open, onClose, navTargets }: CommandMenuProps) {
|
||||
const router = useRouter()
|
||||
const { pathToLabelMap } = useQContext()
|
||||
const [search, setSearch] = useState('')
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const items = buildMenuItems(pathToLabelMap)
|
||||
// Remember what had focus before the palette opened, and give it focus back on close
|
||||
// (a modal dialog restores focus to its trigger).
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
const returnTo = returnFocusRef.current
|
||||
returnFocusRef.current = null
|
||||
if (returnTo?.isConnected) returnTo.focus()
|
||||
const remember = (event: FocusEvent) => {
|
||||
if (event.target instanceof HTMLElement && !event.target.closest('[data-qqq-id="command-menu"]')) returnFocusRef.current = event.target
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement && document.activeElement !== document.body) {
|
||||
returnFocusRef.current = document.activeElement
|
||||
}
|
||||
document.addEventListener('focusin', remember)
|
||||
return () => document.removeEventListener('focusin', remember)
|
||||
}, [open])
|
||||
|
||||
/**
|
||||
* Keeps Tab and Shift+Tab inside the palette while it is open (focus trap).
|
||||
*
|
||||
* @param event - The keydown event from within the dialog.
|
||||
*/
|
||||
const trapFocus = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Tab' || !dialogRef.current) return
|
||||
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('input, button, [href], [tabindex]:not([tabindex="-1"])'))
|
||||
.filter((element) => !element.hasAttribute('disabled'))
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Clear search when closed
|
||||
useEffect(() => {
|
||||
@@ -87,6 +109,11 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/**
|
||||
* Navigates to the selected item's path and closes the palette.
|
||||
*
|
||||
* @param path - The URL path of the selected command item.
|
||||
*/
|
||||
const handleSelect = useCallback(
|
||||
(path: string) => {
|
||||
router.push(path)
|
||||
@@ -100,7 +127,8 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
return (
|
||||
// Backdrop
|
||||
<div
|
||||
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
|
||||
className="fixed inset-0 flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
|
||||
style={{ zIndex: 'var(--qqq-z-toast)' } as React.CSSProperties}
|
||||
data-qqq-id="command-menu-backdrop"
|
||||
>
|
||||
<div
|
||||
@@ -116,6 +144,8 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
role="dialog"
|
||||
aria-label="Command palette"
|
||||
aria-modal="true"
|
||||
ref={dialogRef}
|
||||
onKeyDown={trapFocus}
|
||||
>
|
||||
<Command shouldFilter={true} label="Command palette">
|
||||
{/* Search input */}
|
||||
@@ -124,7 +154,7 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search pages, tables, processes..."
|
||||
placeholder="Search apps, tables, processes..."
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autoFocus
|
||||
data-qqq-id="command-menu-search"
|
||||
@@ -132,7 +162,7 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label="Close command palette"
|
||||
data-qqq-id="button-command-menu-close"
|
||||
>
|
||||
@@ -145,12 +175,13 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
className="max-h-80 overflow-y-auto py-2"
|
||||
aria-label="Navigation results"
|
||||
aria-live="polite"
|
||||
data-qqq-id="command-menu-results"
|
||||
>
|
||||
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
{items.length > 0 && (
|
||||
{navTargets.length > 0 && (
|
||||
<Command.Group
|
||||
heading={
|
||||
<span className="px-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
@@ -158,29 +189,31 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
value={`${item.label} ${item.breadcrumb ?? ''}`}
|
||||
onSelect={() => handleSelect(item.path)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm',
|
||||
'text-foreground',
|
||||
'hover:bg-accent',
|
||||
'aria-selected:bg-primary/10 aria-selected:text-primary',
|
||||
'outline-none transition-colors'
|
||||
)}
|
||||
data-qqq-id={`command-item-${item.id.replace(/\//g, '-')}`}
|
||||
>
|
||||
<TypeIcon type={item.type} />
|
||||
<span className="flex-1 truncate font-medium">{item.label}</span>
|
||||
{item.breadcrumb && (
|
||||
{navTargets.map((target) => {
|
||||
const context = target.ancestors.map((ancestor) => ancestor.label).join(' / ')
|
||||
return (
|
||||
<Command.Item
|
||||
key={target.path}
|
||||
value={`${target.label} ${context} ${target.path}`}
|
||||
onSelect={() => handleSelect(target.path)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm',
|
||||
'text-foreground',
|
||||
'hover:bg-accent',
|
||||
'aria-selected:bg-primary/10 aria-selected:text-primary',
|
||||
'outline-none transition-colors'
|
||||
)}
|
||||
data-qqq-id={`command-item-${target.key}`}
|
||||
data-node-type={target.nodeType}
|
||||
>
|
||||
<MetadataIcon icon={target.icon} kind={ICON_KIND[target.nodeType]} />
|
||||
<span className="flex-1 truncate font-medium">{target.label}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{item.breadcrumb}
|
||||
{context ? `${TYPE_LABEL[target.nodeType]} · ${context}` : TYPE_LABEL[target.nodeType]}
|
||||
</span>
|
||||
)}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Item>
|
||||
)
|
||||
})}
|
||||
</Command.Group>
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
'use client'
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// EmptyState — Reusable empty state component
|
||||
// Used when a query returns zero results or no data is available
|
||||
/**
|
||||
* @file EmptyState — reusable placeholder shown when a query returns zero results or no data is available.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { type ReactNode } from 'react'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the EmptyState component.
|
||||
*/
|
||||
export interface EmptyStateProps {
|
||||
/** The primary heading text describing the empty state (e.g. "No records found"). */
|
||||
title: string
|
||||
/** Optional supporting text explaining why the list is empty or what the user can do. */
|
||||
description?: string
|
||||
/** Optional custom icon node; defaults to the `Inbox` Lucide icon when not provided. */
|
||||
icon?: ReactNode
|
||||
/** Optional primary call-to-action rendered as a button below the description. */
|
||||
action?: { label: string; onClick: () => void }
|
||||
/** Additional Tailwind class names applied to the outermost container div. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a centered empty-state placeholder with an icon, title, optional description,
|
||||
* and an optional call-to-action button.
|
||||
*
|
||||
* Uses `role="status"` and `aria-live="polite"` so screen readers announce
|
||||
* the state when it appears after a data load.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A bordered dashed container centered in its parent, with
|
||||
* `role="status"` and `aria-live="polite"` so screen readers announce the
|
||||
* empty-state message without requiring focus.
|
||||
*/
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -1,43 +1,124 @@
|
||||
'use client'
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// ErrorBoundary — Reusable React class-based error boundary
|
||||
// Catches render errors in its children subtree
|
||||
/**
|
||||
* @file ErrorBoundary — reusable React class-based error boundary that catches render errors in its children subtree.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { type ReactNode, type ErrorInfo } from 'react'
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the ErrorBoundary class component.
|
||||
*/
|
||||
export interface ErrorBoundaryProps {
|
||||
/** The React subtree to protect against render errors. */
|
||||
children: ReactNode
|
||||
/** Custom fallback UI to render instead of the default error card when an error is caught. */
|
||||
fallback?: ReactNode
|
||||
/** Optional callback invoked with the caught error and React error info for external logging. */
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
/** Additional Tailwind class names applied to the default error card container. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state for the ErrorBoundary class component.
|
||||
*/
|
||||
interface ErrorBoundaryState {
|
||||
/** Whether an error has been caught and the fallback UI should be shown. */
|
||||
hasError: boolean
|
||||
/** The most recently caught error, or `null` when none has occurred. */
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* React class component that catches JavaScript errors anywhere in its child tree.
|
||||
*
|
||||
* When an error is caught, it renders either a custom `fallback` node or the
|
||||
* built-in error card with a "Try Again" button. Calling `handleReset` clears
|
||||
* the error state so children can re-render.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ErrorBoundary onError={(err) => logger.error(err)}>
|
||||
* <WidgetGrid />
|
||||
* </ErrorBoundary>
|
||||
* ```
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* React lifecycle method called during the render phase when a descendant
|
||||
* throws. Because it runs in the render phase (not the commit phase), React
|
||||
* can use the returned state to re-render the fallback UI in the same pass —
|
||||
* no additional render cycle is required. Side effects such as logging must
|
||||
* NOT be placed here; use {@link componentDidCatch} instead, which runs in
|
||||
* the commit phase after the fallback has been painted.
|
||||
*
|
||||
* @param error - The error that was thrown by a descendant component.
|
||||
* @returns New state with `hasError: true` and the caught `error` object so
|
||||
* `render()` can display the fallback UI.
|
||||
*/
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* React lifecycle method called in the commit phase after the fallback UI
|
||||
* has been painted to the DOM. This is the correct place for side effects
|
||||
* such as logging because the render phase has already completed. In
|
||||
* contrast, {@link getDerivedStateFromError} runs during the render phase
|
||||
* and must remain pure (no side effects).
|
||||
*
|
||||
* @param error - The caught error.
|
||||
* @param info - React error info containing the component stack trace.
|
||||
*/
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('[ErrorBoundary] Caught error:', error, info)
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the error state so the child tree will attempt to re-render.
|
||||
*/
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders either the fallback UI (when an error has been caught) or the child tree.
|
||||
*
|
||||
* Priority order when `hasError` is true:
|
||||
* 1. `fallback` prop — custom caller-supplied node rendered as-is.
|
||||
* 2. Default error card — centered red card with the error message and a
|
||||
* "Try Again" button that calls {@link handleReset}.
|
||||
*
|
||||
* When `hasError` is false, renders `children` unchanged.
|
||||
*
|
||||
* @returns The `fallback` prop when provided, the default error card as
|
||||
* secondary fallback, or `children` when no error has occurred.
|
||||
*/
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
|
||||
@@ -1,25 +1,53 @@
|
||||
'use client'
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// KeyboardShortcutsDialog — "?" help dialog showing available keyboard shortcuts
|
||||
// Organized by context: Global, Table Query, Record View
|
||||
// Uses Radix Dialog primitive with project design tokens
|
||||
/**
|
||||
* @file KeyboardShortcutsDialog — "?" help dialog listing available keyboard shortcuts grouped by context.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
|
||||
|
||||
/**
|
||||
* Describes a single keyboard shortcut with its key combination and action description.
|
||||
*/
|
||||
interface ShortcutEntry {
|
||||
/** Ordered array of key labels (e.g. `['⌘', 'K']`). */
|
||||
keys: string[]
|
||||
/** Human-readable description of the action triggered by the shortcut. */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A named group of related keyboard shortcuts shown under a section header.
|
||||
*/
|
||||
interface ShortcutSection {
|
||||
/** Section heading (e.g. `"Global"`, `"Table Query Page"`). */
|
||||
title: string
|
||||
/** List of shortcuts belonging to this section. */
|
||||
shortcuts: ShortcutEntry[]
|
||||
}
|
||||
|
||||
/** Static list of all keyboard shortcut sections displayed in the dialog. */
|
||||
const shortcutSections: ShortcutSection[] = [
|
||||
{
|
||||
title: 'Global',
|
||||
@@ -49,6 +77,13 @@ const shortcutSections: ShortcutSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Renders a styled keyboard key badge using the `<kbd>` HTML element.
|
||||
*
|
||||
* @param children - The key label to display (e.g. `"⌘"`, `"K"`, `"esc"`).
|
||||
* @returns A `<kbd>` element with a minimum 24 px square size, monospace font,
|
||||
* and muted background/border using project design tokens.
|
||||
*/
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd
|
||||
@@ -62,12 +97,31 @@ function Kbd({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the KeyboardShortcutsDialog component.
|
||||
*/
|
||||
interface KeyboardShortcutsDialogProps {
|
||||
/** Whether the dialog is currently open. */
|
||||
open: boolean
|
||||
/** Called when the dialog should close. */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal dialog listing all keyboard shortcuts organized by contextual section.
|
||||
*
|
||||
* Triggered by pressing `?` anywhere in the application (when focus is not in
|
||||
* a text field). Closes via the X button, the close prop, or Escape key. Uses
|
||||
* the Radix `Dialog` primitive with project design tokens and the `Kbd` badge
|
||||
* component to render key labels.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A Radix Dialog portal with a sticky header, scrollable shortcut
|
||||
* sections (max 60 vh), and a footer reminder. Each section groups related
|
||||
* shortcuts under a heading with {@link Kbd} badges for each key combination.
|
||||
*/
|
||||
export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDialogProps) {
|
||||
const restoreFocus = useRestoreFocus(open)
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<Dialog.Portal>
|
||||
@@ -76,6 +130,7 @@ export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDial
|
||||
data-qqq-id="keyboard-shortcuts-overlay"
|
||||
/>
|
||||
<Dialog.Content
|
||||
onCloseAutoFocus={restoreFocus}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-[2001] w-full max-w-md -translate-x-1/2 -translate-y-1/2',
|
||||
'rounded-xl border border-border bg-card shadow-lg',
|
||||
|
||||
@@ -1,52 +1,59 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file SearchDialog — "/" key "jump to" dialog over pages and recently viewed records.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// SearchDialog — "/" key command palette for searching records
|
||||
// Shows recent records when idle, live API search results when typing
|
||||
// Enter on a result navigates to record; Enter with no selection goes to full search page
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search, Clock, ArrowRight, X } from 'lucide-react'
|
||||
import { Search, ArrowRight, X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { globalSearch } from '@/lib/api/tables'
|
||||
import type { GlobalSearchResult } from '@/lib/api/tables'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
|
||||
import { NavigationSearchResults } from '@/components/layout/NavigationSearchResults'
|
||||
|
||||
/**
|
||||
* Props for the SearchDialog component.
|
||||
*/
|
||||
interface SearchDialogProps {
|
||||
/** Whether the search dialog is currently open. */
|
||||
open: boolean
|
||||
/** Called when the dialog should close (backdrop click, Escape, or item navigation). */
|
||||
onClose: () => void
|
||||
/** Navigable targets from the app tree. */
|
||||
navTargets: NavTarget[]
|
||||
}
|
||||
|
||||
function getInitials(label: string): string {
|
||||
const words = label.trim().split(/\s+/)
|
||||
if (words.length === 0) return '?'
|
||||
if (words.length === 1) return words[0].charAt(0).toUpperCase()
|
||||
return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase()
|
||||
}
|
||||
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
if (!query || query.length < 2) return <>{text}</>
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(`(${escaped})`, 'gi')
|
||||
const parts = text.split(regex)
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-primary/20 text-foreground rounded-sm px-0.5">{part}</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
/**
|
||||
* Modal search dialog triggered by the `/` keyboard shortcut.
|
||||
*
|
||||
* Lists recently viewed records until the user types, then matching pages
|
||||
* (apps, tables, processes, reports by label) and matching recent records.
|
||||
* ArrowUp/Down move the selection, Enter opens it (or the search page when
|
||||
* nothing is selected), Escape closes.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The dialog overlay, or `null` when closed.
|
||||
*/
|
||||
export function SearchDialog({ open, onClose, navTargets }: SearchDialogProps) {
|
||||
const router = useRouter()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -54,59 +61,30 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
|
||||
// Debounced search term for API calls
|
||||
const [debouncedTerm, setDebouncedTerm] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedTerm(searchTerm), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchTerm])
|
||||
|
||||
// TanStack Query for search results
|
||||
const { data: searchResults = [], isLoading: isSearching } = useQuery<GlobalSearchResult[]>({
|
||||
queryKey: queryKeys.globalSearch(debouncedTerm),
|
||||
queryFn: () => globalSearch(debouncedTerm),
|
||||
enabled: open && debouncedTerm.length >= 2,
|
||||
staleTime: 1000 * 30,
|
||||
})
|
||||
|
||||
// Load recent records when dialog opens; clear state when closed
|
||||
// Load recent records when the dialog opens; reset state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRecentRecords(getRecentRecords().slice(0, 10))
|
||||
setSearchTerm('')
|
||||
setDebouncedTerm('')
|
||||
setSelectedIndex(-1)
|
||||
// Focus after animation frame
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Reset selected index when results change
|
||||
const items = useMemo(
|
||||
() => buildNavigationSearchItems(navTargets, recentRecords, searchTerm),
|
||||
[navTargets, recentRecords, searchTerm]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
}, [searchResults, recentRecords, searchTerm])
|
||||
|
||||
// Build navigable items
|
||||
const navigableItems: Array<{ path: string; label: string; tableLabel?: string }> = []
|
||||
if (searchTerm.length >= 2 && searchResults.length > 0) {
|
||||
for (const result of searchResults) {
|
||||
navigableItems.push({
|
||||
path: `/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`,
|
||||
label: result.recordLabel,
|
||||
tableLabel: result.tableLabel || result.tableName,
|
||||
})
|
||||
}
|
||||
} else if (searchTerm.length < 2 && recentRecords.length > 0) {
|
||||
for (const record of recentRecords) {
|
||||
navigableItems.push({
|
||||
path: record.path,
|
||||
label: record.recordLabel,
|
||||
tableLabel: record.tableLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [items])
|
||||
|
||||
/**
|
||||
* Closes the dialog and navigates to the given path.
|
||||
*
|
||||
* @param path - The URL path to navigate to.
|
||||
*/
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
onClose()
|
||||
@@ -115,14 +93,17 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
[router, onClose]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation within the result list.
|
||||
*
|
||||
* @param e - The synthetic keyboard event from the search input.
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
prev < navigableItems.length - 1 ? prev + 1 : prev
|
||||
)
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
@@ -130,8 +111,8 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < navigableItems.length) {
|
||||
handleNavigate(navigableItems[selectedIndex].path)
|
||||
if (selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
handleNavigate(items[selectedIndex].path)
|
||||
} else if (searchTerm.trim()) {
|
||||
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
|
||||
}
|
||||
@@ -142,26 +123,13 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
break
|
||||
}
|
||||
},
|
||||
[selectedIndex, navigableItems, searchTerm, handleNavigate, onClose]
|
||||
[selectedIndex, items, searchTerm, handleNavigate, onClose]
|
||||
)
|
||||
|
||||
// Group search results by table
|
||||
const groupedResults: Record<string, GlobalSearchResult[]> = {}
|
||||
if (searchTerm.length >= 2) {
|
||||
for (const result of searchResults) {
|
||||
const key = result.tableLabel || result.tableName
|
||||
if (!groupedResults[key]) groupedResults[key] = []
|
||||
groupedResults[key].push(result)
|
||||
}
|
||||
}
|
||||
|
||||
let runningIndex = 0
|
||||
const showRecent = searchTerm.length < 2 && recentRecords.length > 0
|
||||
const showResults = searchTerm.length >= 2
|
||||
const showEmpty = searchTerm.length >= 2 && !isSearching && searchResults.length === 0
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const hasTerm = searchTerm.trim().length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
|
||||
@@ -177,7 +145,7 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg overflow-hidden"
|
||||
data-qqq-id="search-dialog"
|
||||
role="dialog"
|
||||
aria-label="Search records"
|
||||
aria-label="Search"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Search input */}
|
||||
@@ -189,11 +157,12 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search records..."
|
||||
placeholder="Jump to a page or recent record..."
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autoFocus
|
||||
aria-label="Search records"
|
||||
aria-label="Search pages and recent records"
|
||||
aria-expanded={true}
|
||||
aria-controls="search-dialog-results"
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
@@ -206,7 +175,7 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<kbd className="hidden sm:inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
|
||||
@@ -215,111 +184,20 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-80 overflow-y-auto" role="listbox" aria-label="Search results">
|
||||
{/* Recent records */}
|
||||
{showRecent && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
Recently Viewed
|
||||
</div>
|
||||
{recentRecords.map((record, index) => (
|
||||
<button
|
||||
key={record.path}
|
||||
onClick={() => handleNavigate(record.path)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors',
|
||||
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||
{getInitials(record.recordLabel)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{record.recordLabel}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{record.tableLabel}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Searching spinner */}
|
||||
{showResults && isSearching && (
|
||||
<div className="flex items-center justify-center px-4 py-8">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Searching...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouped search results */}
|
||||
{showResults && !isSearching && !showEmpty && (
|
||||
Object.entries(groupedResults).map(([tableLabel, results]) => {
|
||||
const groupItems = results.map((result, resultIdx) => {
|
||||
const itemIndex = runningIndex
|
||||
runningIndex++
|
||||
return (
|
||||
<button
|
||||
key={`${result.tableName}-${result.recordId}-${resultIdx}`}
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
`/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => setSelectedIndex(itemIndex)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors',
|
||||
selectedIndex === itemIndex ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === itemIndex}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||
{getInitials(result.recordLabel)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
<HighlightedText text={result.recordLabel} query={searchTerm} />
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{result.tableLabel || result.tableName}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<div key={tableLabel}>
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
{tableLabel}
|
||||
</div>
|
||||
{groupItems}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmpty && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No results found for “{searchTerm}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No recent records and no search */}
|
||||
{!showRecent && !showResults && (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Start typing to search records...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasTerm || items.length > 0 ? (
|
||||
<NavigationSearchResults
|
||||
id="search-dialog-results"
|
||||
items={items}
|
||||
term={searchTerm}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleNavigate}
|
||||
onHover={setSelectedIndex}
|
||||
/>
|
||||
) : (
|
||||
<div id="search-dialog-results" className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Type to find an app, table, process or recent record...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
@@ -329,17 +207,17 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd>{' '}navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}
|
||||
{searchTerm.trim() ? 'open' : 'select'}
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}open
|
||||
</span>
|
||||
</div>
|
||||
{searchTerm.trim() && (
|
||||
{hasTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
View all results
|
||||
Show all matches
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,43 @@
|
||||
'use client'
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// Skeleton — Reusable skeleton loader variants
|
||||
// Usage:
|
||||
// <Skeleton.Text lines={3} />
|
||||
// <Skeleton.Card />
|
||||
// <Skeleton.Table rows={5} cols={4} />
|
||||
/**
|
||||
* @file Skeleton — collection of animated placeholder components used while content is loading.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Base props shared by all Skeleton sub-components.
|
||||
*/
|
||||
interface SkeletonBaseProps {
|
||||
/** Additional Tailwind class names applied to the outermost element. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Single animated bar
|
||||
/**
|
||||
* A single animated pulse bar used as a building block for skeleton variants.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns An `aria-hidden` div with `animate-pulse rounded bg-muted` styling;
|
||||
* width/height are controlled entirely by the `className` prop.
|
||||
*/
|
||||
function Bar({ className }: SkeletonBaseProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -26,12 +50,24 @@ function Bar({ className }: SkeletonBaseProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Text lines placeholder
|
||||
/**
|
||||
* Props for the Skeleton.Text sub-component.
|
||||
*/
|
||||
interface TextProps {
|
||||
/** Number of placeholder text lines to render. Defaults to `3`. */
|
||||
lines?: number
|
||||
/** Additional Tailwind class names applied to the container div. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a stack of animated placeholder text lines at varying widths.
|
||||
*
|
||||
* Uses `role="status"` and `aria-busy="true"` for accessibility.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A stack of `Bar` elements mimicking a block of text.
|
||||
*/
|
||||
function Text({ lines = 3, className }: TextProps) {
|
||||
const widths = ['w-full', 'w-4/5', 'w-3/5', 'w-full', 'w-2/3', 'w-1/2']
|
||||
return (
|
||||
@@ -48,11 +84,23 @@ function Text({ lines = 3, className }: TextProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Card placeholder
|
||||
/**
|
||||
* Props for the Skeleton.Card sub-component.
|
||||
*/
|
||||
interface CardProps {
|
||||
/** Additional Tailwind class names applied to the card container. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an animated placeholder that mimics a content card layout.
|
||||
*
|
||||
* Includes an avatar circle, two lines of meta text, body text bars, and a
|
||||
* tall image placeholder. Uses `role="status"` and `aria-busy="true"`.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A bordered rounded card filled with `Bar` placeholders.
|
||||
*/
|
||||
function Card({ className }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -85,13 +133,26 @@ function Card({ className }: CardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Table rows placeholder
|
||||
/**
|
||||
* Props for the Skeleton.Table sub-component.
|
||||
*/
|
||||
interface TableProps {
|
||||
/** Number of placeholder data rows to render. Defaults to `5`. */
|
||||
rows?: number
|
||||
/** Number of placeholder columns per row. Defaults to `4`. */
|
||||
cols?: number
|
||||
/** Additional Tailwind class names applied to the table container. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an animated placeholder resembling a data table with a header row and data rows.
|
||||
*
|
||||
* Uses `role="status"` and `aria-busy="true"` for accessibility.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A bordered container with a header row and data rows of `Bar` cells.
|
||||
*/
|
||||
function Table({ rows = 5, cols = 4, className }: TableProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -124,5 +185,14 @@ function Table({ rows = 5, cols = 4, className }: TableProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Namespace export — Skeleton.Text, Skeleton.Card, Skeleton.Table
|
||||
/**
|
||||
* Namespace object exporting all skeleton variant components.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Skeleton.Text lines={3} />
|
||||
* <Skeleton.Card className="my-4" />
|
||||
* <Skeleton.Table rows={8} cols={5} />
|
||||
* ```
|
||||
*/
|
||||
export const Skeleton = { Text, Card, Table }
|
||||
@@ -1,6 +1,29 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Toast — re-exports the `Toaster` component from sonner for placement in the providers tree.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// Toast — Re-exports the Toaster from sonner for use in the providers tree
|
||||
// Add <Toaster /> to the root providers to enable toast notifications globally
|
||||
|
||||
/**
|
||||
* Re-exports `Toaster` from sonner for global toast notification support.
|
||||
*
|
||||
* Add `<Toaster />` once in the root providers to enable toast notifications.
|
||||
* Trigger toasts with `toast()`, `toast.success()`, or `toast.error()` from `sonner`.
|
||||
*/
|
||||
export { Toaster } from 'sonner'
|
||||
@@ -1,51 +1,137 @@
|
||||
'use client'
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// DynamicForm — renders form fields from metadata using React Hook Form
|
||||
// This is the CORE REUSABLE FORM component used by EntityForm (Package 3)
|
||||
// and will be reused by ProcessRun (Package 4).
|
||||
/**
|
||||
* @file DynamicForm — core metadata-driven form renderer used by EntityForm and ProcessRun.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Control, UseFormRegister, FieldErrors } from 'react-hook-form'
|
||||
|
||||
import type { QFieldMetaData, QTableSection, QTableMetaData } from '@/types'
|
||||
import type { QFieldMetaData, QRecord, QTableSection, QTableMetaData, QWidgetMetaData } from '@/types'
|
||||
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
import { DynamicFormField } from './DynamicFormField'
|
||||
import { SectionIcon } from '@/components/layout/MetadataIcon'
|
||||
|
||||
/**
|
||||
* Props for the {@link DynamicForm} component.
|
||||
*
|
||||
* Supply either `tableMetaData` (for section-grouped layout) or `fields`
|
||||
* (for a flat list, typically used by processes). Both can be combined with
|
||||
* `fieldNamesToInclude` for fine-grained field filtering.
|
||||
*/
|
||||
export interface DynamicFormProps {
|
||||
// React Hook Form internals — passed in from parent
|
||||
/** React Hook Form register function from the parent `useForm` instance. */
|
||||
register: UseFormRegister<Record<string, unknown>>
|
||||
/** React Hook Form control object from the parent `useForm` instance. */
|
||||
control: Control<Record<string, unknown>>
|
||||
/** React Hook Form validation error map from the parent `useForm` instance. */
|
||||
errors: FieldErrors<Record<string, unknown>>
|
||||
|
||||
// Metadata for rendering
|
||||
/** Table metadata used to derive sections and field ordering. */
|
||||
tableMetaData?: QTableMetaData
|
||||
/** Explicit field list; when provided, section grouping is skipped. */
|
||||
fields?: QFieldMetaData[]
|
||||
/** Override the sections from `tableMetaData`; ignored when `fields` is set. */
|
||||
sections?: QTableSection[]
|
||||
|
||||
// Which fields to include (if undefined, all editable non-hidden fields)
|
||||
/** Restricts rendered fields to this allow-list; when omitted all editable non-hidden fields are shown. */
|
||||
fieldNamesToInclude?: string[]
|
||||
|
||||
// Context for possible values (table or process)
|
||||
/** Context forwarded to possible-value fields to scope their fetch calls. */
|
||||
possibleValueContext?: PossibleValueContext
|
||||
|
||||
// Whether the entire form is disabled
|
||||
/** When `true`, all fields are rendered in a disabled, read-only state. */
|
||||
disabled?: boolean
|
||||
|
||||
// Optional label shown at top
|
||||
/**
|
||||
* Map of field names to a boolean indicating whether that field has been
|
||||
* modified from its default value. When `true` for a field, the field
|
||||
* renders with a left-border accent to highlight the change.
|
||||
* Typically sourced from `formState.dirtyFields` in React Hook Form.
|
||||
*/
|
||||
dirtyFields?: Record<string, boolean>
|
||||
|
||||
/**
|
||||
* The record being edited, if any: supplies the display of read-only fields, the
|
||||
* current file of upload fields and the labels of possible-value selections.
|
||||
*/
|
||||
record?: QRecord
|
||||
|
||||
/** Show non-editable fields as read-only controls (the edit screen does; create and copy do not). */
|
||||
showReadOnlyFields?: boolean
|
||||
|
||||
/** Screen roles used to choose field help content, most specific first. */
|
||||
helpRoles?: readonly string[]
|
||||
|
||||
/** Limit typing to each field's `maxLength` (default); record forms turn this off. */
|
||||
enforceMaxLength?: boolean
|
||||
|
||||
/** Optional heading rendered above the field grid. */
|
||||
formLabel?: string
|
||||
|
||||
/**
|
||||
* Widget metadata by name. A section housing a widget shown on record edit
|
||||
* screens (`includeOnRecordEditScreen`, e.g. the cron schedule) renders the
|
||||
* record fields that widget edits, which usually sit in a hidden section.
|
||||
*/
|
||||
widgets?: Record<string, QWidgetMetaData>
|
||||
|
||||
/** Additional CSS classes applied to the outermost container. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The record fields a widget section edits on a record form, as Material's
|
||||
* EntityForm does for `cronUI` widgets (expression and time zone fields).
|
||||
*
|
||||
* @param section - A table section.
|
||||
* @param widgets - Widget metadata by name.
|
||||
* @returns The edited field names, or `undefined` when the section is not an editable widget section.
|
||||
*/
|
||||
export function editScreenWidgetFieldNames(section: QTableSection, widgets: Record<string, QWidgetMetaData> | undefined): string[] | undefined {
|
||||
const widget = section.widgetName ? widgets?.[section.widgetName] : undefined
|
||||
if (!widget || widget.hasPermission === false || widget.type !== 'cronUI') return undefined
|
||||
const defaults = widget.defaultValues ?? {}
|
||||
if (defaults.includeOnRecordEditScreen !== true) return undefined
|
||||
const names = [defaults.cronExpressionFieldName, defaults.timeZoneFieldName].filter((name): name is string => typeof name === 'string' && name !== '')
|
||||
return names.length > 0 ? names : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicForm renders form fields from metadata.
|
||||
*
|
||||
* Usage patterns:
|
||||
* 1. Table-based: pass tableMetaData (uses its sections for layout)
|
||||
* 2. Process-based: pass fields directly (flat list, no sections)
|
||||
* 3. Both: pass both tableMetaData and fieldNamesToInclude to filter
|
||||
* Supports three usage patterns:
|
||||
* 1. **Table-based** — pass `tableMetaData`; fields are grouped and ordered by
|
||||
* the table's sections.
|
||||
* 2. **Process-based** — pass `fields` directly; renders a flat list with no
|
||||
* section grouping, typically used by `ProcessRun`.
|
||||
* 3. **Filtered** — pass both `tableMetaData` and `fieldNamesToInclude` to show
|
||||
* only a named subset of the table's fields while preserving section layout.
|
||||
*
|
||||
* Used by {@link EntityForm} (create/edit/copy), `ProcessRun` (step forms), and
|
||||
* any other caller that needs a metadata-driven field grid.
|
||||
*
|
||||
* @param props - Component properties (see {@link DynamicFormProps}).
|
||||
* @returns The rendered form fields grouped by section, or `null` when no
|
||||
* renderable fields exist after applying visibility and include-list filters.
|
||||
*/
|
||||
export function DynamicForm({
|
||||
register,
|
||||
@@ -57,7 +143,13 @@ export function DynamicForm({
|
||||
fieldNamesToInclude,
|
||||
possibleValueContext,
|
||||
disabled = false,
|
||||
dirtyFields,
|
||||
record,
|
||||
showReadOnlyFields = false,
|
||||
helpRoles,
|
||||
enforceMaxLength = true,
|
||||
formLabel,
|
||||
widgets,
|
||||
className,
|
||||
}: DynamicFormProps) {
|
||||
// Determine the set of fields to render
|
||||
@@ -80,8 +172,8 @@ export function DynamicForm({
|
||||
// Collect field names in section order
|
||||
const resolvedSections = sections ?? tableMetaData.sections
|
||||
for (const section of resolvedSections) {
|
||||
if (section.isHidden) continue
|
||||
for (const fn of section.fieldNames) {
|
||||
if (section.isHidden || section.hidden) continue
|
||||
for (const fn of section.fieldNames ?? []) {
|
||||
if (!fieldOrder.includes(fn)) fieldOrder.push(fn)
|
||||
}
|
||||
}
|
||||
@@ -108,24 +200,28 @@ export function DynamicForm({
|
||||
const hasSections =
|
||||
tableMetaData &&
|
||||
tableMetaData.sections &&
|
||||
tableMetaData.sections.filter((s) => !s.isHidden).length > 0 &&
|
||||
tableMetaData.sections.filter((s) => !s.isHidden && !s.hidden).length > 0 &&
|
||||
!fields
|
||||
|
||||
if (hasSections && tableMetaData) {
|
||||
const resolvedSections = sections ?? tableMetaData.sections
|
||||
const resolvedSections = (sections ?? tableMetaData.sections).map((section) => {
|
||||
const widgetFieldNames = editScreenWidgetFieldNames(section, widgets)
|
||||
return widgetFieldNames ? { ...section, fieldNames: widgetFieldNames } : section
|
||||
})
|
||||
return (
|
||||
<div className={cn('space-y-6', className)} data-qqq-id="dynamic-form">
|
||||
{formLabel && (
|
||||
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
|
||||
)}
|
||||
{resolvedSections
|
||||
.filter((s) => !s.isHidden)
|
||||
.filter((s) => !s.isHidden && !s.hidden)
|
||||
.map((section) => {
|
||||
const sectionFields = section.fieldNames
|
||||
const sectionFields = (section.fieldNames ?? [])
|
||||
.map((fn) => tableMetaData.fields[fn])
|
||||
.filter((f): f is QFieldMetaData => {
|
||||
if (!f) return false
|
||||
if (f.isHidden) return false
|
||||
if (!f.isEditable && !showReadOnlyFields && !disabled) return false
|
||||
if (fieldNamesToInclude && !fieldNamesToInclude.includes(f.name)) return false
|
||||
return true
|
||||
})
|
||||
@@ -142,7 +238,8 @@ export function DynamicForm({
|
||||
>
|
||||
{section.label && (
|
||||
<div className="border-b border-border pb-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
<h4 className="flex items-center text-sm font-medium text-muted-foreground">
|
||||
<SectionIcon section={section} />
|
||||
{section.label}
|
||||
</h4>
|
||||
</div>
|
||||
@@ -170,7 +267,12 @@ export function DynamicForm({
|
||||
control={control}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
isDirty={dirtyFields?.[f.name] === true}
|
||||
possibleValueContext={possibleValueContext}
|
||||
record={record}
|
||||
showReadOnly={showReadOnlyFields}
|
||||
helpRoles={helpRoles}
|
||||
enforceMaxLength={enforceMaxLength}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -203,7 +305,12 @@ export function DynamicForm({
|
||||
control={control}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
isDirty={dirtyFields?.[f.name] === true}
|
||||
possibleValueContext={possibleValueContext}
|
||||
record={record}
|
||||
showReadOnly={showReadOnlyFields}
|
||||
helpRoles={helpRoles}
|
||||
enforceMaxLength={enforceMaxLength}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AxiosError, AxiosHeaders } from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { QFieldMetaData, QRecord, QTableMetaData, QWidgetMetaData } from '@/types'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { EntityForm } from './EntityForm'
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), back: vi.fn() }) }))
|
||||
|
||||
function field(name: string, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
|
||||
return { name, label: name[0].toUpperCase() + name.slice(1), type: 'STRING', isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments: [], ...extra }
|
||||
}
|
||||
|
||||
const table: QTableMetaData = {
|
||||
name: 'lab', label: 'Lab', isHidden: false, primaryKeyField: 'id',
|
||||
fields: {
|
||||
id: field('id', { type: 'INTEGER', isEditable: false }),
|
||||
title: field('title', { isRequired: true, maxLength: 4 }),
|
||||
secret: field('secret', { type: 'PASSWORD' }),
|
||||
big: field('big', { type: 'LONG' }),
|
||||
stamp: field('stamp', { type: 'DATE_TIME' }),
|
||||
owner: field('owner', { type: 'STRING' }),
|
||||
flag: field('flag', { type: 'BOOLEAN', defaultValue: 'true' }),
|
||||
createDate: field('createDate', { type: 'DATE_TIME', isEditable: false }),
|
||||
},
|
||||
sections: [{ name: 'main', label: 'Main', isHidden: false, fieldNames: ['id', 'title', 'secret', 'big', 'stamp', 'owner', 'flag', 'createDate'] }],
|
||||
capabilities: [], exposedJoins: [], readPermission: true, insertPermission: true, editPermission: true, deletePermission: true,
|
||||
usesVariants: false, variantTableLabel: '',
|
||||
}
|
||||
|
||||
const stored: QRecord = {
|
||||
tableName: 'lab',
|
||||
values: { id: 7, title: 'Old', secret: '************', big: 9007199254740992, stamp: '2024-07-04T13:15:00.123456Z', owner: 'sample:bob', flag: false, createDate: '2024-01-01T00:00:00Z' },
|
||||
displayValues: { id: '7', title: 'Old', secret: '************', big: '9007199254740993', stamp: '2024-07-04T13:15:00.123456Z', owner: 'sample:bob', flag: 'No', createDate: '2024-01-01T00:00:00Z' },
|
||||
}
|
||||
|
||||
function renderForm(record?: QRecord) {
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} record={record} /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('Record form regressions (#649)', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('keeps focus while typing so a field does not remount when it becomes dirty', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderForm(stored)
|
||||
const title = screen.getByLabelText(/^Title/)
|
||||
await user.clear(title)
|
||||
await user.type(title, 'Newer title')
|
||||
expect(title).toHaveFocus()
|
||||
expect(title).toHaveValue('Newer title')
|
||||
})
|
||||
|
||||
it('edit never re-sends a masked password, a LONG, an unchanged date-time or read-only values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ records: [{ tableName: 'lab', values: { id: 7 } }] })
|
||||
renderForm(stored)
|
||||
expect(screen.getByLabelText(/^Secret/)).toHaveValue('')
|
||||
expect(screen.getByLabelText(/^Id/)).toBeDisabled()
|
||||
const title = screen.getByLabelText(/^Title/)
|
||||
await user.clear(title)
|
||||
// Record forms leave maxLength to the server's too-long policy.
|
||||
await user.type(title, 'Longer than four')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toHaveBeenCalledTimes(1))
|
||||
const body = put.mock.calls[0][1] as FormData
|
||||
expect(Object.fromEntries(body.entries())).toEqual({ title: 'Longer than four', owner: 'sample:bob', flag: 'false' })
|
||||
})
|
||||
|
||||
it('sends a changed date-time as the UTC instant and a typed LONG exactly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ records: [{ tableName: 'lab', values: { id: 7 } }] })
|
||||
renderForm(stored)
|
||||
fireEvent.change(screen.getByLabelText(/^Big/), { target: { value: '9007199254740993' } })
|
||||
fireEvent.change(screen.getByLabelText(/^Stamp/), { target: { value: '2024-01-15T09:15' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toHaveBeenCalledTimes(1))
|
||||
const body = put.mock.calls[0][1] as FormData
|
||||
expect(body.get('big')).toBe('9007199254740993')
|
||||
expect(body.get('stamp')).toBe(new Date(2024, 0, 15, 9, 15).toISOString().replace(/\.\d{3}Z$/, 'Z'))
|
||||
})
|
||||
|
||||
it('create applies metadata defaults and shows the backend error message once, keeping the values', async () => {
|
||||
const user = userEvent.setup()
|
||||
const error = new AxiosError('Request failed with status code 400', 'ERR_BAD_REQUEST', undefined, undefined,
|
||||
{ status: 400, statusText: '', headers: {}, config: { headers: new AxiosHeaders() }, data: { error: 'Error inserting Lab: Another record already exists with this Title' } })
|
||||
const post = vi.spyOn(apiClient, 'post').mockRejectedValue(error)
|
||||
renderForm()
|
||||
expect(screen.getByRole('checkbox', { name: 'Flag' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.queryByLabelText(/^Id/)).toBeNull()
|
||||
await user.type(screen.getByLabelText(/^Title/), 'Dupe')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
expect((post.mock.calls[0][1] as FormData).get('flag')).toBe('true')
|
||||
expect(await screen.findByText('Error inserting Lab: Another record already exists with this Title')).toBeVisible()
|
||||
expect(screen.getByLabelText(/^Title/)).toHaveValue('Dupe')
|
||||
})
|
||||
it('renders the fields a cron schedule widget section edits, though they sit in a hidden section, and saves them', async () => {
|
||||
const user = userEvent.setup()
|
||||
const scheduled: QTableMetaData = {
|
||||
...table,
|
||||
name: 'schedule', label: 'Schedule',
|
||||
fields: {
|
||||
id: field('id', { type: 'INTEGER', isEditable: false }),
|
||||
subject: field('subject'),
|
||||
cronExpression: field('cronExpression', { label: 'Cron Expression', isRequired: true }),
|
||||
cronTimeZoneId: field('cronTimeZoneId', { label: 'Cron Time Zone Id' }),
|
||||
userId: field('userId', { label: 'User Id' }),
|
||||
},
|
||||
sections: [
|
||||
{ name: 'email', label: 'Email', isHidden: false, fieldNames: ['subject'] },
|
||||
{ name: 'schedule', label: 'Schedule', isHidden: false, fieldNames: [], widgetName: 'cronWidget' },
|
||||
{ name: 'hidden', label: 'Hidden', isHidden: true, fieldNames: ['userId', 'cronExpression', 'cronTimeZoneId'] },
|
||||
],
|
||||
}
|
||||
const widgets = {
|
||||
cronWidget: { name: 'cronWidget', label: 'Schedule', type: 'cronUI', hasPermission: true,
|
||||
defaultValues: { cronExpressionFieldName: 'cronExpression', timeZoneFieldName: 'cronTimeZoneId', includeOnRecordEditScreen: true } },
|
||||
} as unknown as Record<string, QWidgetMetaData>
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'schedule', values: { id: 1 }, displayValues: {} }] })
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={scheduled} widgets={widgets} /></QueryClientProvider>)
|
||||
expect(screen.getByRole('heading', { name: 'Schedule' })).toBeVisible()
|
||||
// only the widget's fields are revealed; the rest of the hidden section stays hidden
|
||||
expect(screen.queryByLabelText(/^User Id/)).toBeNull()
|
||||
await user.type(screen.getByLabelText(/^Cron Expression/), '0 0 9 * * ?')
|
||||
await user.type(screen.getByLabelText(/^Cron Time Zone Id/), 'UTC')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
const body = post.mock.calls[0][1] as FormData
|
||||
expect(body.get('cronExpression')).toBe('0 0 9 * * ?')
|
||||
expect(body.get('cronTimeZoneId')).toBe('UTC')
|
||||
})
|
||||
|
||||
it('keeps a hidden section hidden when its widget is not shown on edit screens', () => {
|
||||
const scheduled: QTableMetaData = {
|
||||
...table,
|
||||
fields: { ...table.fields, cronExpression: field('cronExpression', { label: 'Cron Expression' }) },
|
||||
sections: [
|
||||
...table.sections,
|
||||
{ name: 'schedule', label: 'Schedule', isHidden: false, fieldNames: [], widgetName: 'cronWidget' },
|
||||
{ name: 'hidden', label: 'Hidden', isHidden: true, fieldNames: ['cronExpression'] },
|
||||
],
|
||||
}
|
||||
const widgets = {
|
||||
cronWidget: { name: 'cronWidget', label: 'Schedule', type: 'cronUI', hasPermission: true, defaultValues: { cronExpressionFieldName: 'cronExpression' } },
|
||||
} as unknown as Record<string, QWidgetMetaData>
|
||||
const client = new QueryClient()
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={scheduled} widgets={widgets} /></QueryClientProvider>)
|
||||
expect(screen.queryByLabelText(/^Cron Expression/)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { QRecord } from '@/types'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { EntityForm } from './EntityForm'
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), back: vi.fn() }) }))
|
||||
|
||||
function renderForm(fixedValues: Record<string, string | number | boolean>) {
|
||||
const table = structuredClone(qInstance.tables.company)
|
||||
table.fields = { name: table.fields.name, owner: { ...table.fields.id, name: 'owner', label: 'Owner', isEditable: true } }
|
||||
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['name', 'owner'] }]
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} fixedValues={fixedValues} /></QueryClientProvider>)
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'Child' } })
|
||||
return { table }
|
||||
}
|
||||
|
||||
describe('Explicit fixed relationship submission', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('validates fixed values and merges them last without mutating caller data', async () => {
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 7 } }] })
|
||||
const fixed = { owner: 2 }
|
||||
renderForm(fixed)
|
||||
fireEvent.change(screen.getByLabelText(/Owner/), { target: { value: '99' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalled())
|
||||
expect((post.mock.calls[0][1] as FormData).get('owner')).toBe('2')
|
||||
expect(fixed).toEqual({ owner: 2 })
|
||||
})
|
||||
|
||||
it.each<Record<string, string | number | boolean>>([{ missing: 1 }, { owner: 'not-an-integer' }])('rejects undeclared or mistyped fixed values before HTTP: %j', async (fixed) => {
|
||||
const post = vi.spyOn(apiClient, 'post')
|
||||
renderForm(fixed)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toBeVisible()
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Base copy starts a new record identity', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('clears inherited manual keys on initial load and source reset, then submits a fresh key', async () => {
|
||||
const table = structuredClone(qInstance.tables.company)
|
||||
table.primaryKeyField = 'code'
|
||||
table.fields = { name: table.fields.name, code: { ...table.fields.name, name: 'code', label: 'Code', isRequired: true } }
|
||||
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['code', 'name'] }]
|
||||
const source: QRecord = { tableName: 'company', values: { code: 'old/key', name: 'Original' },
|
||||
associatedRecords: { 'arbitrary children': [{ tableName: 'person', values: { id: 3 } }] } }
|
||||
const nextSource: QRecord = { tableName: 'company', values: { code: 'other/key', name: 'Another' } }
|
||||
const sourceSnapshot = structuredClone(source)
|
||||
const nextSnapshot = structuredClone(nextSource)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { code: 'new/key' } }] })
|
||||
const onSuccess = vi.fn()
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
const view = (record: QRecord) => <QueryClientProvider client={client}>
|
||||
<EntityForm tableMetaData={table} record={record} isCopy onSuccess={onSuccess} />
|
||||
</QueryClientProvider>
|
||||
const { rerender } = render(view(source))
|
||||
expect(screen.getByLabelText(/Code/)).toHaveValue('')
|
||||
expect(screen.getByLabelText(/Name/)).toHaveValue('Original')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByText('Code is required')).toBeVisible()
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByLabelText(/Code/), { target: { value: 'draft/key' } })
|
||||
rerender(view(nextSource))
|
||||
expect(screen.getByLabelText(/Code/)).toHaveValue('')
|
||||
expect(screen.getByLabelText(/Name/)).toHaveValue('Another')
|
||||
fireEvent.change(screen.getByLabelText(/Code/), { target: { value: 'new/key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted.get('code')).toBe('new/key')
|
||||
expect(submitted.get('name')).toBe('Another')
|
||||
expect(submitted.has('associations')).toBe(false)
|
||||
expect(source).toEqual(sourceSnapshot)
|
||||
expect(nextSource).toEqual(nextSnapshot)
|
||||
})
|
||||
|
||||
it.each([undefined, '0'])('omits an untouched optional numeric key but preserves a supplied %s', async (newKey) => {
|
||||
const table = structuredClone(qInstance.tables.company)
|
||||
table.fields = { name: table.fields.name, id: { ...table.fields.id, isEditable: true, isRequired: false } }
|
||||
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['id', 'name'] }]
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 99 } }] })
|
||||
const onSuccess = vi.fn()
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table}
|
||||
record={{ tableName: 'company', values: { id: 42, name: 'Original' } }} isCopy onSuccess={onSuccess} />
|
||||
</QueryClientProvider>)
|
||||
expect(screen.getByLabelText(/ID/)).toHaveValue(null)
|
||||
if (newKey !== undefined) fireEvent.change(screen.getByLabelText(/ID/), { target: { value: newKey } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted.get('id')).toBe(newKey ?? null)
|
||||
})
|
||||
|
||||
it.each([{ encoded: 'AQCA/w==', expected: [1, 0, 128, 255] }, { encoded: '', expected: [] }])('copies native BLOB $encoded bytes as a file with the declared source filename', async ({ encoded, expected }) => {
|
||||
const table = structuredClone(qInstance.tables.company)
|
||||
table.fields = { name: table.fields.name, attachment: { ...table.fields.name, name: 'attachment', label: 'Attachment',
|
||||
type: 'BLOB', isRequired: false, adornments: [{ type: 'FILE_DOWNLOAD', values: { fileNameField: 'fileName', defaultMimeType: 'application/octet-stream' } }] } }
|
||||
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['name', 'attachment'] }]
|
||||
const source: QRecord = { tableName: 'company', values: { id: 1, name: 'Original', attachment: encoded, fileName: 'original.bin' } }
|
||||
const snapshot = structuredClone(source)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 99 } }] })
|
||||
const onSuccess = vi.fn()
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} record={source} isCopy onSuccess={onSuccess} /></QueryClientProvider>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
|
||||
const file = (post.mock.calls[0][1] as FormData).get('attachment')
|
||||
expect(file).toBeInstanceOf(File)
|
||||
if (!(file instanceof File)) throw new Error('Expected binary multipart file')
|
||||
expect(file.name).toBe('original.bin')
|
||||
expect(file.type).toBe('application/octet-stream')
|
||||
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => reader.result instanceof ArrayBuffer ? resolve(reader.result) : reject(new Error('Expected binary bytes'))
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
expect(Array.from(new Uint8Array(bytes))).toEqual(expected)
|
||||
expect(screen.getByText('original.bin')).toBeVisible()
|
||||
expect(source).toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('blocks malformed copied binary values before HTTP with an explicit source error', async () => {
|
||||
const table = structuredClone(qInstance.tables.company)
|
||||
table.fields = { name: table.fields.name, attachment: { ...table.fields.name, name: 'attachment', label: 'Attachment', type: 'BLOB' } }
|
||||
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['name', 'attachment'] }]
|
||||
const post = vi.spyOn(apiClient, 'post')
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table}
|
||||
record={{ tableName: 'company', values: { id: 1, name: 'Original', attachment: 'not base64!' } }} isCopy />
|
||||
</QueryClientProvider>)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Cannot copy Attachment: the source file data is invalid.')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves explicit nullable source values without replacing false, zero or omitted defaults', async () => {
|
||||
const table = structuredClone(qInstance.tables.company)
|
||||
const optional = { ...table.fields.name, isRequired: false }
|
||||
table.fields = {
|
||||
name: table.fields.name,
|
||||
unknownFlag: { ...optional, name: 'unknownFlag', label: 'Unknown flag', type: 'BOOLEAN' },
|
||||
flag: { ...optional, name: 'flag', label: 'Known false', type: 'BOOLEAN', defaultValue: true },
|
||||
note: { ...optional, name: 'note', label: 'Null note', defaultValue: 'Default note' },
|
||||
missing: { ...optional, name: 'missing', label: 'Omitted note', defaultValue: 'Omitted default' },
|
||||
amount: { ...optional, name: 'amount', label: 'Null amount', type: 'DECIMAL', defaultValue: 7 },
|
||||
quantity: { ...optional, name: 'quantity', label: 'Known zero', type: 'INTEGER', defaultValue: 8 },
|
||||
}
|
||||
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: Object.keys(table.fields) }]
|
||||
const source: QRecord = { tableName: 'company', values: { id: 1, name: 'Original', unknownFlag: null, flag: false, note: null, amount: null, quantity: 0 } }
|
||||
const snapshot = structuredClone(source)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 99 } }] })
|
||||
const onSuccess = vi.fn()
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} record={source} isCopy onSuccess={onSuccess} /></QueryClientProvider>)
|
||||
expect(screen.getByRole('checkbox', { name: 'Unknown flag' })).toHaveAttribute('aria-checked', 'mixed')
|
||||
expect(screen.getByRole('checkbox', { name: 'Known false' })).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByLabelText('Null note')).toHaveValue('')
|
||||
expect(screen.getByLabelText('Null amount')).toHaveValue(null)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted.get('unknownFlag')).toBe('')
|
||||
expect(submitted.get('note')).toBe('')
|
||||
expect(submitted.get('amount')).toBe('')
|
||||
expect(submitted.get('flag')).toBe('false')
|
||||
expect(submitted.get('quantity')).toBe('0')
|
||||
expect(submitted.get('missing')).toBe('Omitted default')
|
||||
expect(source).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Copy never submits native password masks', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
it('requires an explicit new password while ordinary strings and revealed passwords copy normally', async () => {
|
||||
const table = structuredClone(qInstance.tables.company)
|
||||
table.fields = { id: table.fields.id, name: table.fields.name,
|
||||
secret: { ...table.fields.name, name: 'secret', label: 'New secret', type: 'PASSWORD', isRequired: false },
|
||||
revealed: { ...table.fields.name, name: 'revealed', label: 'Revealed secret', type: 'PASSWORD', adornments: [{ type: 'REVEAL' }] },
|
||||
}
|
||||
table.sections = []
|
||||
const source = { tableName: 'company', values: { id: 1, name: '********', secret: '********', revealed: 'available' } }
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 2 } }] })
|
||||
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} record={source} isCopy /></QueryClientProvider>)
|
||||
expect(screen.getByLabelText(/New secret/)).toHaveValue('')
|
||||
expect(screen.getByLabelText(/Revealed secret/)).toHaveValue('available')
|
||||
expect(screen.getByLabelText(/^Name/)).toHaveValue('********')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Enter a new value for New secret')
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByLabelText(/New secret/), { target: { value: 'new-explicit-value' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
|
||||
expect((post.mock.calls[0][1] as FormData).get('secret')).toBe('new-explicit-value')
|
||||
expect(source.values.secret).toBe('********')
|
||||
})
|
||||
})
|
||||
@@ -1,72 +1,147 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file EntityForm — full create/edit/copy form for a QQQ record with validation, mutations, and unsaved-changes guard.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// EntityForm — full create/edit form for a record
|
||||
// Wraps DynamicForm with React Hook Form + Zod validation + save/cancel actions
|
||||
// Includes unsaved changes guard for both browser navigation and client-side navigation
|
||||
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Loader2, Save, X } from 'lucide-react'
|
||||
|
||||
import type { QTableMetaData, QRecord } from '@/types'
|
||||
import type { QTableMetaData, QRecord, QRecordInput, QWidgetMetaData } from '@/types'
|
||||
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
|
||||
import { insertRecord, updateRecord } from '@/lib/api/tables'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { zodSchemaFromTableMetadata, defaultValuesFromRecord } from '@/lib/utils/zod-from-metadata'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import {
|
||||
zodSchemaFromTableMetadata, defaultValuesFromRecord, defaultValuesForCopy, defaultValuesForCreate, zodFieldFromMetadata,
|
||||
validateCopyPasswords, wireValuesFromForm,
|
||||
} from '@/lib/utils/zod-from-metadata'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { EDIT_SCREEN_HELP_ROLES, INSERT_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
|
||||
import { toast } from '@/lib/hooks/use-toast'
|
||||
|
||||
import { DynamicForm } from './DynamicForm'
|
||||
import { UnsavedChangesDialog } from './UnsavedChangesDialog'
|
||||
|
||||
/**
|
||||
* Props for the {@link EntityForm} component.
|
||||
*/
|
||||
export interface EntityFormProps {
|
||||
// Required
|
||||
/** Table metadata that drives field rendering, schema generation, and API calls. */
|
||||
tableMetaData: QTableMetaData
|
||||
|
||||
// For edit mode: existing record values
|
||||
/** Existing record values; when provided the form operates in edit mode. */
|
||||
record?: QRecord
|
||||
|
||||
// Control
|
||||
/** When `true`, the enclosing dialog supplies the heading. */
|
||||
isModal?: boolean
|
||||
/** When `true`, copy base values into a new record with a blank key; an editable manual key may be supplied. */
|
||||
isCopy?: boolean
|
||||
/** Full-copy descendants are validated again immediately before the one insert. */
|
||||
copyAssociations?: {
|
||||
getRecords: (rootValues: Record<string, unknown>) => Record<string, QRecordInput[]>
|
||||
validateResult?: (record: QRecord) => void
|
||||
error?: string
|
||||
dirty?: boolean
|
||||
}
|
||||
/** Draft editors rendered inside the single root form, without child form tags. */
|
||||
children?: React.ReactNode
|
||||
/** When `true`, all form inputs are rendered in a disabled, read-only state. */
|
||||
disabled?: boolean
|
||||
|
||||
// Override heading
|
||||
/** Overrides the auto-generated "Create / Edit / Copy {label}" heading. */
|
||||
overrideHeading?: string
|
||||
/** Label for the primary submit button; defaults to `"Save"`. */
|
||||
saveButtonLabel?: string
|
||||
|
||||
// Callbacks
|
||||
/**
|
||||
* Called with the saved record after a successful insert or update.
|
||||
* If not provided, the component auto-navigates to the record detail page
|
||||
* (edit/copy mode) or the table list page (create mode) after save.
|
||||
*/
|
||||
onSuccess?: (record: QRecord) => void
|
||||
/**
|
||||
* Called when the user clicks Cancel.
|
||||
* If not provided, the component auto-navigates back to the record detail page
|
||||
* (edit mode) or the table list page (create/copy mode).
|
||||
* When `isDirty` is `true`, an unsaved-changes confirmation dialog is shown
|
||||
* before any navigation occurs (whether via this callback or the default).
|
||||
*/
|
||||
onCancel?: () => void
|
||||
|
||||
// Default values to pre-populate (overrides record values)
|
||||
/** Default field values that override values derived from `record`. */
|
||||
defaultValues?: Record<string, unknown>
|
||||
|
||||
// Specific fields to show (if not set, all editable fields shown)
|
||||
/** Declared relationship fields merged after ordinary input validation on insert. */
|
||||
fixedValues?: Record<string, string | number | boolean>
|
||||
|
||||
/** Restricts the form to only these fields; when omitted all editable non-hidden fields are shown. */
|
||||
fieldNamesToInclude?: string[]
|
||||
|
||||
// Possible value context
|
||||
/** Context used to fetch possible values (table, process, or standalone). */
|
||||
possibleValueContext?: PossibleValueContext
|
||||
|
||||
/** Widget metadata by name, so widget sections shown on edit screens (the cron schedule) render their fields. */
|
||||
widgets?: Record<string, QWidgetMetaData>
|
||||
|
||||
/** Additional CSS classes applied to the `<form>` element. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Full create/edit/copy form for a QQQ record.
|
||||
*
|
||||
* Wraps {@link DynamicForm} with React Hook Form + Zod schema validation,
|
||||
* TanStack Query mutations (insert/update), a browser-level `beforeunload`
|
||||
* guard, and a client-side unsaved-changes dialog.
|
||||
*
|
||||
* - In **create** mode (`record` is undefined and `isCopy` is false) the form
|
||||
* calls `insertRecord` on submit.
|
||||
* - In **edit** mode (`record` is provided and `isCopy` is false) the form
|
||||
* calls `updateRecord` on submit.
|
||||
* - In **copy** mode (`isCopy` is true) the form pre-fills from `record` but
|
||||
* calls `insertRecord`, producing a new record.
|
||||
*
|
||||
* @param props - See {@link EntityFormProps}.
|
||||
* @returns The rendered form with action buttons and unsaved-changes dialog.
|
||||
*/
|
||||
export function EntityForm({
|
||||
tableMetaData,
|
||||
record,
|
||||
isModal = false,
|
||||
isCopy = false,
|
||||
copyAssociations,
|
||||
children,
|
||||
disabled = false,
|
||||
overrideHeading,
|
||||
saveButtonLabel = 'Save',
|
||||
onSuccess,
|
||||
onCancel,
|
||||
defaultValues: propDefaultValues,
|
||||
fixedValues,
|
||||
fieldNamesToInclude,
|
||||
possibleValueContext,
|
||||
widgets,
|
||||
className,
|
||||
}: EntityFormProps) {
|
||||
const router = useRouter()
|
||||
@@ -79,63 +154,85 @@ export function EntityForm({
|
||||
|
||||
// Build Zod schema from metadata (memoized to avoid expensive recomputation)
|
||||
const schema = useMemo(
|
||||
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude),
|
||||
[tableMetaData, fieldNamesToInclude]
|
||||
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude, isCopy),
|
||||
[tableMetaData, fieldNamesToInclude, isCopy]
|
||||
)
|
||||
|
||||
// Build default values (memoized to keep a stable reference for useForm)
|
||||
const mergedDefaults = useMemo(() => {
|
||||
const computedDefaults: Record<string, unknown> = record
|
||||
? defaultValuesFromRecord(tableMetaData, record.values)
|
||||
: {}
|
||||
return { ...computedDefaults, ...propDefaultValues }
|
||||
}, [tableMetaData, record, propDefaultValues])
|
||||
const { values: mergedDefaults, error: defaultsError } = useMemo(() => {
|
||||
try {
|
||||
const computedDefaults: Record<string, unknown> = record
|
||||
? (isCopy ? defaultValuesForCopy : defaultValuesFromRecord)(tableMetaData, record.values)
|
||||
: defaultValuesForCreate(tableMetaData)
|
||||
return { values: { ...computedDefaults, ...propDefaultValues }, error: null }
|
||||
} catch (error) {
|
||||
return { values: {}, error: error instanceof Error ? error.message : 'Source values are unavailable.' }
|
||||
}
|
||||
}, [tableMetaData, record, propDefaultValues, isCopy])
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
formState: { errors, isDirty, isSubmitting, dirtyFields },
|
||||
reset,
|
||||
} = useForm<Record<string, unknown>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: mergedDefaults,
|
||||
})
|
||||
|
||||
// Latest dirty-field map for the update mutation (values that cannot round-trip are sent only when changed).
|
||||
const dirtyFieldsRef = useRef<Record<string, unknown>>({})
|
||||
dirtyFieldsRef.current = dirtyFields as Record<string, unknown>
|
||||
|
||||
// Reset when record changes (e.g., navigating between records)
|
||||
useEffect(() => {
|
||||
if (record) {
|
||||
reset(defaultValuesFromRecord(tableMetaData, record.values))
|
||||
reset(mergedDefaults)
|
||||
}
|
||||
}, [record, tableMetaData, reset])
|
||||
}, [record, mergedDefaults, reset])
|
||||
|
||||
const hasChanges = isDirty || Boolean(copyAssociations?.dirty)
|
||||
|
||||
// Browser-level navigation guard (tab close, URL change, refresh)
|
||||
// Both e.preventDefault() and e.returnValue are required for cross-browser support:
|
||||
// Chrome/Edge require returnValue to be set, Firefox/Safari rely on preventDefault().
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
if (hasChanges) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [isDirty])
|
||||
}, [hasChanges])
|
||||
|
||||
// Navigation helper that checks for unsaved changes before proceeding
|
||||
/**
|
||||
* Runs a navigation function only after confirming no unsaved changes exist.
|
||||
*
|
||||
* When the form is dirty the unsaved-changes dialog is displayed and the
|
||||
* navigation function is deferred until the user confirms. When the form is
|
||||
* clean the function is called immediately.
|
||||
*
|
||||
* @param navigateFn - The navigation action to perform after the guard passes.
|
||||
*/
|
||||
const guardedNavigate = useCallback(
|
||||
(navigateFn: () => void) => {
|
||||
if (isDirty) {
|
||||
if (hasChanges) {
|
||||
setPendingNavigation(() => navigateFn)
|
||||
setShowUnsavedDialog(true)
|
||||
} else {
|
||||
navigateFn()
|
||||
}
|
||||
},
|
||||
[isDirty]
|
||||
[hasChanges]
|
||||
)
|
||||
|
||||
/**
|
||||
* Confirms navigation away from the form, executing the deferred navigation
|
||||
* function and closing the unsaved-changes dialog.
|
||||
*/
|
||||
const handleConfirmLeave = useCallback(() => {
|
||||
setShowUnsavedDialog(false)
|
||||
if (pendingNavigation) {
|
||||
@@ -144,15 +241,51 @@ export function EntityForm({
|
||||
}
|
||||
}, [pendingNavigation])
|
||||
|
||||
/**
|
||||
* Cancels the pending navigation, keeping the user on the current form and
|
||||
* closing the unsaved-changes dialog.
|
||||
*/
|
||||
const handleCancelLeave = useCallback(() => {
|
||||
setShowUnsavedDialog(false)
|
||||
setPendingNavigation(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Check the table's identifier before reporting success or navigating.
|
||||
* @param savedRecord - The API adapter's validated record.
|
||||
* @returns The record with a usable metadata-defined primary key.
|
||||
*/
|
||||
function validateSavedRecord(savedRecord: QRecord): QRecord {
|
||||
const primaryKey = savedRecord.values[tableMetaData.primaryKeyField]
|
||||
if ((typeof primaryKey !== 'string' || primaryKey.length === 0) &&
|
||||
(typeof primaryKey !== 'number' || !Number.isFinite(primaryKey))) {
|
||||
throw new Error('The server did not return a valid record identifier.')
|
||||
}
|
||||
return savedRecord
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
const insertMutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) =>
|
||||
insertRecord(tableMetaData.name, values),
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const relationshipValues: Record<string, unknown> = {}
|
||||
for (const [name, value] of Object.entries(fixedValues ?? {})) {
|
||||
const field = tableMetaData.fields[name]
|
||||
if (!field) throw new Error(`Relationship field ${name} is unavailable`)
|
||||
relationshipValues[name] = zodFieldFromMetadata(field).parse(value)
|
||||
}
|
||||
const insertValues = { ...values, ...relationshipValues }
|
||||
const key = tableMetaData.primaryKeyField
|
||||
if (isCopy && (insertValues[key] === '' || insertValues[key] == null)) {
|
||||
delete insertValues[key]
|
||||
}
|
||||
if (isCopy) validateCopyPasswords(tableMetaData, insertValues, fieldNamesToInclude)
|
||||
if (copyAssociations?.error) throw new Error(copyAssociations.error)
|
||||
const associations = copyAssociations?.getRecords(insertValues)
|
||||
const saved = validateSavedRecord(await insertRecord(tableMetaData.name, wireValuesFromForm(tableMetaData, insertValues), associations))
|
||||
copyAssociations?.validateResult?.(saved)
|
||||
return saved
|
||||
},
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
onSuccess: (savedRecord) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableMetaData.name) })
|
||||
toast.success(`${tableMetaData.label} created successfully.`)
|
||||
@@ -160,19 +293,26 @@ export function EntityForm({
|
||||
onSuccess(savedRecord)
|
||||
} else {
|
||||
const pk = savedRecord.values[tableMetaData.primaryKeyField]
|
||||
router.push(`/app/${tableMetaData.name}/${pk}`)
|
||||
router.push(`/app/${encodeURIComponent(tableMetaData.name)}/${encodeURIComponent(String(pk))}`)
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Failed to create ${tableMetaData.label}: ${err.message}`)
|
||||
toast.error(`Failed to create ${tableMetaData.label}: ${getErrorMessage(err)}`)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => {
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const pk = record!.values[tableMetaData.primaryKeyField] as string | number
|
||||
return updateRecord(tableMetaData.name, pk, values)
|
||||
// Values that cannot round-trip through the form are sent only when changed: a
|
||||
// masked password, a LONG beyond 2^53, a date-time shown at minute/second precision
|
||||
// and a file (a download URL or bytes). Other values are re-sent as stored, as the
|
||||
// Material dashboard does, so omission never triggers a write default.
|
||||
const submitted = Object.fromEntries(Object.entries(values).filter(([name]) =>
|
||||
Boolean(dirtyFieldsRef.current[name]) || !onlyWhenChanged(tableMetaData.fields[name])))
|
||||
return validateSavedRecord(await updateRecord(tableMetaData.name, pk, wireValuesFromForm(tableMetaData, submitted)))
|
||||
},
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
onSuccess: (savedRecord) => {
|
||||
const pk = savedRecord.values[tableMetaData.primaryKeyField] as string | number
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(tableMetaData.name, pk) })
|
||||
@@ -181,30 +321,69 @@ export function EntityForm({
|
||||
if (onSuccess) {
|
||||
onSuccess(savedRecord)
|
||||
} else {
|
||||
router.push(`/app/${tableMetaData.name}/${pk}`)
|
||||
router.push(`/app/${encodeURIComponent(tableMetaData.name)}/${encodeURIComponent(String(pk))}`)
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Failed to save ${tableMetaData.label}: ${err.message}`)
|
||||
toast.error(`Failed to save ${tableMetaData.label}: ${getErrorMessage(err)}`)
|
||||
},
|
||||
})
|
||||
|
||||
const activeMutation = isEdit ? updateMutation : insertMutation
|
||||
const mutationError = activeMutation.error as Error | null
|
||||
const isSaving = isSubmitting || activeMutation.isPending
|
||||
|
||||
const onSubmit = async (values: Record<string, unknown>) => {
|
||||
await activeMutation.mutateAsync(values)
|
||||
/**
|
||||
* React Hook Form submit handler — delegates to the appropriate mutation
|
||||
* (insert or update) based on the current form mode.
|
||||
*
|
||||
* @param values - The validated form field values.
|
||||
*/
|
||||
const onSubmit = useCallback(
|
||||
(values: Record<string, unknown>) => {
|
||||
activeMutation.mutate(values)
|
||||
},
|
||||
[activeMutation]
|
||||
)
|
||||
|
||||
/**
|
||||
* Preserve the browser's distinction between malformed numeric input and a blank.
|
||||
* @param event - The form submission event.
|
||||
* @returns Metadata validation when native numeric input is readable.
|
||||
*/
|
||||
function onFormSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
if (defaultsError || copyAssociations?.error) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const invalidNumber = Array.from(event.currentTarget.querySelectorAll<HTMLInputElement>('input[type="number"]'))
|
||||
.find((input) => !input.matches(':disabled') && input.validity.badInput)
|
||||
if (invalidNumber) {
|
||||
event.preventDefault()
|
||||
invalidNumber.reportValidity()
|
||||
return
|
||||
}
|
||||
return handleSubmit(onSubmit)(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the Cancel button click.
|
||||
*
|
||||
* Runs the navigation target through {@link guardedNavigate} so unsaved
|
||||
* changes trigger a confirmation dialog. Navigation target priority:
|
||||
* 1. `onCancel` prop callback.
|
||||
* 2. Record detail page (edit mode).
|
||||
* 3. Table list page (create/copy mode).
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
const doCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else if (isEdit && record) {
|
||||
const pk = record.values[tableMetaData.primaryKeyField]
|
||||
router.push(`/app/${tableMetaData.name}/${pk}`)
|
||||
router.push(`/app/${encodeURIComponent(tableMetaData.name)}/${encodeURIComponent(String(pk))}`)
|
||||
} else {
|
||||
router.push(`/app/${tableMetaData.name}`)
|
||||
router.push(`/app/${encodeURIComponent(tableMetaData.name)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +407,7 @@ export function EntityForm({
|
||||
|
||||
const formContent = (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onSubmit={onFormSubmit}
|
||||
noValidate
|
||||
className={cn('flex flex-col gap-6', className)}
|
||||
data-qqq-id={`entity-form-${tableMetaData.name}`}
|
||||
@@ -241,16 +420,19 @@ export function EntityForm({
|
||||
)}
|
||||
|
||||
{/* Mutation error alert */}
|
||||
{mutationError && (
|
||||
{(defaultsError || copyAssociations?.error || mutationError) && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<strong>Error: </strong>
|
||||
{mutationError.message || 'An error occurred while saving.'}
|
||||
{defaultsError || copyAssociations?.error || (mutationError ? getErrorMessage(mutationError, 'An error occurred while saving.') : 'An error occurred while saving.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCopy && Object.values(tableMetaData.fields).some(field => field.type === 'PASSWORD' && field.isEditable && !field.isHidden && !field.adornments?.some(item => item.type === 'REVEAL')) &&
|
||||
<p className="text-sm text-muted-foreground">Unreadable passwords are not copied. Enter new values before saving.</p>}
|
||||
|
||||
{/* Fields */}
|
||||
<DynamicForm
|
||||
register={register}
|
||||
@@ -259,9 +441,17 @@ export function EntityForm({
|
||||
tableMetaData={tableMetaData}
|
||||
fieldNamesToInclude={fieldNamesToInclude}
|
||||
possibleValueContext={pvContext}
|
||||
disabled={disabled || isSubmitting}
|
||||
disabled={disabled || isSaving || Boolean(defaultsError)}
|
||||
dirtyFields={dirtyFields as Record<string, boolean>}
|
||||
record={record}
|
||||
showReadOnlyFields={isEdit}
|
||||
helpRoles={isEdit ? EDIT_SCREEN_HELP_ROLES : INSERT_SCREEN_HELP_ROLES}
|
||||
enforceMaxLength={false}
|
||||
widgets={widgets}
|
||||
/>
|
||||
|
||||
{children && <fieldset disabled={disabled || isSaving} className="min-w-0">{children}</fieldset>}
|
||||
|
||||
{/* Actions — sticky on mobile, static on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -274,7 +464,7 @@ export function EntityForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSaving}
|
||||
data-qqq-id="button-cancel"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md border border-input px-4 py-2 text-sm font-medium',
|
||||
@@ -289,7 +479,7 @@ export function EntityForm({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || isSubmitting || (!isDirty && isEdit)}
|
||||
disabled={disabled || isSaving || Boolean(defaultsError) || Boolean(copyAssociations?.error) || (!isDirty && isEdit)}
|
||||
data-qqq-id="button-save"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
|
||||
@@ -299,12 +489,12 @@ export function EntityForm({
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{isSubmitting ? 'Saving...' : saveButtonLabel}
|
||||
{isSaving ? 'Saving...' : saveButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -314,9 +504,6 @@ export function EntityForm({
|
||||
<>
|
||||
{isModal ? (
|
||||
<div data-qqq-id={`entity-form-modal-${tableMetaData.name}`}>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{heading}</h2>
|
||||
</div>
|
||||
<div className="p-6">{formContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -332,3 +519,16 @@ export function EntityForm({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an edit form submits a field only after the user changed it.
|
||||
*
|
||||
* @param field - Field metadata (undefined for values without metadata).
|
||||
* @returns `true` for passwords without REVEAL, LONG, DATE_TIME and file fields.
|
||||
*/
|
||||
function onlyWhenChanged(field: QTableMetaData['fields'][string] | undefined): boolean {
|
||||
if (!field) return true
|
||||
if (field.type === 'LONG' || field.type === 'DATE_TIME' || field.type === 'BLOB') return true
|
||||
if (field.adornments?.some((adornment) => adornment.type === 'FILE_UPLOAD')) return true
|
||||
return field.type === 'PASSWORD' && !field.adornments?.some((adornment) => adornment.type === 'REVEAL')
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/** @file Editable per-path descendants for one full-copy submission. */
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
|
||||
import type { QRecord } from '@/types'
|
||||
import type { CopyNode } from '@/lib/utils/copy-tree'
|
||||
import { copyTreePayload } from '@/lib/utils/copy-tree'
|
||||
import { zodSchemaFromTableMetadata } from '@/lib/utils/zod-from-metadata'
|
||||
import { DynamicFormField } from './DynamicFormField'
|
||||
import type { EntityFormProps } from './EntityForm'
|
||||
|
||||
type CopyState = NonNullable<EntityFormProps['copyAssociations']>
|
||||
|
||||
/**
|
||||
* Maintain independent descendant drafts while the enclosing EntityForm owns Save.
|
||||
* @param props - Prepared immutable tree and parent readiness callback.
|
||||
* @returns Editors for exact named association paths.
|
||||
*/
|
||||
export function FullCopyDraft({ tree, onChange }: { tree: CopyNode; onChange: (state: CopyState) => void }) {
|
||||
const edits = useRef<Record<string, Record<string, unknown>>>({})
|
||||
const [revision, setRevision] = useState(0)
|
||||
const change = useCallback((path: string, values: Record<string, unknown>) => {
|
||||
edits.current[path] = values
|
||||
setRevision(value => value + 1)
|
||||
}, [])
|
||||
const state = useMemo<CopyState>(() => {
|
||||
let error: string | undefined
|
||||
try { copyTreePayload(tree, edits.current) } catch (failure) { error = failure instanceof Error ? failure.message : 'Full copy values are invalid.' }
|
||||
return {
|
||||
getRecords: rootValues => copyTreePayload(tree, edits.current, rootValues), error, dirty: revision > 0,
|
||||
validateResult: (record: QRecord) => {
|
||||
/**
|
||||
* Require confirmation of every nonempty path after the single insert.
|
||||
* @param node - Requested draft.
|
||||
* @param result - Corresponding saved record.
|
||||
*/
|
||||
function check(node: CopyNode, result: QRecord) {
|
||||
const key = result.values[node.table.primaryKeyField]
|
||||
if (result.tableName !== node.table.name || (typeof key !== 'string' && typeof key !== 'number') || key === '') throw new Error('The server did not confirm every copied record identifier.')
|
||||
for (const [name, children] of Object.entries(node.groups)) {
|
||||
if (!children.length) continue
|
||||
const saved = result.associatedRecords?.[name]
|
||||
if (!saved || saved.length !== children.length) throw new Error('The server did not confirm every copied association.')
|
||||
children.forEach((child, index) => check(child, saved[index]))
|
||||
}
|
||||
}
|
||||
check(tree, record)
|
||||
},
|
||||
}
|
||||
}, [tree, revision])
|
||||
useEffect(() => { onChange(state) }, [onChange, state])
|
||||
return <CopyGroups node={tree} onChange={change} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Render exact group names, preserving known empty groups.
|
||||
* @param props - Node and draft-value callback.
|
||||
* @returns Named group panels.
|
||||
*/
|
||||
function CopyGroups({ node, onChange }: { node: CopyNode; onChange: (path: string, values: Record<string, unknown>) => void }) {
|
||||
return <div className="space-y-4">{Object.entries(node.groups).map(([name, children]) => <section key={name} className="min-w-0 rounded-lg border p-4">
|
||||
<h3 className="font-semibold">{name} <span className="font-normal text-muted-foreground">({children.length})</span></h3>
|
||||
{children.length === 0 ? <p className="text-sm text-muted-foreground">No associated records to copy.</p> : children.map(child => <CopyChild key={child.path} node={child} onChange={onChange} />)}
|
||||
</section>)}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit one node locally; no nested form or independent mutation is created.
|
||||
* @param props - Prepared node and shared draft-value callback.
|
||||
* @returns Metadata-driven fields and nested panels.
|
||||
*/
|
||||
function CopyChild({ node, onChange }: { node: CopyNode; onChange: (path: string, values: Record<string, unknown>) => void }) {
|
||||
const schema = useMemo(() => zodSchemaFromTableMetadata(node.table, node.fields, true), [node])
|
||||
const { register, control, watch, formState: { errors } } = useForm<Record<string, unknown>>({
|
||||
defaultValues: node.values, resolver: zodResolver(schema), mode: 'onChange',
|
||||
})
|
||||
useEffect(() => {
|
||||
const subscription = watch(values => onChange(node.path, values))
|
||||
return () => subscription.unsubscribe()
|
||||
}, [watch, node.path, onChange])
|
||||
const id = `copy-node-${encodeURIComponent(node.path)}`
|
||||
return <fieldset className="my-4 min-w-0 space-y-4 rounded-lg border p-4" data-qqq-id={id}>
|
||||
<legend className="px-2 font-medium">{node.table.label}</legend>
|
||||
<div className="grid gap-4 sm:grid-cols-2">{node.fields.map(name => <DynamicFormField key={name} idPrefix={id}
|
||||
field={node.table.fields[name]} register={register} control={control} errors={errors}
|
||||
possibleValueContext={{ type: 'table', tableName: node.table.name }} />)}</div>
|
||||
<CopyGroups node={node} onChange={onChange} />
|
||||
</fieldset>
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// Tests for PossibleValueSelect component
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
import { PossibleValueSelect } from './PossibleValueSelect'
|
||||
import type { QPossibleValue } from '@/types'
|
||||
|
||||
// Mock the API modules so we control option loading
|
||||
vi.mock('@/lib/api/possible-values', () => ({
|
||||
fetchTablePossibleValues: vi.fn(),
|
||||
fetchProcessPossibleValues: vi.fn(),
|
||||
fetchPossibleValues: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchTablePossibleValues,
|
||||
fetchProcessPossibleValues,
|
||||
fetchPossibleValues,
|
||||
} from '@/lib/api/possible-values'
|
||||
|
||||
const mockFetchTable = vi.mocked(fetchTablePossibleValues)
|
||||
const mockFetchProcess = vi.mocked(fetchProcessPossibleValues)
|
||||
const mockFetchStandalone = vi.mocked(fetchPossibleValues)
|
||||
|
||||
const OPTIONS: QPossibleValue[] = [
|
||||
{ id: 1, label: 'Alice' },
|
||||
{ id: 2, label: 'Bob' },
|
||||
{ id: 3, label: 'Charlie' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Test harness: wraps PossibleValueSelect in a React Hook Form context
|
||||
* so the Controller can attach to a real form.
|
||||
*/
|
||||
function Wrapper({
|
||||
context = { type: 'table' as const, tableName: 'person' },
|
||||
defaultValue,
|
||||
required = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
context?: { type: 'table'; tableName: string } | { type: 'process'; processName: string } | { type: 'standalone' }
|
||||
defaultValue?: unknown
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const { control } = useForm<Record<string, unknown>>({
|
||||
defaultValues: { testField: defaultValue ?? null },
|
||||
})
|
||||
|
||||
return (
|
||||
<PossibleValueSelect
|
||||
id="test-pvs"
|
||||
label="Person"
|
||||
name="testField"
|
||||
control={control}
|
||||
fieldName="person"
|
||||
context={context}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchTable.mockResolvedValue(OPTIONS)
|
||||
mockFetchProcess.mockResolvedValue(OPTIONS)
|
||||
mockFetchStandalone.mockResolvedValue(OPTIONS)
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — initial value label', () => {
|
||||
it('looks up and shows the label of a value it did not select (e.g. after process Back)', async () => {
|
||||
mockFetchProcess.mockResolvedValue([{ id: 2, label: 'Bob' }])
|
||||
render(<Wrapper context={{ type: 'process', processName: 'prcWizard' }} defaultValue={2} />)
|
||||
expect(await screen.findByText('Bob')).toBeInTheDocument()
|
||||
expect(mockFetchProcess).toHaveBeenCalledWith('prcWizard', 'person', { ids: '2' })
|
||||
expect(screen.getByRole('combobox', { name: 'Person' })).not.toHaveTextContent(/^2$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — rendering', () => {
|
||||
it('renders the label', () => {
|
||||
render(<Wrapper />)
|
||||
expect(screen.getByText('Person')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the combobox from its field label', () => {
|
||||
render(<Wrapper />)
|
||||
expect(screen.getByRole('combobox', { name: 'Person' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows default placeholder when no value is selected', () => {
|
||||
render(<Wrapper />)
|
||||
expect(screen.getByText(/-- Select Person --/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows asterisk when required=true', () => {
|
||||
render(<Wrapper required={true} />)
|
||||
// The asterisk span is aria-hidden but still present in DOM
|
||||
const label = screen.getByText('Person').closest('label')
|
||||
expect(label?.textContent).toContain('*')
|
||||
})
|
||||
|
||||
it('does not show asterisk when required=false', () => {
|
||||
render(<Wrapper required={false} />)
|
||||
const label = screen.getByText('Person').closest('label')
|
||||
expect(label?.textContent).not.toContain('*')
|
||||
})
|
||||
|
||||
it('has aria-required on the combobox when required=true', () => {
|
||||
render(<Wrapper required={true} />)
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute('aria-required', 'true')
|
||||
})
|
||||
|
||||
it('combobox is visually dimmed / has opacity style when disabled=true', () => {
|
||||
render(<Wrapper disabled={true} />)
|
||||
const combobox = screen.getByRole('combobox')
|
||||
// The disabled class includes "opacity-50"
|
||||
expect(combobox.className).toContain('opacity-50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — open / close', () => {
|
||||
it('starts closed (dropdown not visible)', () => {
|
||||
render(<Wrapper />)
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens dropdown when combobox trigger is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('sets aria-expanded=true when open', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
it('closes dropdown when clicking outside', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<div>
|
||||
<Wrapper />
|
||||
<div data-testid="outside">Outside</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await waitFor(() => expect(screen.getByRole('listbox')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getByTestId('outside'))
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('does not open when disabled=true', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper disabled={true} />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — loading state', () => {
|
||||
it('shows loading indicator while fetching', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Never resolves — stays loading
|
||||
mockFetchTable.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls fetchTablePossibleValues for table context', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper context={{ type: 'table', tableName: 'person' }} />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTable).toHaveBeenCalledWith('person', 'person', expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
it('calls fetchProcessPossibleValues for process context', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper context={{ type: 'process', processName: 'myProcess' }} />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchProcess).toHaveBeenCalledWith('myProcess', 'person', expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
it('calls fetchPossibleValues for standalone context', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper context={{ type: 'standalone' }} />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchStandalone).toHaveBeenCalledWith('person', expect.any(Object))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — options list', () => {
|
||||
it('renders options as listbox items after loading', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('option')).toHaveLength(OPTIONS.length)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows option labels', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charlie')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "No options found" when the fetch returns empty array', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchTable.mockResolvedValue([])
|
||||
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no options found/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — option selection', () => {
|
||||
it('closes dropdown and shows selected label after clicking an option', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
|
||||
|
||||
await user.click(screen.getByText('Alice'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('marks selected option as aria-selected=true', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
|
||||
|
||||
await user.click(screen.getByText('Bob'))
|
||||
|
||||
// Re-open to check aria-selected
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await waitFor(() => {
|
||||
const bobOption = screen.getByRole('option', { name: /bob/i })
|
||||
expect(bobOption).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a clear button once a value is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
|
||||
await user.click(screen.getByText('Alice'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /clear person/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the selected value when the clear button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
|
||||
await user.click(screen.getByText('Alice'))
|
||||
await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /clear person/i }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /clear person/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/-- Select Person --/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — search', () => {
|
||||
it('renders a search input when the dropdown is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name: /search person options/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('triggers a fetch with the search term after typing in the search input', async () => {
|
||||
// Use real timers — just wait for the 300ms debounce to fire naturally.
|
||||
// This avoids the interaction issues between vi.useFakeTimers and userEvent.
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
await waitFor(() => expect(screen.getByRole('textbox', { name: /search/i })).toBeInTheDocument())
|
||||
|
||||
const searchInput = screen.getByRole('textbox', { name: /search/i })
|
||||
await user.type(searchInput, 'Al')
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
// Should have been called with the search term after the debounce fires
|
||||
expect(mockFetchTable).toHaveBeenCalledWith('person', 'person', { searchTerm: 'Al' })
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — error state', () => {
|
||||
it.each(['success', 'failure'])('keeps newer search results after an older request settles with %s', async (outcome) => {
|
||||
const user = userEvent.setup()
|
||||
let resolveOld!: (values: QPossibleValue[]) => void
|
||||
let rejectOld!: (error: Error) => void
|
||||
const older = new Promise<QPossibleValue[]>((resolve, reject) => { resolveOld = resolve; rejectOld = reject })
|
||||
mockFetchTable.mockReturnValueOnce(older).mockResolvedValueOnce([{ id: 2, label: 'Bob' }])
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('combobox', { name: 'Person' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Search Person options' }), 'Bo')
|
||||
expect(await screen.findByRole('option', { name: 'Bob' })).toBeVisible()
|
||||
await act(async () => {
|
||||
if (outcome === 'success') resolveOld([{ id: 1, label: 'Alice' }])
|
||||
else rejectOld(new Error('Older request failed'))
|
||||
})
|
||||
expect(screen.getByRole('option', { name: 'Bob' })).toBeVisible()
|
||||
expect(screen.queryByRole('option', { name: 'Alice' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(mockFetchTable).toHaveBeenLastCalledWith('person', 'person', { searchTerm: 'Bo' })
|
||||
})
|
||||
|
||||
it('distinguishes a failed request from empty choices and retries on reopen', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetchTable.mockRejectedValueOnce(new Error('404')).mockResolvedValueOnce(OPTIONS)
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('combobox', { name: 'Person' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Options could not be loaded.')
|
||||
expect(screen.queryByText('No options found')).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('combobox', { name: 'Person' }))
|
||||
await user.click(screen.getByRole('combobox', { name: 'Person' }))
|
||||
expect(await screen.findByRole('option', { name: 'Alice' })).toBeVisible()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
it('renders error message when error prop is provided', () => {
|
||||
const { control } = (() => {
|
||||
let capturedControl: ReturnType<typeof useForm<Record<string, unknown>>>['control'] | undefined
|
||||
function Inner() {
|
||||
const form = useForm<Record<string, unknown>>()
|
||||
capturedControl = form.control
|
||||
return null
|
||||
}
|
||||
render(<Inner />)
|
||||
return { control: capturedControl! }
|
||||
})()
|
||||
|
||||
const error = { type: 'required', message: 'This field is required' }
|
||||
|
||||
render(
|
||||
<PossibleValueSelect
|
||||
id="err-pvs"
|
||||
label="Person"
|
||||
name="testField"
|
||||
control={control}
|
||||
fieldName="person"
|
||||
context={{ type: 'table', tableName: 'person' }}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('This field is required')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets aria-invalid on combobox when error is present', () => {
|
||||
const { control } = (() => {
|
||||
let capturedControl: ReturnType<typeof useForm<Record<string, unknown>>>['control'] | undefined
|
||||
function Inner() {
|
||||
const form = useForm<Record<string, unknown>>()
|
||||
capturedControl = form.control
|
||||
return null
|
||||
}
|
||||
render(<Inner />)
|
||||
return { control: capturedControl! }
|
||||
})()
|
||||
|
||||
const error = { type: 'required', message: 'Required' }
|
||||
|
||||
render(
|
||||
<PossibleValueSelect
|
||||
id="err-pvs2"
|
||||
label="Person"
|
||||
name="testField"
|
||||
control={control}
|
||||
fieldName="person"
|
||||
context={{ type: 'table', tableName: 'person' }}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,28 @@
|
||||
'use client'
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// PossibleValueSelect — async combobox for fields with possibleValueSourceName
|
||||
// Debounced search hits the backend possible values endpoint
|
||||
/**
|
||||
* @file PossibleValueSelect — debounced async combobox for fields with a possibleValueSourceName.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import type { Control, FieldError } from 'react-hook-form'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { Controller, useWatch } from 'react-hook-form'
|
||||
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
@@ -17,26 +34,62 @@ import {
|
||||
fetchPossibleValues,
|
||||
} from '@/lib/api/possible-values'
|
||||
|
||||
/**
|
||||
* Props for the {@link PossibleValueSelect} component.
|
||||
*/
|
||||
interface PossibleValueSelectProps {
|
||||
/** The HTML `id` for the combobox trigger element and its associated label. */
|
||||
id: string
|
||||
/** Human-readable field label displayed above the combobox. */
|
||||
label: string
|
||||
/** The React Hook Form field name used by the `Controller`. */
|
||||
name: string
|
||||
/** React Hook Form control object from the parent `useForm` instance. */
|
||||
control: Control<Record<string, unknown>>
|
||||
/** The QQQ field name sent to the possible-values API as the field identifier. */
|
||||
fieldName: string
|
||||
/** Determines which possible-values endpoint is called (table, process, or standalone). */
|
||||
context: PossibleValueContext
|
||||
/** Validation error from React Hook Form; triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the combobox trigger is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
|
||||
required?: boolean
|
||||
/** Placeholder text shown when no option is selected; defaults to `"-- Select {label} --"`. */
|
||||
placeholder?: string
|
||||
/** `data-qqq-id` attribute forwarded to the combobox trigger for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
/** Possible-value source name, used for standalone (`/possibleValues/{source}`) lookups. */
|
||||
possibleValueSourceName?: string
|
||||
/** Label of the value the field already holds (e.g. the record's display value). */
|
||||
initialLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Async combobox for QQQ fields that reference a possible-value source.
|
||||
*
|
||||
* On open, fetches an initial list of options from the backend. As the user
|
||||
* types in the search input, additional fetches are debounced by 300 ms to
|
||||
* reduce API traffic during fast typing. The dropdown closes on an outside
|
||||
* `mousedown` event (via a document-level listener) or when an option is
|
||||
* selected. Integrates with React Hook Form via `Controller` — the stored
|
||||
* form value is `option.id` (not the display label); the label is only used
|
||||
* for rendering the selected state in the trigger button.
|
||||
*
|
||||
* @param props - See {@link PossibleValueSelectProps}.
|
||||
* @returns The rendered labeled combobox with a searchable dropdown listbox.
|
||||
* Shows a loading spinner while options are being fetched and a "No options
|
||||
* found" message when the search returns an empty result set.
|
||||
*/
|
||||
export function PossibleValueSelect({
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
control,
|
||||
fieldName,
|
||||
possibleValueSourceName,
|
||||
initialLabel,
|
||||
context,
|
||||
error,
|
||||
disabled = false,
|
||||
@@ -48,14 +101,27 @@ export function PossibleValueSelect({
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [options, setOptions] = useState<QPossibleValue[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [selectedOption, setSelectedOption] = useState<QPossibleValue | null>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const requestSequence = useRef(0)
|
||||
|
||||
/**
|
||||
* Fetches possible values for the given search term from the appropriate
|
||||
* backend endpoint based on `context.type`.
|
||||
*
|
||||
* @param term - The search string typed by the user; an empty string returns
|
||||
* the default/initial set of options.
|
||||
*/
|
||||
const fetchOptions = useCallback(
|
||||
async (term: string) => {
|
||||
const sequence = ++requestSequence.current
|
||||
setIsLoading(true)
|
||||
setLoadFailed(false)
|
||||
try {
|
||||
const request = { searchTerm: term || undefined }
|
||||
let results: QPossibleValue[]
|
||||
@@ -64,18 +130,27 @@ export function PossibleValueSelect({
|
||||
} else if (context.type === 'process') {
|
||||
results = await fetchProcessPossibleValues(context.processName, fieldName, request)
|
||||
} else {
|
||||
results = await fetchPossibleValues(fieldName, request)
|
||||
results = await fetchPossibleValues(possibleValueSourceName ?? fieldName, request)
|
||||
}
|
||||
setOptions(results)
|
||||
if (sequence === requestSequence.current) setOptions(results)
|
||||
} catch {
|
||||
setOptions([])
|
||||
if (sequence === requestSequence.current) {
|
||||
setOptions([])
|
||||
setLoadFailed(true)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (sequence === requestSequence.current) setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[context, fieldName]
|
||||
[context, fieldName, possibleValueSourceName]
|
||||
)
|
||||
|
||||
/**
|
||||
* Wraps {@link fetchOptions} in a 300 ms debounce, cancelling any pending
|
||||
* timer before scheduling a new one.
|
||||
*
|
||||
* @param term - The search string to debounce.
|
||||
*/
|
||||
const debouncedFetch = useCallback(
|
||||
(term: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
@@ -90,6 +165,26 @@ export function PossibleValueSelect({
|
||||
}
|
||||
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A value set without its label (e.g. a create-form default) is looked up by id.
|
||||
const watchedValue = useWatch({ control, name })
|
||||
const contextKey = JSON.stringify(context)
|
||||
useEffect(() => {
|
||||
const held = watchedValue === null || watchedValue === undefined || watchedValue === '' ? '' : String(watchedValue)
|
||||
if (!held || initialLabel || (selectedOption && String(selectedOption.id) === held)) return
|
||||
let cancelled = false
|
||||
const request = { ids: held }
|
||||
const lookup = context.type === 'table'
|
||||
? fetchTablePossibleValues(context.tableName, fieldName, request)
|
||||
: context.type === 'process'
|
||||
? fetchProcessPossibleValues(context.processName, fieldName, request)
|
||||
: fetchPossibleValues(possibleValueSourceName ?? fieldName, request)
|
||||
lookup.then((results) => {
|
||||
const match = results.find((option) => String(option.id) === held)
|
||||
if (!cancelled && match) setSelectedOption(match)
|
||||
}).catch(() => { /* the raw value stays visible */ })
|
||||
return () => { cancelled = true }
|
||||
}, [watchedValue, initialLabel, selectedOption, contextKey, fieldName, possibleValueSourceName]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -101,6 +196,13 @@ export function PossibleValueSelect({
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles changes to the search input inside the open dropdown.
|
||||
*
|
||||
* Updates the local search term state and triggers a debounced fetch.
|
||||
*
|
||||
* @param e - The change event from the search `<input>`.
|
||||
*/
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const term = e.target.value
|
||||
setSearchTerm(term)
|
||||
@@ -110,6 +212,7 @@ export function PossibleValueSelect({
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
id={`${id}-label`}
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
@@ -127,6 +230,41 @@ export function PossibleValueSelect({
|
||||
setSelectedOption(option)
|
||||
setIsOpen(false)
|
||||
setSearchTerm('')
|
||||
setActiveIndex(-1)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
if (disabled) return
|
||||
setIsOpen(true)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (disabled) return
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setActiveIndex((index) => Math.min(index + 1, options.length - 1))
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setActiveIndex((index) => Math.max(index - 1, 0))
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const option = options[activeIndex] ?? (options.length === 1 ? options[0] : undefined)
|
||||
if (option) handleSelect(option)
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setIsOpen(false)
|
||||
setActiveIndex(-1)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
@@ -136,13 +274,23 @@ export function PossibleValueSelect({
|
||||
setSearchTerm('')
|
||||
}
|
||||
|
||||
const displayValue = selectedOption?.label ?? (field.value ? String(field.value) : '')
|
||||
// The held value's label: the chosen option, else the record's display value
|
||||
// for an unchanged value, else the raw id as a last resort.
|
||||
const heldValue = field.value === null || field.value === undefined || field.value === '' ? '' : String(field.value)
|
||||
const displayValue = selectedOption && String(selectedOption.id) === heldValue
|
||||
? selectedOption.label
|
||||
: heldValue ? (initialLabel ?? heldValue) : ''
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
ref={triggerRef}
|
||||
id={id}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
aria-disabled={disabled || undefined}
|
||||
role="combobox"
|
||||
aria-labelledby={`${id}-label`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-required={required}
|
||||
@@ -152,10 +300,8 @@ export function PossibleValueSelect({
|
||||
data-qqq-id={dataQqqId}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setIsOpen((o) => !o)
|
||||
if (!isOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
if (isOpen) setIsOpen(false)
|
||||
else open()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -211,9 +357,12 @@ export function PossibleValueSelect({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
onChange={(event) => { setActiveIndex(-1); handleSearchChange(event) }}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search..."
|
||||
aria-label={`Search ${label} options`}
|
||||
aria-controls={`${id}-listbox`}
|
||||
aria-activedescendant={activeIndex >= 0 && options[activeIndex] ? `${id}-option-${activeIndex}` : undefined}
|
||||
className={cn(
|
||||
'w-full rounded border border-input px-2 py-1 text-sm',
|
||||
'bg-muted text-foreground',
|
||||
@@ -233,23 +382,29 @@ export function PossibleValueSelect({
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading...
|
||||
</li>
|
||||
) : loadFailed ? (
|
||||
<li role="alert" className="py-4 text-center text-sm text-destructive">
|
||||
Options could not be loaded.
|
||||
</li>
|
||||
) : options.length === 0 ? (
|
||||
<li className="py-4 text-center text-sm text-muted-foreground">
|
||||
No options found
|
||||
</li>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
options.map((option, index) => {
|
||||
const isSelected = String(field.value) === String(option.id)
|
||||
return (
|
||||
<li
|
||||
key={String(option.id)}
|
||||
id={`${id}-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-between px-3 py-2 text-sm',
|
||||
'hover:bg-accent',
|
||||
isSelected && 'bg-accent text-primary'
|
||||
isSelected && 'bg-accent text-primary',
|
||||
index === activeIndex && 'ring-2 ring-inset ring-ring'
|
||||
)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// Rich-text editor: engine-neutral values without disturbing the user's editing (#649)
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RichTextField } from './RichTextField'
|
||||
|
||||
function Harness({ onValue }: { onValue: (value: string) => void }) {
|
||||
const [value, setValue] = useState('')
|
||||
return <RichTextField aria-label="Html Value" value={value} onChange={(next) => { setValue(next); onValue(next) }} />
|
||||
}
|
||||
|
||||
describe('RichTextField', () => {
|
||||
it('reports editing-only non-breaking spaces as plain spaces and leaves the editor DOM alone', () => {
|
||||
const onValue = vi.fn()
|
||||
render(<Harness onValue={onValue} />)
|
||||
const editor = screen.getByRole('textbox', { name: 'Html Value' })
|
||||
// what Firefox leaves after typing "Plain ", Bold, "Strong"
|
||||
editor.innerHTML = 'Plain <b>Strong</b>'
|
||||
const bold = editor.querySelector('b')
|
||||
fireEvent.input(editor)
|
||||
expect(onValue).toHaveBeenLastCalledWith('Plain <b>Strong</b>')
|
||||
// the editor keeps its own nodes (rewriting them would move the caret)
|
||||
expect(editor.querySelector('b')).toBe(bold)
|
||||
expect(editor.innerHTML).toBe('Plain <b>Strong</b>')
|
||||
})
|
||||
|
||||
it('still writes values that change from outside into the editor', () => {
|
||||
const { rerender } = render(<RichTextField aria-label="Html Value" value="<i>first</i>" onChange={vi.fn()} />)
|
||||
const editor = screen.getByRole('textbox', { name: 'Html Value' })
|
||||
expect(editor.innerHTML).toBe('<i>first</i>')
|
||||
rerender(<RichTextField aria-label="Html Value" value="second" onChange={vi.fn()} />)
|
||||
expect(editor.innerHTML).toBe('second')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RichTextField — a controlled textarea with a basic HTML formatting toolbar.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Bold, Italic, Underline, Link } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { normalizeEditorHtml } from '@/lib/utils/editor-html'
|
||||
|
||||
/**
|
||||
* Props for the {@link RichTextField} component.
|
||||
*/
|
||||
export interface RichTextFieldProps {
|
||||
/** Current HTML string value of the field. */
|
||||
value: string
|
||||
/** Callback invoked with the updated HTML string whenever the content changes. */
|
||||
onChange: (value: string) => void
|
||||
/** When true, the editor and all toolbar buttons are non-interactive. */
|
||||
disabled?: boolean
|
||||
/** HTML `id` applied to the contentEditable region for label association. */
|
||||
id?: string
|
||||
/** Accessible label for the content-editable region. */
|
||||
'aria-label'?: string
|
||||
/** Whether a value is required. */
|
||||
required?: boolean
|
||||
/** CSS class names appended to the outermost wrapper div. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Labels and helpers for each toolbar formatting action. */
|
||||
const TOOLBAR_ACTIONS = [
|
||||
{ command: 'bold', icon: Bold, label: 'Bold' },
|
||||
{ command: 'italic', icon: Italic, label: 'Italic' },
|
||||
{ command: 'underline', icon: Underline, label: 'Underline' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* A controlled rich-text editor built on a `contentEditable` `<div>`.
|
||||
*
|
||||
* Provides a lightweight formatting toolbar (Bold, Italic, Underline, Link)
|
||||
* that uses `document.execCommand` to apply inline HTML formatting. The
|
||||
* component remains self-contained — no third-party WYSIWYG library is needed.
|
||||
*
|
||||
* The `value` / `onChange` interface mirrors a controlled `<textarea>` so the
|
||||
* parent form can treat this field identically to any other controlled input.
|
||||
*
|
||||
* @param props - See {@link RichTextFieldProps}.
|
||||
* @returns The rendered rich text editor with formatting toolbar.
|
||||
*/
|
||||
export function RichTextField({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
id,
|
||||
'aria-label': ariaLabel,
|
||||
required = false,
|
||||
className,
|
||||
}: RichTextFieldProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
// The editor HTML last reported and the normalized value it was reported as
|
||||
const emitted = useRef<{ html: string; value: string } | null>(null)
|
||||
|
||||
// Write the value into the editor only when it changed from outside (initial load,
|
||||
// form reset). Re-writing the user's own input would move the caret to the start
|
||||
// on every keystroke. Values are sanitized to prevent stored XSS.
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
if (emitted.current && value === emitted.current.value && editor.innerHTML === emitted.current.html) return
|
||||
if (editor.innerHTML !== value) editor.innerHTML = DOMPurify.sanitize(value)
|
||||
}, [value])
|
||||
|
||||
/**
|
||||
* Reports the editor content, normalized so the stored HTML does not depend on the
|
||||
* browser engine (see {@link normalizeEditorHtml}).
|
||||
*/
|
||||
const emitChange = () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
const html = editor.innerHTML
|
||||
const next = normalizeEditorHtml(html)
|
||||
emitted.current = { html, value: next }
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a `document.execCommand` formatting instruction to the current
|
||||
* selection inside the contentEditable editor.
|
||||
*
|
||||
* @param command - The execCommand command string (e.g. 'bold', 'italic').
|
||||
*/
|
||||
const applyFormat = (command: string) => {
|
||||
if (disabled) return
|
||||
editorRef.current?.focus()
|
||||
document.execCommand(command, false)
|
||||
// Sync updated inner HTML back to the parent form state
|
||||
emitChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts for a URL and wraps the current selection in an anchor tag.
|
||||
*/
|
||||
const applyLink = () => {
|
||||
if (disabled) return
|
||||
const url = window.prompt('Enter URL:')
|
||||
if (!url) return
|
||||
editorRef.current?.focus()
|
||||
document.execCommand('createLink', false, url)
|
||||
emitChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the contentEditable HTML to React state on every input event.
|
||||
*/
|
||||
const handleInput = () => {
|
||||
emitChange()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col gap-0', className)}
|
||||
data-qqq-id="rich-text-field"
|
||||
>
|
||||
{/* Formatting toolbar */}
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Text formatting"
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 rounded-t-md border border-b-0 border-input bg-muted/50 px-2 py-1',
|
||||
disabled ? 'opacity-50 pointer-events-none' : ''
|
||||
)}
|
||||
data-qqq-id="rich-text-toolbar"
|
||||
>
|
||||
{TOOLBAR_ACTIONS.map(({ command, icon: Icon, label }) => (
|
||||
<button
|
||||
key={command}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent the editor from losing focus
|
||||
e.preventDefault()
|
||||
applyFormat(command)
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded text-sm',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
'focus:outline-none focus:ring-1 focus:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-100'
|
||||
)}
|
||||
data-qqq-id={`rich-text-${command}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-1 h-4 w-px bg-border" aria-hidden="true" />
|
||||
|
||||
{/* Link */}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
applyLink()
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Insert link"
|
||||
title="Insert link"
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded text-sm',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
'focus:outline-none focus:ring-1 focus:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-100'
|
||||
)}
|
||||
data-qqq-id="rich-text-link"
|
||||
>
|
||||
<Link className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content-editable editor */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
id={id}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-required={required}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning
|
||||
onInput={handleInput}
|
||||
onBlur={handleInput}
|
||||
className={cn(
|
||||
'min-h-[120px] w-full rounded-b-md border border-input bg-background px-3 py-2',
|
||||
'text-sm text-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring',
|
||||
'disabled:cursor-not-allowed',
|
||||
disabled ? 'bg-muted text-muted-foreground cursor-not-allowed' : '',
|
||||
'[&_a]:text-primary [&_a]:underline',
|
||||
'prose prose-sm max-w-none'
|
||||
)}
|
||||
data-qqq-id="rich-text-editor"
|
||||
/>
|
||||
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
HTML formatting is supported. Use the toolbar or type HTML directly.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// Tests for the ScriptEditor component
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
|
||||
import { ScriptEditor } from './ScriptEditor'
|
||||
|
||||
describe('ScriptEditor — rendering', () => {
|
||||
it('renders the label text', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="test-script"
|
||||
label="My Script"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('My Script')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a textarea element', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="test-script"
|
||||
label="My Script"
|
||||
value="console.log('hello')"
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue("console.log('hello')")
|
||||
})
|
||||
|
||||
it('associates the label with the textarea via htmlFor/id', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="my-editor"
|
||||
label="Editor Label"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const textarea = screen.getByLabelText('Editor Label')
|
||||
expect(textarea.tagName).toBe('TEXTAREA')
|
||||
})
|
||||
|
||||
it('renders the language badge when language prop is provided', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="groovy-script"
|
||||
label="Groovy Script"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
language="groovy"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Groovy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders "Text" badge when language is not specified', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="text-script"
|
||||
label="Script"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the line count in the toolbar', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="multi-line"
|
||||
label="Script"
|
||||
value={'line1\nline2\nline3'}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/3 lines/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets data-qqq-id on the wrapper', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="my-script"
|
||||
label="Script"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
document.querySelector('[data-qqq-id="script-editor-my-script"]')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScriptEditor — onChange', () => {
|
||||
it('calls onChange when the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="typing-test"
|
||||
label="Script"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'hello')
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScriptEditor — Tab key', () => {
|
||||
it('inserts two spaces when Tab is pressed instead of moving focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="tab-test"
|
||||
label="Script"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.click(textarea)
|
||||
await user.keyboard('{Tab}')
|
||||
|
||||
// onChange should have been called with two spaces
|
||||
const calls = onChange.mock.calls
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
// The last call should include two spaces
|
||||
const lastCallValue = calls[calls.length - 1][0] as string
|
||||
expect(lastCallValue).toBe(' ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScriptEditor — readOnly', () => {
|
||||
it('renders the textarea as readonly when readOnly=true', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="readonly-test"
|
||||
label="Script"
|
||||
value="const x = 1"
|
||||
onChange={vi.fn()}
|
||||
readOnly={true}
|
||||
/>
|
||||
)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('does not render readonly attribute when readOnly=false', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="editable-test"
|
||||
label="Script"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
readOnly={false}
|
||||
/>
|
||||
)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).not.toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScriptEditor — error display', () => {
|
||||
it('shows the error message when error prop is provided', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="error-test"
|
||||
label="Script"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
error={{ message: 'Script is required' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Script is required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show an error message when error is undefined', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="no-error-test"
|
||||
label="Script"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets aria-invalid on the textarea when error is present', () => {
|
||||
render(
|
||||
<ScriptEditor
|
||||
id="aria-invalid-test"
|
||||
label="Script"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
error={{ message: 'Required' }}
|
||||
/>
|
||||
)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file ScriptEditor — a styled textarea for editing QQQ automation scripts.
|
||||
*
|
||||
* Provides a monospace editor with:
|
||||
* - Language badge and line-count toolbar
|
||||
* - Tab-key interception (inserts 2 spaces instead of changing focus)
|
||||
* - Read-only mode with distinct styling
|
||||
* - Inline error message display
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link ScriptEditor} component.
|
||||
*/
|
||||
export interface ScriptEditorProps {
|
||||
/** Unique identifier used for the `<label>` / `<textarea>` association and `data-qqq-id`. */
|
||||
id: string
|
||||
/** Human-readable label displayed above the editor. */
|
||||
label: string
|
||||
/** Current script content value (controlled). */
|
||||
value: string
|
||||
/**
|
||||
* Called whenever the editor content changes.
|
||||
*
|
||||
* @param value - The updated script content.
|
||||
*/
|
||||
onChange: (value: string) => void
|
||||
/** Programming language used for the badge display. Defaults to `'text'`. */
|
||||
language?: 'javascript' | 'groovy' | 'python' | 'sql' | 'json' | 'text'
|
||||
/** When `true`, the textarea is rendered as read-only with muted styling. */
|
||||
readOnly?: boolean
|
||||
/**
|
||||
* Validation error to display below the editor.
|
||||
* Compatible with React Hook Form's `FieldError` shape.
|
||||
*/
|
||||
error?: { message?: string }
|
||||
/** Number of visible text rows for the textarea. Defaults to 12. */
|
||||
rows?: number
|
||||
}
|
||||
|
||||
/** Human-readable display names for each supported language. */
|
||||
const LANGUAGE_LABELS: Record<NonNullable<ScriptEditorProps['language']>, string> = {
|
||||
javascript: 'JavaScript',
|
||||
groovy: 'Groovy',
|
||||
python: 'Python',
|
||||
sql: 'SQL',
|
||||
json: 'JSON',
|
||||
text: 'Text',
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of lines in the given string.
|
||||
*
|
||||
* @param value - The text content to count lines in.
|
||||
* @returns The number of lines (minimum 1).
|
||||
*/
|
||||
function countLines(value: string): number {
|
||||
if (!value) return 1
|
||||
return value.split('\n').length
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a styled script editor textarea with toolbar, language badge, and
|
||||
* optional error display.
|
||||
*
|
||||
* Intercepts the Tab key to insert two spaces instead of moving focus, which
|
||||
* is the standard behavior users expect in code editors.
|
||||
*
|
||||
* @param props - {@link ScriptEditorProps}
|
||||
* @returns The rendered script editor with toolbar and optional error message.
|
||||
*/
|
||||
export function ScriptEditor({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
language = 'text',
|
||||
readOnly = false,
|
||||
error,
|
||||
rows = 12,
|
||||
}: ScriptEditorProps) {
|
||||
const lineCount = countLines(value)
|
||||
const languageLabel = LANGUAGE_LABELS[language]
|
||||
const hasError = Boolean(error?.message)
|
||||
|
||||
/**
|
||||
* Handles keydown events on the textarea.
|
||||
*
|
||||
* Intercepts Tab to insert two spaces at the cursor position instead of
|
||||
* shifting focus away from the editor.
|
||||
*
|
||||
* @param e - The keyboard event from the textarea.
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const textarea = e.currentTarget
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const newValue = value.substring(0, start) + ' ' + value.substring(end)
|
||||
onChange(newValue)
|
||||
// Restore cursor position after the inserted spaces
|
||||
// Use setTimeout so the DOM has time to update before repositioning
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = start + 2
|
||||
textarea.selectionEnd = start + 2
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
[value, onChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-1"
|
||||
data-qqq-id={`script-editor-${id}`}
|
||||
>
|
||||
{/* Label */}
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
|
||||
{/* Editor container */}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border',
|
||||
hasError ? 'border-destructive' : 'border-input',
|
||||
readOnly ? 'opacity-75' : ''
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-1',
|
||||
'border-b border-input rounded-t-lg',
|
||||
'bg-background text-xs text-muted-foreground'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Language badge */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-xs font-medium',
|
||||
'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{languageLabel}
|
||||
</span>
|
||||
|
||||
{/* Right side: line count + keyboard hint */}
|
||||
<div className="flex items-center gap-3">
|
||||
{!readOnly && (
|
||||
<span className="text-muted-foreground/70 hidden sm:inline">
|
||||
Tab inserts spaces
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{lineCount} {lineCount === 1 ? 'line' : 'lines'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
readOnly={readOnly}
|
||||
rows={rows}
|
||||
aria-label={label}
|
||||
aria-required={false}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={hasError ? `${id}-error` : undefined}
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
'font-mono text-sm w-full resize-y p-3',
|
||||
'bg-muted rounded-b-lg',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0',
|
||||
'placeholder:text-muted-foreground/50',
|
||||
readOnly
|
||||
? 'cursor-default text-muted-foreground'
|
||||
: 'text-foreground',
|
||||
'border-0' // border is on the container
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && (
|
||||
<p
|
||||
id={`${id}-error`}
|
||||
role="alert"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
{error!.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// Tests for UnsavedChangesDialog component
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
|
||||
import { UnsavedChangesDialog } from './UnsavedChangesDialog'
|
||||
|
||||
describe('UnsavedChangesDialog — visibility', () => {
|
||||
it('renders nothing when open=false', () => {
|
||||
const { container } = render(
|
||||
<UnsavedChangesDialog open={false} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the dialog when open=true', () => {
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
// The dialog renders two nodes with this text: an sr-only Radix title and the visible h2.
|
||||
// Use getAllByText and assert at least one is present.
|
||||
expect(screen.getAllByText('Unsaved Changes').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UnsavedChangesDialog — content', () => {
|
||||
it('displays the warning message body', () => {
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
expect(
|
||||
screen.getByText(/you have unsaved changes/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows both "Stay" and "Leave" buttons', () => {
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /^stay$/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^leave$/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has data-qqq-id="unsaved-changes-dialog" on the content element', () => {
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
expect(document.querySelector('[data-qqq-id="unsaved-changes-dialog"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UnsavedChangesDialog — callbacks', () => {
|
||||
it('calls onStay when "Stay" button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onStay = vi.fn()
|
||||
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={onStay} onLeave={vi.fn()} />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^stay$/i }))
|
||||
expect(onStay).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onLeave when "Leave" button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onLeave = vi.fn()
|
||||
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={onLeave} />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^leave$/i }))
|
||||
expect(onLeave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call onLeave when "Stay" is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onLeave = vi.fn()
|
||||
const onStay = vi.fn()
|
||||
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={onStay} onLeave={onLeave} />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^stay$/i }))
|
||||
expect(onLeave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onStay when "Leave" is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onStay = vi.fn()
|
||||
const onLeave = vi.fn()
|
||||
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={onStay} onLeave={onLeave} />
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^leave$/i }))
|
||||
expect(onStay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UnsavedChangesDialog — accessibility', () => {
|
||||
it('has an accessible title via sr-only element', () => {
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
// The Radix AlertDialog.Title is sr-only but still in the DOM
|
||||
expect(screen.getAllByText('Unsaved Changes').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('has the description paragraph with its id for aria-describedby', () => {
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
const desc = document.getElementById('unsaved-changes-description')
|
||||
expect(desc).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has data-qqq-id on Stay button', () => {
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
expect(document.querySelector('[data-qqq-id="unsaved-changes-stay"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has data-qqq-id on Leave button', () => {
|
||||
render(
|
||||
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
|
||||
)
|
||||
expect(document.querySelector('[data-qqq-id="unsaved-changes-leave"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file UnsavedChangesDialog — accessible confirmation modal preventing accidental loss of unsaved form data.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// UnsavedChangesDialog — confirmation dialog shown when navigating away with unsaved form changes
|
||||
@@ -9,12 +29,29 @@ import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link UnsavedChangesDialog} component.
|
||||
*/
|
||||
interface UnsavedChangesDialogProps {
|
||||
/** Controls dialog visibility; when `false` the component renders nothing. */
|
||||
open: boolean
|
||||
/** Called when the user clicks "Stay" or dismisses the dialog (Escape / overlay click). */
|
||||
onStay: () => void
|
||||
/** Called when the user clicks "Leave", confirming that unsaved changes can be discarded. */
|
||||
onLeave: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible confirmation dialog that warns users before they navigate away
|
||||
* from a form with unsaved changes.
|
||||
*
|
||||
* Built on Radix `AlertDialog` primitives which handle focus trapping,
|
||||
* `aria-modal`, and initial focus automatically. Pressing Escape or clicking
|
||||
* the overlay calls `onStay`, keeping the user on the current page.
|
||||
*
|
||||
* @param props - See {@link UnsavedChangesDialogProps}.
|
||||
* @returns The rendered confirmation dialog, or null when `open` is false.
|
||||
*/
|
||||
export function UnsavedChangesDialog({ open, onStay, onLeave }: UnsavedChangesDialogProps) {
|
||||
// Radix AlertDialog manages focus automatically (focus trap + initial focus).
|
||||
// Manual focus management via useEffect/ref.focus() races with Radix's
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file BooleanField — three-state toggle switch for boolean form fields integrated with React Hook Form.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// BooleanField — toggle switch for boolean form fields
|
||||
@@ -11,20 +31,37 @@ import { Minus } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link BooleanField} component.
|
||||
*/
|
||||
interface BooleanFieldProps {
|
||||
/** The HTML `id` for the toggle button and its associated label. */
|
||||
id: string
|
||||
/** Human-readable field label rendered next to the toggle. */
|
||||
label: string
|
||||
/** The React Hook Form field name used by the `Controller`. */
|
||||
name: string
|
||||
/** React Hook Form control object from the parent `useForm` instance. */
|
||||
control: Control<Record<string, unknown>>
|
||||
/** Validation error; when present triggers error styling and message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the toggle is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/**
|
||||
* When `true`, cycling is two-state only (true/false).
|
||||
* When `false`, a null/indeterminate state is included in the cycle.
|
||||
*/
|
||||
required?: boolean
|
||||
/** `data-qqq-id` attribute forwarded to the toggle button for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current boolean state from a field value.
|
||||
* Returns true, false, or null (for indeterminate/three-state).
|
||||
*
|
||||
* @param value - The raw field value from React Hook Form.
|
||||
* @returns `true`, `false`, or `null` for the indeterminate state.
|
||||
*/
|
||||
function resolveBoolState(value: unknown): boolean | null {
|
||||
if (value === null || value === undefined) return null
|
||||
@@ -37,6 +74,9 @@ function resolveBoolState(value: unknown): boolean | null {
|
||||
* Returns the aria-checked value for the current boolean state.
|
||||
* Uses role="checkbox" (not "switch") because checkbox supports aria-checked="mixed"
|
||||
* for the indeterminate/null state in three-state mode.
|
||||
*
|
||||
* @param state - The resolved boolean state (`true`, `false`, or `null`).
|
||||
* @returns The ARIA checked string: `'true'`, `'false'`, or `'mixed'`.
|
||||
*/
|
||||
function ariaCheckedValue(state: boolean | null): 'true' | 'false' | 'mixed' {
|
||||
if (state === true) return 'true'
|
||||
@@ -44,6 +84,17 @@ function ariaCheckedValue(state: boolean | null): 'true' | 'false' | 'mixed' {
|
||||
return 'mixed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle switch for boolean form fields integrated with React Hook Form.
|
||||
*
|
||||
* When `required` is true the toggle cycles between `true` and `false`.
|
||||
* When `required` is false it cycles through `null → true → false → null`,
|
||||
* where `null` represents the indeterminate (unset) state displayed with a
|
||||
* dash indicator and `aria-checked="mixed"`.
|
||||
*
|
||||
* @param props - See {@link BooleanFieldProps}.
|
||||
* @returns The rendered toggle switch with label and optional error message.
|
||||
*/
|
||||
export function BooleanField({
|
||||
id,
|
||||
label,
|
||||
@@ -55,6 +106,12 @@ export function BooleanField({
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: BooleanFieldProps) {
|
||||
// Cycle logic depends on whether the field is required
|
||||
/**
|
||||
* Returns the next value in the boolean cycle given the current state.
|
||||
*
|
||||
* @param current - The current boolean state (`true`, `false`, or `null`).
|
||||
* @returns The next boolean state in the cycle.
|
||||
*/
|
||||
const getNextValue = useCallback(
|
||||
(current: boolean | null): boolean | null => {
|
||||
if (required) {
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file DateField — date input form field with label, validation error display, and accessibility attributes.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
@@ -5,16 +25,37 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link DateField} component.
|
||||
*/
|
||||
interface DateFieldProps {
|
||||
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
|
||||
id: string
|
||||
/** Human-readable field label rendered above the input. */
|
||||
label: string
|
||||
/** Return value of `register(fieldName)` from React Hook Form. */
|
||||
registration: UseFormRegisterReturn
|
||||
/** Validation error; when present triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the input is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
|
||||
required?: boolean
|
||||
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
/** Id of help text that describes this control. */
|
||||
describedBy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an accessible date input field with label and validation error display.
|
||||
*
|
||||
* Uses `<input type="date">` which provides native date picker UI in supported
|
||||
* browsers. The value format is `YYYY-MM-DD` as managed by the browser.
|
||||
*
|
||||
* @param props - See {@link DateFieldProps}.
|
||||
* @returns The rendered date input field with label and optional error message.
|
||||
*/
|
||||
export function DateField({
|
||||
id,
|
||||
label,
|
||||
@@ -23,6 +64,7 @@ export function DateField({
|
||||
disabled = false,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
describedBy,
|
||||
}: DateFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -41,7 +83,7 @@ export function DateField({
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file DateTimeField — datetime-local input form field with label, timezone hint, validation error display, and accessibility attributes.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
@@ -5,16 +25,39 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link DateTimeField} component.
|
||||
*/
|
||||
interface DateTimeFieldProps {
|
||||
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
|
||||
id: string
|
||||
/** Human-readable field label rendered above the input. */
|
||||
label: string
|
||||
/** Return value of `register(fieldName)` from React Hook Form. */
|
||||
registration: UseFormRegisterReturn
|
||||
/** Validation error; when present triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the input is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
|
||||
required?: boolean
|
||||
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
/** Id of help text that describes this control. */
|
||||
describedBy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an accessible datetime-local input field with label, timezone hint, and validation error display.
|
||||
*
|
||||
* Uses `<input type="datetime-local">` which provides combined date + time
|
||||
* picker UI in supported browsers. The value format is `YYYY-MM-DDTHH:mm`
|
||||
* as managed by the browser. A small hint line identifies the user's local
|
||||
* timezone so there is no ambiguity about which timezone is assumed.
|
||||
*
|
||||
* @param props - See {@link DateTimeFieldProps}.
|
||||
* @returns The rendered datetime input field with label, timezone hint, and optional error message.
|
||||
*/
|
||||
export function DateTimeField({
|
||||
id,
|
||||
label,
|
||||
@@ -23,7 +66,12 @@ export function DateTimeField({
|
||||
disabled = false,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
describedBy,
|
||||
}: DateTimeFieldProps) {
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const hintId = `${id}-tz-hint`
|
||||
const describedByIds = [hintId, error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
@@ -37,11 +85,12 @@ export function DateTimeField({
|
||||
<input
|
||||
id={id}
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
{...registration}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
aria-describedby={describedByIds}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
@@ -54,6 +103,9 @@ export function DateTimeField({
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
<p id={hintId} className="mt-0.5 text-xs text-muted-foreground">
|
||||
Your local time ({timeZone})
|
||||
</p>
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file FileUploadField — file upload for BLOB / FILE_UPLOAD fields: a "Choose file" button
|
||||
* or a drag-and-drop zone (the FILE_UPLOAD adornment's `format`), with the record's current
|
||||
* file, React Hook Form integration and keyboard access.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useRef, useState } from 'react'
|
||||
@@ -7,19 +29,45 @@ import { Upload, X, File as FileIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link FileUploadField} component.
|
||||
*/
|
||||
interface FileUploadFieldProps {
|
||||
/** The HTML `id` for the upload control. */
|
||||
id: string
|
||||
/** Human-readable field label. */
|
||||
label: string
|
||||
/** The React Hook Form field name used by the `Controller`. */
|
||||
name: string
|
||||
/** React Hook Form control object from the parent `useForm` instance. */
|
||||
control: Control<Record<string, unknown>>
|
||||
/** Validation error; when present triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the control is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/** When `true`, an asterisk indicator is shown next to the label. */
|
||||
required?: boolean
|
||||
/** Forwarded to the hidden `<input type="file">` `accept` attribute (e.g. `"image/*,.pdf"`). */
|
||||
accept?: string
|
||||
/** `button` (default): a "Choose file to upload" button; `dragAndDrop`: a drop zone. */
|
||||
format?: 'button' | 'dragAndDrop'
|
||||
/** The file the record already holds (edit screen), shown with a remove action. */
|
||||
currentFile?: { name: string; url?: string }
|
||||
/** @deprecated Use `currentFile`. Name of a previously uploaded file. */
|
||||
existingFileName?: string
|
||||
/** `data-qqq-id` attribute for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* File upload field integrated with React Hook Form.
|
||||
*
|
||||
* The value is a `File` once chosen, the record's original value while untouched,
|
||||
* and `null` when the current file is removed (saved as a cleared file).
|
||||
*
|
||||
* @param props - See {@link FileUploadFieldProps}.
|
||||
* @returns The upload control with label, current file and optional error message.
|
||||
*/
|
||||
export function FileUploadField({
|
||||
id,
|
||||
label,
|
||||
@@ -29,98 +77,125 @@ export function FileUploadField({
|
||||
disabled = false,
|
||||
required = false,
|
||||
accept,
|
||||
format = 'button',
|
||||
currentFile,
|
||||
existingFileName,
|
||||
'data-qqq-id': dataQqqId,
|
||||
}: FileUploadFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const initialValue = useRef<{ captured: boolean; value: unknown }>({ captured: false, value: null })
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const current = currentFile ?? (existingFileName ? { name: existingFileName } : undefined)
|
||||
const labelId = `${id}-label`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
|
||||
>
|
||||
<span id={labelId} className="text-sm font-medium text-foreground" data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
</span>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const currentFile = field.value instanceof File ? (field.value as File) : null
|
||||
const displayName = currentFile?.name ?? existingFileName
|
||||
if (!initialValue.current.captured) initialValue.current = { captured: true, value: field.value ?? null }
|
||||
const selected = field.value instanceof File ? field.value : null
|
||||
const showsCurrent = Boolean(current) && !selected && field.value !== null && field.value !== '' && field.value !== undefined
|
||||
|
||||
const choose = () => { if (!disabled) inputRef.current?.click() }
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
if (files && files.length > 0) {
|
||||
field.onChange(files[0])
|
||||
}
|
||||
if (files && files.length > 0) field.onChange(files[0])
|
||||
}
|
||||
const clearSelection = () => {
|
||||
field.onChange(initialValue.current.value instanceof File ? null : initialValue.current.value)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
id={id}
|
||||
role="region"
|
||||
aria-label={`${label} upload area`}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
if (!disabled) handleFiles(e.dataTransfer.files)
|
||||
}}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-md border-2 border-dashed p-6 text-center',
|
||||
'transition-colors duration-150 cursor-pointer',
|
||||
dragOver && !disabled ? 'border-primary bg-accent' : 'border-input',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
error && 'border-destructive',
|
||||
'hover:border-primary'
|
||||
)}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
|
||||
<div className="space-y-2">
|
||||
{showsCurrent && current && (
|
||||
<div className="flex items-center gap-2 text-sm" data-qqq-id={dataQqqId ? `${dataQqqId}-current-file` : undefined}>
|
||||
<span className="text-muted-foreground">Current File:</span>
|
||||
{current.url
|
||||
? <a href={current.url} target="_blank" rel="noopener noreferrer" className="text-primary underline">{current.name}</a>
|
||||
: <span className="text-foreground">{current.name}</span>}
|
||||
{!disabled && (
|
||||
<button type="button" aria-label={`Remove current file ${current.name}`} title="Remove current file"
|
||||
data-qqq-id={dataQqqId ? `${dataQqqId}-remove-current` : undefined}
|
||||
onClick={() => field.onChange(null)}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!selected && current && field.value === null && (
|
||||
<p className="text-sm text-muted-foreground" role="status">The current file will be removed when you save.</p>
|
||||
)}
|
||||
|
||||
{format === 'dragAndDrop' ? (
|
||||
<div
|
||||
id={id}
|
||||
role="group"
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
data-upload-format="dragAndDrop"
|
||||
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{displayName ? (
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<FileIcon className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
<span className="max-w-[200px] truncate">{displayName}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${displayName}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
field.onChange(null)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}}
|
||||
className="ml-1 rounded p-0.5 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mb-2 h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-primary">Click to upload</span>{' '}
|
||||
or drag and drop
|
||||
</p>
|
||||
{accept && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{accept}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
setDragOver(false)
|
||||
if (!disabled) handleFiles(e.dataTransfer.files)
|
||||
}}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed p-6 text-center',
|
||||
'transition-colors duration-150',
|
||||
dragOver && !disabled ? 'border-primary bg-accent' : 'border-input',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
error && !dragOver && 'border-destructive'
|
||||
)}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">{dragOver && !disabled ? 'Drop file here' : 'Drag and drop a file'}</p>
|
||||
<p className="text-xs text-muted-foreground">or</p>
|
||||
<button type="button" onClick={choose} disabled={disabled} aria-describedby={labelId}
|
||||
data-qqq-id={dataQqqId ? `${dataQqqId}-browse` : undefined}
|
||||
className="rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-primary hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed">
|
||||
Browse files
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-3" data-qqq-id={dataQqqId} data-upload-format="button">
|
||||
<button id={id} type="button" onClick={choose} disabled={disabled}
|
||||
aria-labelledby={`${labelId} ${id}`}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
data-qqq-id={dataQqqId ? `${dataQqqId}-choose` : undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium',
|
||||
'bg-card text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error ? 'border-destructive' : 'border-input'
|
||||
)}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Choose file to upload
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="flex items-center gap-2 text-sm text-foreground" data-qqq-id={dataQqqId ? `${dataQqqId}-selected-file` : undefined}>
|
||||
<FileIcon className="h-4 w-4 text-primary" aria-hidden="true" />
|
||||
<span className="max-w-[240px] truncate">{selected.name}</span>
|
||||
{!disabled && (
|
||||
<button type="button" aria-label={`Remove ${selected.name}`} onClick={clearSelection}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
@@ -130,6 +205,7 @@ export function FileUploadField({
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="sr-only"
|
||||
data-qqq-id={dataQqqId ? `${dataQqqId}-file-input` : undefined}
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file NumberField — numeric input form field supporting integer, long, and decimal types with validation.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
@@ -5,18 +25,52 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link NumberField} component.
|
||||
*/
|
||||
interface NumberFieldProps {
|
||||
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
|
||||
id: string
|
||||
/** Human-readable field label rendered above the input. */
|
||||
label: string
|
||||
/** Return value of `register(fieldName)` from React Hook Form. */
|
||||
registration: UseFormRegisterReturn
|
||||
/** Validation error; when present triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the input is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/** Placeholder text shown when the field is empty. */
|
||||
placeholder?: string
|
||||
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
|
||||
required?: boolean
|
||||
/**
|
||||
* The `step` attribute for the numeric input.
|
||||
* Use `1` for integers/longs; use `"any"` for decimals.
|
||||
*/
|
||||
step?: string | number
|
||||
/** Minimum allowed value from field metadata; displayed as hint text below the input. */
|
||||
minValue?: number | string | null
|
||||
/** Maximum allowed value from field metadata; displayed as hint text below the input. */
|
||||
maxValue?: number | string | null
|
||||
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
/** Id of help text that describes this control. */
|
||||
describedBy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an accessible numeric input field with label, optional min/max hint, and validation error display.
|
||||
*
|
||||
* Used for QQQ field types INTEGER, LONG (with `step={1}`), and DECIMAL
|
||||
* (with `step="any"`). Values remain input strings until schema validation,
|
||||
* preserving an empty input separately from a numeric zero.
|
||||
*
|
||||
* When `minValue` or `maxValue` is provided, a hint line is rendered below
|
||||
* the input showing the allowed range or bound before any error message.
|
||||
*
|
||||
* @param props - See {@link NumberFieldProps}.
|
||||
* @returns The rendered numeric input field with label, optional constraint hint, and optional error message.
|
||||
*/
|
||||
export function NumberField({
|
||||
id,
|
||||
label,
|
||||
@@ -26,8 +80,14 @@ export function NumberField({
|
||||
placeholder,
|
||||
required = false,
|
||||
step,
|
||||
minValue,
|
||||
maxValue,
|
||||
'data-qqq-id': dataQqqId,
|
||||
describedBy,
|
||||
}: NumberFieldProps) {
|
||||
const hintId = (minValue != null || maxValue != null) ? `${id}-hint` : undefined
|
||||
const describedByIds = [hintId, error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
@@ -47,7 +107,7 @@ export function NumberField({
|
||||
placeholder={placeholder}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
aria-describedby={describedByIds}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
@@ -61,6 +121,15 @@ export function NumberField({
|
||||
: 'border-input'
|
||||
)}
|
||||
/>
|
||||
{(minValue != null || maxValue != null) && (
|
||||
<p id={hintId} className="mt-0.5 text-xs text-muted-foreground">
|
||||
{minValue != null && maxValue != null
|
||||
? `Range: ${minValue} \u2013 ${maxValue}`
|
||||
: minValue != null
|
||||
? `Minimum: ${minValue}`
|
||||
: `Maximum: ${maxValue}`}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
|
||||
{error.message}
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file PasswordField — password input with show/hide toggle, validation error display, and accessibility attributes.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
@@ -6,18 +26,42 @@ import { Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link PasswordField} component.
|
||||
*/
|
||||
interface PasswordFieldProps {
|
||||
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
|
||||
id: string
|
||||
/** Human-readable field label rendered above the input. */
|
||||
label: string
|
||||
/** Return value of `register(fieldName)` from React Hook Form. */
|
||||
registration: UseFormRegisterReturn
|
||||
/** Validation error; when present triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the input is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/** Placeholder text shown when the field is empty. */
|
||||
placeholder?: string
|
||||
/** Forwarded to the `<input>` `maxlength` attribute. */
|
||||
maxLength?: number
|
||||
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
|
||||
required?: boolean
|
||||
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
/** Id of help text that describes this control. */
|
||||
describedBy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a password input field with a show/hide toggle button.
|
||||
*
|
||||
* Toggling visibility switches the underlying `<input>` between `type="password"`
|
||||
* and `type="text"`. The button is labelled via `aria-label` and is excluded
|
||||
* from form submission focus order concerns because it has `type="button"`.
|
||||
*
|
||||
* @param props - See {@link PasswordFieldProps}.
|
||||
* @returns The rendered password input field with show/hide toggle and optional error message.
|
||||
*/
|
||||
export function PasswordField({
|
||||
id,
|
||||
label,
|
||||
@@ -28,6 +72,7 @@ export function PasswordField({
|
||||
maxLength,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
describedBy,
|
||||
}: PasswordFieldProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
@@ -51,7 +96,7 @@ export function PasswordField({
|
||||
maxLength={maxLength}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 pr-10 text-sm text-foreground',
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file SelectField — native `<select>` field for static option lists, integrated with React Hook Form.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
@@ -6,18 +26,43 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import type { QPossibleValue } from '@/types'
|
||||
|
||||
/**
|
||||
* Props for the {@link SelectField} component.
|
||||
*/
|
||||
interface SelectFieldProps {
|
||||
/** The HTML `id` for the `<select>` element and its associated `<label>`. */
|
||||
id: string
|
||||
/** Human-readable field label rendered above the select. */
|
||||
label: string
|
||||
/** Return value of `register(fieldName)` from React Hook Form. */
|
||||
registration: UseFormRegisterReturn
|
||||
/** The list of selectable options rendered as `<option>` elements. */
|
||||
options: QPossibleValue[]
|
||||
/** Validation error; when present triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the select is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/** When `true`, no blank placeholder option is prepended; an asterisk indicator is shown. */
|
||||
required?: boolean
|
||||
/** Text for the blank placeholder option prepended when `required` is false; defaults to `"-- Select {label} --"`. */
|
||||
placeholder?: string
|
||||
/** `data-qqq-id` attribute forwarded to the select element for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a native `<select>` field for a static list of options.
|
||||
*
|
||||
* Unlike {@link PossibleValueSelect} (which fetches options asynchronously),
|
||||
* this component renders a fixed `options` array synchronously, making it
|
||||
* suitable for inline enum-like value sets that do not need server-side search.
|
||||
*
|
||||
* When `required` is false a blank placeholder option is prepended so the
|
||||
* user can deselect an existing value.
|
||||
*
|
||||
* @param props - See {@link SelectFieldProps}.
|
||||
* @returns The rendered native select field with label and optional error message.
|
||||
*/
|
||||
export function SelectField({
|
||||
id,
|
||||
label,
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file TextField — single-line text input form field with label, validation error display, and accessibility attributes.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
@@ -5,18 +25,41 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* Props for the {@link TextField} component.
|
||||
*/
|
||||
interface TextFieldProps {
|
||||
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
|
||||
id: string
|
||||
/** Human-readable field label rendered above the input. */
|
||||
label: string
|
||||
/** Return value of `register(fieldName)` from React Hook Form. */
|
||||
registration: UseFormRegisterReturn
|
||||
/** Validation error; when present triggers error styling and an error message. */
|
||||
error?: FieldError
|
||||
/** When `true`, the input is non-interactive and visually dimmed. */
|
||||
disabled?: boolean
|
||||
/** Placeholder text shown when the field is empty. */
|
||||
placeholder?: string
|
||||
/** Forwarded to the `<input>` `maxlength` attribute. */
|
||||
maxLength?: number
|
||||
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
|
||||
required?: boolean
|
||||
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
|
||||
'data-qqq-id'?: string
|
||||
/** Id of help text that describes this control. */
|
||||
describedBy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an accessible single-line text input field with label and validation error display.
|
||||
*
|
||||
* Used as the default field type for QQQ `STRING` fields and as the fallback
|
||||
* renderer for any unrecognised field type in {@link DynamicFormField}.
|
||||
*
|
||||
* @param props - See {@link TextFieldProps}.
|
||||
* @returns The rendered text input field with label and optional error message.
|
||||
*/
|
||||
export function TextField({
|
||||
id,
|
||||
label,
|
||||
@@ -27,6 +70,7 @@ export function TextField({
|
||||
maxLength,
|
||||
required = false,
|
||||
'data-qqq-id': dataQqqId,
|
||||
describedBy,
|
||||
}: TextFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -47,7 +91,7 @@ export function TextField({
|
||||
maxLength={maxLength}
|
||||
aria-required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
|
||||
data-qqq-id={dataQqqId}
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
|
||||
|
||||
@@ -1,82 +1,78 @@
|
||||
// Tests for Banner component
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file 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.
|
||||
*/
|
||||
|
||||
// Tests for Banner component (v1 branding banner shape, keyed by slot)
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
|
||||
import BannerComponent from './Banner'
|
||||
import type { Banner } from '@/types'
|
||||
|
||||
const banners: Record<string, Banner> = {
|
||||
QFMD_TOP_OF_SITE: { severity: 'INFO', messageText: 'Site notice' },
|
||||
QFMD_TOP_OF_BODY: { severity: 'WARNING', messageHTML: 'Body <b>bold</b><img src=x onerror="alert(1)">' },
|
||||
QFMD_SIDE_NAV_UNDER_LOGO: {
|
||||
severity: 'SUCCESS',
|
||||
textColor: '#ffffff',
|
||||
backgroundColor: '#14532d',
|
||||
additionalStyles: { fontWeight: '700' },
|
||||
messageText: 'NAV',
|
||||
},
|
||||
SOMETHING_ELSE: { severity: 'ERROR', messageText: 'Unknown slot' },
|
||||
}
|
||||
|
||||
describe('Banner', () => {
|
||||
const banners: Record<string, Banner> = {
|
||||
QFMD_TOP_OF_SITE: {
|
||||
text: 'This is a test environment',
|
||||
severity: 'warning',
|
||||
dismissible: true,
|
||||
},
|
||||
}
|
||||
|
||||
it('should render banner text', () => {
|
||||
render(<BannerComponent banners={banners} />)
|
||||
expect(screen.getByText('This is a test environment')).toBeInTheDocument()
|
||||
it('renders the plain-text message of the requested slot in a labelled region', () => {
|
||||
render(<BannerComponent banners={banners} slot="QFMD_TOP_OF_SITE" />)
|
||||
const region = screen.getByRole('region', { name: 'Site banner' })
|
||||
expect(region).toHaveTextContent('Site notice')
|
||||
expect(region).toHaveAttribute('data-qqq-id', 'banner-QFMD_TOP_OF_SITE')
|
||||
expect(region).toHaveAttribute('data-severity', 'info')
|
||||
expect(screen.queryByText('Unknown slot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have role="alert"', () => {
|
||||
render(<BannerComponent banners={banners} />)
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
it('renders sanitized HTML in preference to text', () => {
|
||||
render(<BannerComponent banners={banners} slot="QFMD_TOP_OF_BODY" />)
|
||||
const region = screen.getByRole('region', { name: 'Page banner' })
|
||||
expect(region.querySelector('b')).toHaveTextContent('bold')
|
||||
expect(region.querySelector('img')?.getAttribute('onerror')).toBeFalsy()
|
||||
expect(region).toHaveAttribute('data-severity', 'warning')
|
||||
})
|
||||
|
||||
it('should show dismiss button for dismissible banners', () => {
|
||||
render(<BannerComponent banners={banners} />)
|
||||
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument()
|
||||
it('applies text color, background color and additional styles from metadata', () => {
|
||||
render(<BannerComponent banners={banners} slot="QFMD_SIDE_NAV_UNDER_LOGO" />)
|
||||
const region = screen.getByRole('region', { name: 'Navigation banner' })
|
||||
expect(region.style.color).toBe('rgb(255, 255, 255)')
|
||||
expect(region.style.background).toContain('rgb(20, 83, 45)')
|
||||
expect(region.style.fontWeight).toBe('700')
|
||||
})
|
||||
|
||||
it('should hide banner after dismiss', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BannerComponent banners={banners} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }))
|
||||
expect(screen.queryByText('This is a test environment')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show dismiss button for non-dismissible banners', () => {
|
||||
const nonDismissibleBanners: Record<string, Banner> = {
|
||||
QFMD_TOP_OF_SITE: {
|
||||
text: 'Persistent banner',
|
||||
severity: 'info',
|
||||
dismissible: false,
|
||||
},
|
||||
}
|
||||
render(<BannerComponent banners={nonDismissibleBanners} />)
|
||||
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onDismiss callback when dismissed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDismiss = vi.fn()
|
||||
render(<BannerComponent banners={banners} onDismiss={onDismiss} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }))
|
||||
expect(onDismiss).toHaveBeenCalledWith('QFMD_TOP_OF_SITE')
|
||||
})
|
||||
|
||||
it('should render nothing for empty banners', () => {
|
||||
const { container } = render(<BannerComponent banners={{}} />)
|
||||
it('renders nothing for an empty slot, missing banners, or a banner without a message', () => {
|
||||
const { container, rerender } = render(<BannerComponent banners={{}} slot="QFMD_TOP_OF_SITE" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
rerender(<BannerComponent slot="QFMD_TOP_OF_SITE" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
rerender(<BannerComponent banners={{ QFMD_TOP_OF_SITE: { severity: 'INFO' } }} slot="QFMD_TOP_OF_SITE" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should have data-qqq-id on banner element', () => {
|
||||
render(<BannerComponent banners={banners} />)
|
||||
expect(screen.getByRole('alert').closest('[data-qqq-id]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render multiple banners', () => {
|
||||
const multiBanners: Record<string, Banner> = {
|
||||
banner1: { text: 'First banner', severity: 'info', dismissible: false },
|
||||
banner2: { text: 'Second banner', severity: 'warning', dismissible: true },
|
||||
}
|
||||
render(<BannerComponent banners={multiBanners} />)
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(2)
|
||||
it('defaults to info severity', () => {
|
||||
render(<BannerComponent banners={{ QFMD_TOP_OF_SITE: { messageText: 'Plain' } }} slot="QFMD_TOP_OF_SITE" />)
|
||||
expect(screen.getByRole('region', { name: 'Site banner' })).toHaveAttribute('data-severity', 'info')
|
||||
})
|
||||
})
|
||||
Loaded 100 of 502 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user