Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2c7e71712 | ||
|
|
a8860f6d0d | ||
|
|
1f57dbf657 | ||
|
|
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 | ||
|
|
df8e2e61cf | ||
|
|
6073f1c4b3 | ||
|
|
d28c569b3a | ||
|
|
1137802f8c | ||
|
|
66db82b3ce | ||
|
|
26052f99db | ||
|
|
641961a335 |
No files matched your search
@@ -0,0 +1,97 @@
|
||||
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.
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job; avoid the interactive needrestart prompt.
|
||||
sudo sed -i 's|http://security.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
jobs:
|
||||
publish_dashboard_jar:
|
||||
executor: qqq-orb/java
|
||||
steps:
|
||||
- configure-package-install
|
||||
- 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
|
||||
@@ -0,0 +1,8 @@
|
||||
# Mock API environment — copy to .env.local to run without a real backend
|
||||
#
|
||||
# cp .env.mock .env.local && pnpm dev
|
||||
# OR pass inline:
|
||||
# NEXT_PUBLIC_MOCK_API=true pnpm dev
|
||||
|
||||
NEXT_PUBLIC_MOCK_API=true
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/qqq/v1
|
||||
@@ -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"
|
||||
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.claude/
|
||||
target/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/log/
|
||||
/.playwright-mcp/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,184 @@
|
||||
# CLAUDE.md — QQQ Frontend Next
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is the **QQQ Admin UI Modernization** project — a complete rewrite of the QQQ admin frontend from React + Material UI to Next.js + Tailwind CSS + shadcn/ui.
|
||||
|
||||
QQQ is a metadata-driven, low-code application framework. The admin UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded. The backend declares metadata (tables, processes, apps, widgets), and the frontend renders it dynamically.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 15 (App Router) with React 19
|
||||
- **Language:** TypeScript 5.x (`strict: true` — no `any` in component props)
|
||||
- **Styling:** Tailwind CSS 4.x with CSS custom properties for QQQ theme tokens
|
||||
- **Components:** shadcn/ui (Radix primitives + Tailwind)
|
||||
- **Data Grid:** TanStack Table v8 (replaces MUI X DataGrid Pro)
|
||||
- **Forms:** React Hook Form v7 + Zod validation (replaces Formik + Yup)
|
||||
- **Data Fetching:** TanStack Query v5
|
||||
- **Charts:** Recharts
|
||||
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
|
||||
- **Package Manager:** pnpm
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ ├── (auth)/ # Auth routes (login, callback)
|
||||
│ ├── (dashboard)/ # Authenticated routes
|
||||
│ │ ├── layout.tsx # Dashboard layout (sidebar + nav + banners)
|
||||
│ │ └── app/ # All app routes
|
||||
│ │ ├── [appName]/page.tsx # App home (dashboard)
|
||||
│ │ ├── [tableName]/page.tsx # Record query
|
||||
│ │ ├── [tableName]/create/page.tsx # Create record
|
||||
│ │ ├── [tableName]/[recordId]/page.tsx # Record view
|
||||
│ │ ├── [tableName]/[recordId]/edit/page.tsx # Edit record
|
||||
│ │ └── [processName]/page.tsx # Process execution
|
||||
│ ├── layout.tsx # Root layout (providers)
|
||||
│ ├── error.tsx # Global error boundary
|
||||
│ └── not-found.tsx # 404
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives
|
||||
│ ├── 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, blocks, grids
|
||||
│ └── feedback/ # Alerts, Toasts, Modals, CommandMenu
|
||||
├── lib/
|
||||
│ ├── api/ # Typed API client modules
|
||||
│ │ ├── client.ts # Base axios client with 401 interceptor
|
||||
│ │ ├── auth.ts # Auth endpoints
|
||||
│ │ ├── metadata.ts # Metadata endpoints
|
||||
│ │ ├── tables.ts # CRUD + query/count
|
||||
│ │ ├── processes.ts # Process lifecycle
|
||||
│ │ ├── widgets.ts # Widget data
|
||||
│ │ └── possible-values.ts
|
||||
│ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
|
||||
│ ├── hooks/ # Shared React hooks
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ ├── context/ # React contexts (QContext)
|
||||
│ └── theme/ # Theme provider, CSS tokens
|
||||
├── types/ # TypeScript type definitions
|
||||
└── styles/ # Global styles, theme tokens
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Metadata-Driven Rendering
|
||||
- ALL components that render from metadata accept it as a prop — never fetch internally
|
||||
- No hardcoded table names, field names, or process names anywhere
|
||||
- User-facing text uses `label` from metadata, never `name`
|
||||
- UI structure derives entirely from backend metadata
|
||||
|
||||
### API Integration
|
||||
- All API calls go through typed functions in `src/lib/api/` — never raw `fetch`
|
||||
- TanStack Query manages server state (caching, deduplication, revalidation)
|
||||
- Query keys follow the factory pattern in `src/lib/query-client.ts`
|
||||
- 401 responses intercepted globally → redirect to login
|
||||
|
||||
### Component Patterns
|
||||
- Server Components: root layouts, metadata-fetching pages
|
||||
- Client Components: interactive UI (forms, grids, filters) — mark with `"use client"`
|
||||
- Every interactive component has a `data-qqq-id` attribute for CSS customization
|
||||
- Naming: `data-qqq-id="{component-type}-{identifier}"` (e.g., `data-qqq-id="button-save"`)
|
||||
|
||||
### State Management
|
||||
- Server state: TanStack Query
|
||||
- UI state: React context (QContext)
|
||||
- Form state: React Hook Form
|
||||
- URL state: Next.js searchParams (filters, pagination — shareable URLs)
|
||||
- Local persistence: localStorage (density, column widths, saved views)
|
||||
|
||||
### File Naming
|
||||
- Components: PascalCase (`RecordQuery.tsx`)
|
||||
- Hooks: camelCase with `use-` prefix (`use-metadata.ts`)
|
||||
- Utilities: kebab-case (`filter-utils.ts`)
|
||||
- Types: kebab-case (`metadata.ts`)
|
||||
- Tests: `*.test.ts(x)` co-located with source
|
||||
|
||||
### Import Order
|
||||
```typescript
|
||||
// 1. React/Next.js
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// 2. External libraries
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
// 3. Types
|
||||
import type { QTableMetaData } from '@/types'
|
||||
|
||||
// 4. Lib (API, hooks, utils)
|
||||
import { queryRecords } from '@/lib/api/tables'
|
||||
|
||||
// 5. Components
|
||||
import { Button } from '@/components/ui/button'
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Error boundaries at route level (`error.tsx`) and widget level
|
||||
- API errors via TanStack Query `onError` → toast notification
|
||||
- Form validation: Zod schemas from field metadata
|
||||
- Loading: skeleton components matching final layout
|
||||
- Empty: descriptive messages with action buttons
|
||||
|
||||
### Accessibility (WCAG 2.1 AA)
|
||||
- All form inputs: `<label>` with `htmlFor`, `aria-required`, `aria-invalid`
|
||||
- All buttons: descriptive text or `aria-label`
|
||||
- Focus trapped in modals, restored on close
|
||||
- Keyboard navigation for all interactive elements
|
||||
- Color contrast: 4.5:1 normal, 3:1 large text
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Production build
|
||||
pnpm test # Run Vitest unit tests
|
||||
pnpm test:e2e # Run Playwright E2E tests
|
||||
pnpm tsc --noEmit # Type check without emitting
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
```
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
Full implementation plans are in `docs/implementation-plans/`. The project is built in 6 work packages:
|
||||
|
||||
1. **Package 1** — Scaffold, Auth, Layout Shell (foundation — must be first)
|
||||
2. **Package 2** — Record Query (data grid, filters, pagination, saved views)
|
||||
3. **Package 3** — Record View + CRUD (forms, field types, validation)
|
||||
4. **Package 4** — Process Execution (step wizard, async polling, bulk load)
|
||||
5. **Package 5** — Dashboard + Widgets (charts, statistics, blocks, grids)
|
||||
6. **Package 6** — Polish + Parity (responsive, a11y, perf, Storybook, E2E)
|
||||
|
||||
Shared context for all agents: `docs/implementation-plans/shared-context/`
|
||||
|
||||
## Reference Codebase
|
||||
|
||||
The current frontend being replaced lives in the sibling `qqq-frontend-material-dashboard` repo. Key files to reference for behavior parity:
|
||||
- `App.tsx` — route generation, auth flow, layout
|
||||
- `src/qqq/pages/records/query/RecordQuery.tsx` — most complex page (260+ state vars)
|
||||
- `src/qqq/pages/records/view/RecordView.tsx` — record detail
|
||||
- `src/qqq/pages/processes/ProcessRun.tsx` — process execution
|
||||
- `src/qqq/components/widgets/` — 60+ widget components
|
||||
|
||||
## API Base URL
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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.
|
||||
@@ -1,3 +1,123 @@
|
||||
# PR assets: SQL Workbench theme
|
||||
# QQQ Frontend Next
|
||||
|
||||
Before (main) and after screenshots of the SQL Workbench, light and dark, desktop and phone (mocked API, production build).
|
||||
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
|
||||
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 95 KiB |
@@ -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,293 @@
|
||||
# Master Agent Execution Plan — QQQ Frontend Next
|
||||
|
||||
## Your Role
|
||||
|
||||
You are the **Master Orchestrator Agent**. Your job is to build the entire QQQ admin UI replacement by spawning and controlling implementation sub-agents, one per work package, in the correct dependency order. You delegate all coding work to sub-agents. You verify their output, handle failures, and ensure the full project compiles and integrates at the end.
|
||||
|
||||
## Repository
|
||||
|
||||
You are working in `qqq-frontend-next` — a new repo for the Next.js replacement of the QQQ admin UI.
|
||||
|
||||
### Critical Files in This Repo
|
||||
|
||||
```
|
||||
docs/
|
||||
├── qqq-admin-modernization-requirements.md # Full requirements spec
|
||||
├── master-architect-agent-prompt.md # How the plans were designed
|
||||
├── 00-master-execution-plan.md # THIS FILE — your instructions
|
||||
└── implementation-plans/
|
||||
├── 00-project-overview.md # Tech stack, directory structure, conventions
|
||||
├── 01-scaffold-and-auth.md # Work Package 1 plan
|
||||
├── 02-record-query.md # Work Package 2 plan
|
||||
├── 03-record-view-and-crud.md # Work Package 3 plan
|
||||
├── 04-process-execution.md # Work Package 4 plan
|
||||
├── 05-dashboard-and-widgets.md # Work Package 5 plan
|
||||
├── 06-polish-and-parity.md # Work Package 6 plan
|
||||
├── cross-check-report.md # Requirements coverage verification
|
||||
├── shared-context/
|
||||
│ ├── api-contract.md # API spec — give to every agent
|
||||
│ ├── type-definitions.md # TypeScript types — give to every agent
|
||||
│ └── coding-conventions.md # Coding standards — give to every agent
|
||||
└── review-checklists/
|
||||
├── review-01-scaffold.md through review-06-polish.md
|
||||
```
|
||||
|
||||
### Reference Repos (Read-Only)
|
||||
|
||||
If accessible, the agents should also reference:
|
||||
- `qqq-frontend-material-dashboard` — The current UI being replaced (behavior parity)
|
||||
- `qqq-frontend-core` — TypeScript model types and API client (types to port)
|
||||
- `qqq` — Backend middleware (API endpoint source of truth)
|
||||
|
||||
---
|
||||
|
||||
## Execution Phases
|
||||
|
||||
### Phase 1: Foundation (BLOCKING — must complete before any other phase)
|
||||
|
||||
**Spawn Agent 1: Package 1 — Scaffold + Auth + Layout Shell**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/01-scaffold-and-auth.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `docs/implementation-plans/00-project-overview.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementations in `qqq-frontend-material-dashboard`:
|
||||
> - `src/App.tsx` — Route generation, auth dispatch, layout
|
||||
> - `src/qqq/components/horseshoe/` — SideNav, NavBar, Breadcrumbs
|
||||
>
|
||||
> And from `qqq-frontend-core`:
|
||||
> - `src/model/**/*.ts` — All TypeScript types to port
|
||||
> - `src/controllers/QController.ts` — Auth methods
|
||||
>
|
||||
> Execute every step in the implementation plan (Steps 1-11).
|
||||
> After each step, verify: `pnpm tsc --noEmit` passes.
|
||||
> When done, verify ALL acceptance criteria in Section 9.
|
||||
> Commit your work.
|
||||
|
||||
**Verification gate before proceeding:**
|
||||
```bash
|
||||
pnpm dev # Must start without errors
|
||||
pnpm build # Must succeed
|
||||
pnpm tsc --noEmit # Zero errors
|
||||
pnpm test # All tests pass
|
||||
```
|
||||
|
||||
If the agent fails, fix the issues before moving to Phase 2.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Core Pages (CAN BE PARALLEL — no file conflicts)
|
||||
|
||||
**Spawn Agent 2: Package 2 — Record Query**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/02-record-query.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `docs/implementation-plans/00-project-overview.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/query/RecordQuery.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/query/` (all files)
|
||||
> - `qqq-frontend-core/src/controllers/QControllerV1.ts` (query/count methods)
|
||||
>
|
||||
> Execute every step in the implementation plan.
|
||||
> The foundation from Package 1 is already in place — import from it.
|
||||
> After each step: `pnpm tsc --noEmit`
|
||||
> When done: verify ALL acceptance criteria in Section 9.
|
||||
> Commit your work.
|
||||
|
||||
**Spawn Agent 3: Package 3 — Record View + CRUD**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/03-record-view-and-crud.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `docs/implementation-plans/00-project-overview.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/view/RecordView.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/` (all files)
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/EntityForm.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/DynamicForm.tsx`
|
||||
> - `qqq-frontend-core/src/controllers/QController.ts` (CRUD methods: get, create, update, delete)
|
||||
>
|
||||
> Execute every step in the implementation plan.
|
||||
> The foundation from Package 1 is already in place — import from it.
|
||||
> After each step: `pnpm tsc --noEmit`
|
||||
> When done: verify ALL acceptance criteria in Section 9.
|
||||
> Commit your work.
|
||||
|
||||
**Verification gate before proceeding:**
|
||||
Both agents must complete. Then:
|
||||
```bash
|
||||
pnpm tsc --noEmit # Zero errors (both packages together)
|
||||
pnpm test # All tests pass
|
||||
pnpm build # Must succeed
|
||||
```
|
||||
|
||||
If running in parallel on branches, merge both to main and resolve conflicts before Phase 3.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Process Execution (SEQUENTIAL — needs forms from Package 3)
|
||||
|
||||
**Spawn Agent 4: Package 4 — Process Execution**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/04-process-execution.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/processes/ProcessRun.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/processes/` (all files)
|
||||
> - `qqq-frontend-core/src/controllers/QController.ts` (process methods)
|
||||
>
|
||||
> IMPORTANT: You must reuse form components from Package 3:
|
||||
> - Import `EntityForm` from `@/components/forms/EntityForm`
|
||||
> - Import `DynamicFormField` from `@/components/forms/DynamicFormField`
|
||||
> - Import form hooks and possible-values utilities
|
||||
>
|
||||
> Execute every step. After each: `pnpm tsc --noEmit`.
|
||||
> When done: verify ALL acceptance criteria.
|
||||
> Commit your work.
|
||||
|
||||
**Verification gate:**
|
||||
```bash
|
||||
pnpm tsc --noEmit && pnpm test && pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Dashboard + Widgets (needs DataGrid from P2, forms from P3)
|
||||
|
||||
**Spawn Agent 5: Package 5 — Dashboard and Widgets**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/05-dashboard-and-widgets.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/api-contract.md`
|
||||
> - `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/widgets/` (all files — 60+)
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/apps/AppHome.tsx`
|
||||
>
|
||||
> IMPORTANT: Reuse components from previous packages:
|
||||
> - DataGrid from Package 2 (`@/components/query/DataGrid`) for RecordGridWidget
|
||||
> - EntityForm from Package 3 (`@/components/forms/EntityForm`) for DynamicFormWidget
|
||||
> - Cell renderers from Package 2 for field value display in widgets
|
||||
>
|
||||
> Execute every step. After each: `pnpm tsc --noEmit`.
|
||||
> When done: verify ALL acceptance criteria.
|
||||
> Commit your work.
|
||||
|
||||
**Verification gate:**
|
||||
```bash
|
||||
pnpm tsc --noEmit && pnpm test && pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Polish + Parity (needs EVERYTHING)
|
||||
|
||||
**Spawn Agent 6: Package 6 — Polish, Parity, and Production Readiness**
|
||||
|
||||
Instruct the agent:
|
||||
> Read these files before writing any code:
|
||||
> - `docs/implementation-plans/06-polish-and-parity.md` (your implementation plan)
|
||||
> - `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
> - `CLAUDE.md`
|
||||
>
|
||||
> Also read the reference implementation for features unique to this package:
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/misc/CommandMenu.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/audits/AuditBody.tsx`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/developer/`
|
||||
> - `qqq-frontend-material-dashboard/src/qqq/components/scripts/ScriptViewer.tsx`
|
||||
>
|
||||
> This is the final package. ALL previous packages are complete.
|
||||
> Your job is: responsive audit, accessibility fixes, command palette, keyboard shortcuts,
|
||||
> audit trail, developer tools, Storybook, Playwright E2E, performance optimization,
|
||||
> theme tokens, and customCss injection.
|
||||
>
|
||||
> Execute every step. After each: `pnpm tsc --noEmit`.
|
||||
> When done: verify ALL acceptance criteria.
|
||||
> Run: `pnpm build && pnpm test && pnpm lint`
|
||||
> Commit your work.
|
||||
|
||||
---
|
||||
|
||||
## Final Verification (Master Agent Does This)
|
||||
|
||||
After all 6 agents complete:
|
||||
|
||||
1. **Full build check:**
|
||||
```bash
|
||||
pnpm tsc --noEmit # Zero errors
|
||||
pnpm build # Production build succeeds
|
||||
pnpm test # All unit tests pass
|
||||
pnpm lint # Zero lint errors
|
||||
```
|
||||
|
||||
2. **Review checklists:** Read and verify each file in `docs/implementation-plans/review-checklists/`:
|
||||
- `review-01-scaffold.md` through `review-06-polish.md`
|
||||
- Every checkbox should be satisfiable
|
||||
|
||||
3. **Cross-check report:** Read `docs/implementation-plans/cross-check-report.md` — all requirements should show ✅
|
||||
|
||||
4. **Integration smoke test:** If a backend is available, verify the app end-to-end:
|
||||
- Login → sidebar loads → click table → query page loads → filter → paginate → click row → record view → edit → save → delete → navigate to process → run process → dashboard loads widgets
|
||||
|
||||
5. **Commit everything and push:**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "Complete QQQ Admin UI Modernization — all 6 work packages implemented"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Recovery
|
||||
|
||||
If an agent fails mid-execution:
|
||||
1. Read its error output
|
||||
2. Fix the immediate issue (missing import, type error, etc.)
|
||||
3. Resume the agent from where it left off (it should be able to continue from the last successful step)
|
||||
4. If the failure is architectural (wrong approach), read the relevant section of the implementation plan and correct course
|
||||
|
||||
If agents produce conflicting code (e.g., both define the same component):
|
||||
1. The implementation plans are designed to avoid this — each package owns specific files
|
||||
2. If it happens, the later package's version wins (it has more context)
|
||||
3. Verify the merged result compiles: `pnpm tsc --noEmit`
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints for ALL Agents
|
||||
|
||||
- **pnpm only** (not npm, not yarn)
|
||||
- **TypeScript strict** — no `any` in component props
|
||||
- **Every interactive element** gets `data-qqq-id` attribute
|
||||
- **All API calls** through `src/lib/api/` — never raw fetch/axios
|
||||
- **Metadata-driven** — no hardcoded table/field/process names
|
||||
- **Labels not names** — user-facing text uses `label` from metadata
|
||||
- **Import order** — React/Next → external libs → types → lib → components
|
||||
- **Error boundaries** at route level and widget level
|
||||
- **Loading skeletons** for all async content
|
||||
- **Empty states** with action buttons for all list/grid views
|
||||
@@ -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,53 @@
|
||||
# Agent Prompt: Package 1 — Scaffold, Auth, and Layout Shell
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Your job is to execute the implementation plan for Work Package 1 and produce working, tested code. You do NOT make architectural decisions — the plan specifies everything. Follow it exactly.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your work package plan:** `docs/implementation-plans/01-scaffold-and-auth.md` — This is your primary instruction set. Execute every step.
|
||||
2. **Shared context — API contract:** `docs/implementation-plans/shared-context/api-contract.md` — Every API call must match this exactly.
|
||||
3. **Shared context — Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md` — Port these types into `/src/types/`.
|
||||
4. **Shared context — Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md` — Follow these patterns.
|
||||
5. **Project overview:** `docs/implementation-plans/00-project-overview.md` — Directory structure and shared conventions.
|
||||
6. **CLAUDE.md** — Project-level conventions.
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
Look at the sibling repo `qqq-frontend-material-dashboard` for:
|
||||
- `src/App.tsx` — Route generation from appTree, auth dispatch, layout structure
|
||||
- `src/qqq/components/horseshoe/` — SideNav, NavBar, Breadcrumbs, Footer
|
||||
- `src/qqq/components/misc/Banners.tsx` — Banner rendering
|
||||
|
||||
Also look at `qqq-frontend-core` for:
|
||||
- `src/controllers/QController.ts` — Auth methods (getAuthenticationMetaData, manageSession)
|
||||
- `src/model/**/*.ts` — All TypeScript model types to port
|
||||
|
||||
## What You Must Deliver
|
||||
|
||||
Execute Steps 1-11 from the work package plan. After completion:
|
||||
|
||||
1. `pnpm dev` runs without errors
|
||||
2. `pnpm build` succeeds
|
||||
3. `pnpm tsc --noEmit` — zero errors
|
||||
4. `pnpm test` — all unit tests pass
|
||||
5. Sidebar renders navigation from metadata (use mock data if no backend)
|
||||
6. Auth flow initializes (AUTH_0, OAUTH2, FULLY_ANONYMOUS)
|
||||
7. All placeholder pages exist and render within the dashboard layout
|
||||
8. Theme system injects CSS custom properties
|
||||
9. 401 interceptor triggers redirect
|
||||
10. Breadcrumbs render from pathname
|
||||
|
||||
## Verification
|
||||
|
||||
After completing all steps, check every acceptance criterion in Section 9 of the work package plan. Every criterion must pass before this package is done.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Use `pnpm` (not npm or yarn)
|
||||
- TypeScript strict mode — no `any` in component props
|
||||
- Every interactive element gets a `data-qqq-id` attribute
|
||||
- All API calls go through `src/lib/api/` — never raw fetch
|
||||
- Follow the import order convention from CLAUDE.md
|
||||
- Commit after each major step so progress isn't lost
|
||||
@@ -0,0 +1,72 @@
|
||||
# Agent Prompt: Package 2 — Record Query Page
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute the implementation plan for Work Package 2 and produce working, tested code. Follow the plan exactly — do not make architectural decisions.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/02-record-query.md`
|
||||
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
|
||||
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
5. **Project overview:** `docs/implementation-plans/00-project-overview.md`
|
||||
6. **CLAUDE.md**
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/pages/records/query/RecordQuery.tsx` — THE most complex page. Read the whole thing. Note the 260+ state variables, the page state machine, query deduplication via latestQueryId, localStorage persistence.
|
||||
- `src/qqq/components/query/FilterCriteriaRow.tsx` — Per-row filter UI with operator selection per field type
|
||||
- `src/qqq/components/query/BasicAndAdvancedQueryControls.tsx` — Basic/advanced filter toggle
|
||||
- `src/qqq/components/query/CustomFilterPanel.tsx` — Full filter builder
|
||||
- `src/qqq/components/query/CustomColumnsPanel.tsx` — Column show/hide/reorder
|
||||
- `src/qqq/components/misc/SavedViews.tsx` — Saved view CRUD
|
||||
- `src/qqq/components/query/ExportMenuItem.tsx` — Export to CSV
|
||||
|
||||
From `qqq-frontend-core`:
|
||||
- `src/controllers/QControllerV1.ts` — `query()` and `count()` methods
|
||||
- `src/model/query/QQueryFilter.ts` — Filter structure
|
||||
- `src/model/query/QCriteriaOperator.ts` — All 20 operators
|
||||
|
||||
## What Package 1 Already Provides (Import From It)
|
||||
|
||||
- Types: `import type { QTableMetaData, QFieldMetaData, QRecord, QQueryFilter, QFilterCriteria, QCriteriaOperator } from '@/types'`
|
||||
- API client: `import apiClient from '@/lib/api/client'`
|
||||
- Query keys: `import { queryKeys } from '@/lib/query-client'`
|
||||
- Metadata: `import { loadTableMetaData } from '@/lib/api/metadata'`
|
||||
- Context: `import { useQContext } from '@/lib/context/q-context'`
|
||||
- Layout components are in place — your page renders inside the dashboard layout
|
||||
|
||||
## What You Must Build
|
||||
|
||||
Replace the placeholder at `src/app/(dashboard)/app/[tableName]/page.tsx` with a full Record Query page featuring:
|
||||
- TanStack Table v8 data grid with dynamic columns from QTableMetaData.fields
|
||||
- Server-side pagination (offset/limit via POST /table/{t}/query + /count)
|
||||
- Cell renderers per QFieldType (STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATE_TIME, etc.)
|
||||
- Cell renderers per AdornmentType (LINK, CHIP, FILE_DOWNLOAD, etc.)
|
||||
- Basic filter (quick text search) and advanced filter builder (all 20 operators, AND/OR, sub-filters)
|
||||
- Column config (show/hide, reorder, persist to localStorage)
|
||||
- Row selection (checkbox column, single and multi)
|
||||
- Export to CSV
|
||||
- Saved views (save/load filter + column configs)
|
||||
- Pagination with page size selector (10/25/50/100)
|
||||
- Table variant selection
|
||||
- Toolbar (create button, process launcher, column config, density, saved views)
|
||||
- Row click → navigate to record view
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria in Section 9 of the plan.
|
||||
Final check: `pnpm build && pnpm test`
|
||||
Commit your work.
|
||||
|
||||
## Important Rules
|
||||
|
||||
- All API calls through `src/lib/api/` — create `src/lib/api/tables.ts` for query/count functions
|
||||
- Use TanStack Table v8 (NOT MUI DataGrid)
|
||||
- Use TanStack Query v5 for data fetching
|
||||
- Every component gets `data-qqq-id` attribute
|
||||
- No hardcoded column names — everything from metadata
|
||||
- TypeScript strict — no `any` in props
|
||||
@@ -0,0 +1,66 @@
|
||||
# Agent Prompt: Package 3 — Record View and CRUD
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute Work Package 3. Follow the plan exactly.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/03-record-view-and-crud.md`
|
||||
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
|
||||
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
5. **Project overview:** `docs/implementation-plans/00-project-overview.md`
|
||||
6. **CLAUDE.md**
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/pages/records/view/RecordView.tsx` — Section rendering, T1/non-T1, widget loading, permission-gated action menu, keyboard shortcuts (n/e/c/d/a)
|
||||
- `src/qqq/components/forms/EntityForm.tsx` — Form orchestrator with section-based layout
|
||||
- `src/qqq/components/forms/DynamicForm.tsx` — Formik-based form with dynamic fields
|
||||
- `src/qqq/components/forms/DynamicFormField.tsx` — Per-field renderer by type
|
||||
- `src/qqq/components/forms/DynamicSelect.tsx` — Possible values autocomplete
|
||||
- `src/qqq/components/forms/BooleanFieldSwitch.tsx` — Boolean null/false/true
|
||||
- `src/qqq/components/forms/FileInputField.tsx` — File upload
|
||||
|
||||
From `qqq-frontend-core`:
|
||||
- `src/controllers/QController.ts` — `get()`, `create()`, `update()`, `delete()` methods
|
||||
- `src/model/metaData/QFieldMetaData.ts` — Field type, adornments, constraints
|
||||
|
||||
## What Package 1 Already Provides (Import From It)
|
||||
|
||||
- Types: `import type { QTableMetaData, QFieldMetaData, QRecord, QPossibleValue, QFieldType } from '@/types'`
|
||||
- API client: `import apiClient from '@/lib/api/client'`
|
||||
- Metadata: `import { loadTableMetaData } from '@/lib/api/metadata'`
|
||||
- Context: `import { useQContext } from '@/lib/context/q-context'`
|
||||
|
||||
## What You Must Build
|
||||
|
||||
1. **Record View page** at `src/app/(dashboard)/app/[tableName]/[recordId]/page.tsx`
|
||||
- Section-organized field display (T1 section at top, others below)
|
||||
- Field value rendering for ALL 12 QFieldTypes
|
||||
- All adornment types (LINK, CHIP, SIZE, FILE_DOWNLOAD, TOOLTIP, etc.)
|
||||
- Action menu (edit, delete, copy, launch process)
|
||||
- Associated records display
|
||||
- Keyboard shortcuts (n=new, e=edit, c=copy, d=delete)
|
||||
|
||||
2. **Entity Form** at `src/components/forms/EntityForm.tsx`
|
||||
- Dynamic form generation from QFieldMetaData
|
||||
- React Hook Form + Zod validation (schemas from metadata: required, maxLength, type)
|
||||
- All field types: text, number, boolean, date, time, datetime, select, file, HTML, password
|
||||
- Possible values autocomplete with debounced search (300ms)
|
||||
- Section-based layout with gridColumns
|
||||
- File upload with preview
|
||||
|
||||
3. **Create/Edit/Copy pages** using EntityForm
|
||||
4. **Delete** with confirmation dialog
|
||||
5. **API functions** in `src/lib/api/tables.ts`: getRecord, insertRecord, updateRecord, deleteRecord
|
||||
6. **Possible values** in `src/lib/api/possible-values.ts`: fetchPossibleValues
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria.
|
||||
Final: `pnpm build && pnpm test`
|
||||
Commit your work.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Agent Prompt: Package 4 — Process Execution
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute Work Package 4. Follow the plan exactly.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/04-process-execution.md`
|
||||
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
|
||||
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
5. **CLAUDE.md**
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/pages/processes/ProcessRun.tsx` — THE process page. Read the whole thing. Note: step state machine, 14 component types, async job polling with exponential backoff (1.5s initial, 1.5x multiplier, 12s max), file upload, record selection modes
|
||||
- `src/qqq/components/processes/` — All process components
|
||||
- `src/qqq/components/processes/ProcessViewForm.tsx` — View-only form steps
|
||||
- `src/qqq/components/processes/ProcessSummaryResults.tsx` — Results display
|
||||
- `src/qqq/components/processes/ValidationReview.tsx` — Pre-commit validation
|
||||
- `src/qqq/components/processes/BulkLoadFileMappingForm.tsx` — Column-to-field mapping
|
||||
- `src/qqq/components/processes/BulkLoadValueMappingForm.tsx` — Value mapping
|
||||
- `src/qqq/components/processes/SavedBulkLoadProfiles.tsx` — Profile save/load
|
||||
|
||||
From `qqq-frontend-core`:
|
||||
- `src/controllers/QController.ts` — processInit, processStep, processJobStatus, processRecords, processCancel
|
||||
- `src/model/processes/` — QJobStarted, QJobRunning, QJobComplete, QJobError, ProcessMetaDataAdjustment
|
||||
|
||||
## What Previous Packages Provide (Import From Them)
|
||||
|
||||
From Package 1: types, API client, context, layout
|
||||
From Package 3 (CRITICAL — reuse these):
|
||||
- `import { EntityForm } from '@/components/forms/EntityForm'` — For form steps
|
||||
- `import { DynamicFormField } from '@/components/forms/DynamicFormField'` — For individual fields
|
||||
- `import { fetchPossibleValues } from '@/lib/api/possible-values'` — For select fields in process forms
|
||||
- Form validation patterns from Package 3
|
||||
|
||||
## What You Must Build
|
||||
|
||||
1. **Process Run page** at `src/app/(dashboard)/app/[processName]/page.tsx`
|
||||
- Step wizard with stepper UI showing progress
|
||||
- Dynamic step rendering from QFrontendStepMetaData.components
|
||||
|
||||
2. **All 14 component types:** HELP_TEXT, BULK_EDIT_FORM, BULK_LOAD_FILE_MAPPING_FORM, BULK_LOAD_VALUE_MAPPING_FORM, BULK_LOAD_PROFILE_FORM, VALIDATION_REVIEW_SCREEN, EDIT_FORM, VIEW_FORM, DOWNLOAD_FORM, RECORD_LIST, PROCESS_SUMMARY_RESULTS, GOOGLE_DRIVE_SELECT_FOLDER, WIDGET, HTML
|
||||
|
||||
3. **Async job handling:**
|
||||
- Detect QJobStarted → start polling GET /status/{jobUUID}
|
||||
- Exponential backoff: 1.5s initial, 1.5x multiplier, 12s max cap
|
||||
- Display QJobRunning progress (message, current/total)
|
||||
- Handle QJobComplete (advance to next step or show results)
|
||||
- Handle QJobError (display error, allow retry)
|
||||
|
||||
4. **Bulk load workflows:** file upload, column-to-field mapping, value mapping, profile save/load
|
||||
|
||||
5. **Process records** endpoint for paginated result viewing
|
||||
|
||||
6. **Process cancellation**
|
||||
|
||||
7. **Both launch modes:** standalone (/app/{processName}) and table-scoped (with recordIds or filterJSON)
|
||||
|
||||
8. **API functions** in `src/lib/api/processes.ts`: processInit, processStep, processStatus, processRecords, processCancel
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria.
|
||||
Final: `pnpm build && pnpm test`
|
||||
Commit your work.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Agent Prompt: Package 5 — Dashboard and Widgets
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute Work Package 5. Follow the plan exactly.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/05-dashboard-and-widgets.md`
|
||||
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
|
||||
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
|
||||
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
5. **CLAUDE.md**
|
||||
|
||||
## Reference Code (Read for Behavior Parity)
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/components/widgets/Widget.tsx` — Widget container (label, dropdown, reload, export, help)
|
||||
- `src/qqq/components/widgets/DashboardWidgets.tsx` — Widget grid orchestrator
|
||||
- `src/qqq/components/widgets/CompositeWidget.tsx` — Multi-widget container
|
||||
- `src/qqq/components/widgets/ParentWidget.tsx` — Parent managing children
|
||||
- `src/qqq/components/widgets/RecordGridWidget.tsx` — Embedded DataGrid with CRUD
|
||||
- `src/qqq/components/widgets/charts/` — All chart components
|
||||
- `src/qqq/components/widgets/blocks/` — All block renderers
|
||||
- `src/qqq/pages/apps/AppHome.tsx` — App home page with dashboard layout
|
||||
|
||||
## What Previous Packages Provide (Import From Them)
|
||||
|
||||
From Package 1: types, API client, context, layout
|
||||
From Package 2 (reuse):
|
||||
- `import { DataGrid } from '@/components/query/DataGrid'` — For RecordGridWidget
|
||||
- Cell renderers for field value display in widgets
|
||||
From Package 3 (reuse):
|
||||
- `import { EntityForm } from '@/components/forms/EntityForm'` — For DynamicFormWidget
|
||||
- `import { fetchPossibleValues } from '@/lib/api/possible-values'` — For widget dropdown options
|
||||
|
||||
## What You Must Build
|
||||
|
||||
1. **App Home page** at `src/app/(dashboard)/app/[appName]/page.tsx`
|
||||
- Dashboard layout with widget grid
|
||||
- Widgets loaded from QAppMetaData.widgets
|
||||
|
||||
2. **Widget container** — label, dropdown selectors, reload, export, help
|
||||
- Dropdown changes trigger cancel-and-replace data fetch (AbortController)
|
||||
|
||||
3. **DashboardWidgets orchestrator** — loads multiple widgets, manages lifecycle
|
||||
|
||||
4. **Charts (Recharts):** line, bar, horizontal bar, stacked bar, pie/doughnut
|
||||
- Use ResponsiveContainer for all charts
|
||||
|
||||
5. **Statistics:** MiniStatisticsCard, MultiStatisticsCard, StatisticsCard
|
||||
|
||||
6. **All block types:** TextBlock, BigNumberBlock, UpOrDownNumberBlock, ProgressBarBlock, ButtonBlock, IconBlock, ImageBlock, AudioBlock, DividerBlock, InputFieldBlock
|
||||
|
||||
7. **RecordGridWidget** — embedded DataGrid (reuse from P2) with add/edit/delete
|
||||
|
||||
8. **Composite/Parent widgets**
|
||||
|
||||
9. **Specialized widgets:** PivotTableSetup, FilterAndColumnsSetup, StepperCard, CronUI, ScriptViewer, CustomComponent mount point
|
||||
|
||||
10. **API function** in `src/lib/api/widgets.ts`: fetchWidgetData with AbortController cancel-and-replace
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria.
|
||||
Final: `pnpm build && pnpm test`
|
||||
Commit your work.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Agent Prompt: Package 6 — Polish, Parity, and Production Readiness
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an implementation agent. Execute Work Package 6 — the final package. ALL previous packages (1-5) are complete. Your job is polish, parity, and production readiness.
|
||||
|
||||
## Before Writing Any Code, Read These Files
|
||||
|
||||
1. **Your plan:** `docs/implementation-plans/06-polish-and-parity.md`
|
||||
2. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
|
||||
3. **CLAUDE.md**
|
||||
4. **Requirements doc Section 5.4 (responsive), 5.6 (performance), 5.7 (DX):** `docs/qqq-admin-modernization-requirements.md`
|
||||
|
||||
## Reference Code
|
||||
|
||||
From `qqq-frontend-material-dashboard`:
|
||||
- `src/qqq/components/misc/CommandMenu.tsx` — Command palette (Cmd+K)
|
||||
- `src/qqq/components/audits/AuditBody.tsx` — Audit trail display
|
||||
- `src/qqq/pages/records/developer/RecordDeveloperView.tsx` — Developer tools
|
||||
- `src/qqq/pages/records/developer/TableDeveloperView.tsx` — Table dev view
|
||||
- `src/qqq/components/scripts/ScriptViewer.tsx` — Code editor
|
||||
|
||||
## What You Must Build
|
||||
|
||||
### Responsive Design Audit & Fixes
|
||||
- Mobile card view for Record Query (< 768px)
|
||||
- Off-canvas sidebar drawer on mobile with hamburger trigger
|
||||
- Bottom sheet filter panel on mobile
|
||||
- 44px minimum tap targets everywhere
|
||||
- Sticky headers and bottom-anchored action buttons on mobile
|
||||
- Breadcrumb truncation on mobile
|
||||
- Responsive widget grid (1/2/N columns)
|
||||
|
||||
### Accessibility (WCAG 2.1 AA)
|
||||
- axe-core integration in test suite
|
||||
- Focus management in modals (trap + restore)
|
||||
- aria-live regions for toasts and loading states
|
||||
- Color contrast audit (4.5:1 / 3:1)
|
||||
- Skip-to-content link
|
||||
- Keyboard navigation for all interactive elements
|
||||
|
||||
### Command Palette
|
||||
- Cmd+K / Ctrl+K trigger
|
||||
- Search tables, processes, reports from metadata
|
||||
- Recently accessed items
|
||||
- Keyboard navigation (arrow keys, Enter)
|
||||
|
||||
### Keyboard Shortcuts
|
||||
- Global: Cmd+K, ?, Escape
|
||||
- Record Query: n, /, f
|
||||
- Record View: n, e, c, d, a
|
||||
- Shortcuts reference panel
|
||||
|
||||
### Audit Trail
|
||||
- Audit log panel on Record View
|
||||
- Timeline with field-level changes
|
||||
- Filter by date/user/field
|
||||
|
||||
### Developer Tools
|
||||
- Table dev view: metadata inspector
|
||||
- Record dev view: raw values, scripts, logs
|
||||
- Script editor (CodeMirror or Monaco)
|
||||
- Script test execution
|
||||
|
||||
### Report Pages
|
||||
- Reuse ProcessRun from Package 4
|
||||
|
||||
### Storybook
|
||||
- Setup with all shared components
|
||||
- Stories for loading/empty/error/populated states
|
||||
- a11y addon
|
||||
|
||||
### Playwright E2E
|
||||
- Auth flow, query, CRUD, process, widgets
|
||||
- Responsive viewport tests
|
||||
- Error handling tests
|
||||
|
||||
### Performance
|
||||
- Bundle analysis (next-bundle-analyzer)
|
||||
- Lazy loading for charts, code editor, file upload
|
||||
- TanStack Query cache tuning
|
||||
- Lighthouse CI integration
|
||||
|
||||
### Theme Tokens
|
||||
- All 60+ CSS custom properties
|
||||
- Light/dark mode with localStorage persistence
|
||||
- data-qqq-id verification on ALL elements
|
||||
- customCss injection support
|
||||
|
||||
### Session Timeout UX
|
||||
- Re-auth modal (not full redirect)
|
||||
- Preserve form state across re-auth
|
||||
|
||||
## Verification
|
||||
|
||||
After each step: `pnpm tsc --noEmit`
|
||||
When done: verify ALL acceptance criteria in the plan.
|
||||
Final checks:
|
||||
```bash
|
||||
pnpm build # Must succeed
|
||||
pnpm test # All tests pass
|
||||
pnpm lint # Zero errors
|
||||
pnpm tsc --noEmit # Zero errors
|
||||
```
|
||||
Commit your work.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Project Overview: QQQ Admin UI Modernization
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework:** Next.js 15+ (App Router) with React 19
|
||||
- **Language:** TypeScript 5.x (strict mode)
|
||||
- **Styling:** Tailwind CSS 4.x with CSS custom properties for QQQ theme tokens
|
||||
- **Component Library:** shadcn/ui (Radix primitives + Tailwind styling)
|
||||
- **Data Grid:** TanStack Table v8 with virtual scrolling (replaces MUI X DataGrid Pro)
|
||||
- **Forms:** React Hook Form v7 + Zod validation (replaces Formik + Yup)
|
||||
- **Data Fetching:** TanStack Query v5 (React Query) for server state
|
||||
- **Charts:** Recharts (replaces Chart.js) — React-native, composable, SSR-compatible
|
||||
- **Testing:** Vitest (unit/component), Playwright (E2E), axe-core (accessibility)
|
||||
- **Build:** Turbopack (dev), Webpack (production via Next.js)
|
||||
- **Linting:** ESLint + Prettier with project-standard config
|
||||
- **Package Manager:** pnpm
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
qqq-frontend-next/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ │ ├── (auth)/ # Auth route group (no layout)
|
||||
│ │ │ ├── login/page.tsx # Login page
|
||||
│ │ │ └── callback/page.tsx # OAuth callback handler
|
||||
│ │ ├── (dashboard)/ # Authenticated route group
|
||||
│ │ │ ├── layout.tsx # Dashboard layout (sidebar + nav + banners)
|
||||
│ │ │ └── app/ # All app routes
|
||||
│ │ │ ├── [appName]/
|
||||
│ │ │ │ └── page.tsx # AppHome (dashboard/widgets)
|
||||
│ │ │ ├── [tableName]/
|
||||
│ │ │ │ ├── page.tsx # RecordQuery (list)
|
||||
│ │ │ │ ├── savedView/[viewId]/page.tsx # Saved view query
|
||||
│ │ │ │ ├── create/page.tsx # Create record
|
||||
│ │ │ │ ├── key/page.tsx # View by unique key
|
||||
│ │ │ │ ├── dev/page.tsx # Table developer view
|
||||
│ │ │ │ └── [recordId]/
|
||||
│ │ │ │ ├── page.tsx # RecordView
|
||||
│ │ │ │ ├── edit/page.tsx # Edit record
|
||||
│ │ │ │ ├── copy/page.tsx # Copy record
|
||||
│ │ │ │ ├── dev/page.tsx # Record developer view
|
||||
│ │ │ │ └── createChild/[childTable]/page.tsx
|
||||
│ │ │ └── [processName]/
|
||||
│ │ │ └── page.tsx # ProcessRun (standalone)
|
||||
│ │ ├── layout.tsx # Root layout (providers, global styles)
|
||||
│ │ ├── not-found.tsx # 404 page
|
||||
│ │ └── error.tsx # Global error boundary
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui primitives (Button, Input, Dialog, etc.)
|
||||
│ │ ├── layout/ # Sidebar, Navbar, Breadcrumbs, BrandedHeader, Footer
|
||||
│ │ ├── records/ # RecordView, FieldValue, RecordSidebar
|
||||
│ │ ├── forms/ # DynamicForm, EntityForm, field-type components
|
||||
│ │ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination, Export
|
||||
│ │ ├── process/ # StepWizard, ValidationReview, BulkLoad, ProcessResults
|
||||
│ │ ├── widgets/ # Widget, DashboardWidgets, all chart/block/grid widgets
|
||||
│ │ └── feedback/ # Alerts, Toasts, Modals, HelpContent, CommandMenu
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Typed API client modules
|
||||
│ │ │ ├── client.ts # Base axios/fetch client with auth interceptor
|
||||
│ │ │ ├── auth.ts # getAuthenticationMetaData, manageSession, logout
|
||||
│ │ │ ├── metadata.ts # loadMetaData, loadTableMetaData, loadProcessMetaData
|
||||
│ │ │ ├── tables.ts # queryRecords, countRecords, getRecord, insertRecord, updateRecord, deleteRecord
|
||||
│ │ │ ├── processes.ts # processInit, processStep, processStatus, processRecords, processCancel
|
||||
│ │ │ ├── widgets.ts # fetchWidgetData
|
||||
│ │ │ ├── possible-values.ts # fetchPossibleValues (table, process, standalone contexts)
|
||||
│ │ │ └── developer.ts # developer mode, scripts, logs
|
||||
│ │ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
|
||||
│ │ │ ├── auth-provider.tsx # AuthContext + provider component
|
||||
│ │ │ ├── use-auth.ts # useAuth() hook
|
||||
│ │ │ ├── oauth2.ts # OAuth2/OIDC flow
|
||||
│ │ │ ├── auth0.ts # Auth0 integration
|
||||
│ │ │ └── anonymous.ts # Anonymous/mock auth
|
||||
│ │ ├── hooks/ # Shared React hooks
|
||||
│ │ │ ├── use-metadata.ts # useMetaData(), useTableMetaData(), useProcessMetaData()
|
||||
│ │ │ ├── use-query-records.ts # useQueryRecords() with TanStack Query
|
||||
│ │ │ ├── use-record.ts # useRecord() single record fetch
|
||||
│ │ │ ├── use-process.ts # useProcess() step machine
|
||||
│ │ │ ├── use-widget.ts # useWidget() with abort/cancel
|
||||
│ │ │ ├── use-possible-values.ts # usePossibleValues() with debounce
|
||||
│ │ │ ├── use-saved-views.ts # useSavedViews() CRUD
|
||||
│ │ │ └── use-local-storage.ts # useLocalStorage() typed wrapper
|
||||
│ │ ├── utils/ # Pure utility functions
|
||||
│ │ │ ├── filter-utils.ts # QQueryFilter builders and validators
|
||||
│ │ │ ├── value-utils.ts # Display value formatting per field type
|
||||
│ │ │ ├── table-utils.ts # Table section/field helpers
|
||||
│ │ │ ├── process-utils.ts # Process navigation helpers
|
||||
│ │ │ ├── export-utils.ts # CSV/Excel export
|
||||
│ │ │ └── responsive-utils.ts # Breakpoint helpers
|
||||
│ │ └── theme/ # Theming system
|
||||
│ │ ├── theme-provider.tsx # ThemeContext (light/dark, accent colors)
|
||||
│ │ ├── tokens.ts # CSS custom property definitions (60+ tokens)
|
||||
│ │ └── tailwind-plugin.ts # Tailwind plugin for QQQ theme tokens
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ │ ├── index.ts # Re-exports all types
|
||||
│ │ ├── metadata.ts # QInstance, QTableMetaData, QFieldMetaData, QProcessMetaData, etc.
|
||||
│ │ ├── records.ts # QRecord, QPossibleValue
|
||||
│ │ ├── query.ts # QQueryFilter, QFilterCriteria, QCriteriaOperator, etc.
|
||||
│ │ ├── processes.ts # QJobStarted, QJobRunning, QJobComplete, QJobError
|
||||
│ │ ├── widgets.ts # Widget data types
|
||||
│ │ └── enums.ts # QFieldType, QComponentType, AdornmentType, Capability, QAppNodeType
|
||||
│ └── styles/
|
||||
│ ├── globals.css # Tailwind directives + CSS custom properties
|
||||
│ └── qqq-theme.css # QQQ-specific theme tokens
|
||||
├── public/ # Static assets
|
||||
├── .storybook/ # Storybook configuration
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ └── setup.ts # Vitest setup
|
||||
├── next.config.ts # Next.js configuration
|
||||
├── tailwind.config.ts # Tailwind configuration
|
||||
├── tsconfig.json # TypeScript config (strict: true)
|
||||
├── vitest.config.ts # Vitest configuration
|
||||
├── playwright.config.ts # Playwright configuration
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Package 1 (Scaffold + Auth + Layout Shell)
|
||||
│
|
||||
├──→ Package 2 (Record Query)
|
||||
│ │
|
||||
│ ├──→ Package 4 (Process Execution) ←── Package 3 (Record View + CRUD)
|
||||
│ │ │
|
||||
│ └──→ Package 5 (Dashboard + Widgets) ←──────┘
|
||||
│
|
||||
└──→ Package 3 (Record View + CRUD)
|
||||
│
|
||||
└──→ Package 6 (Polish + Parity) [depends on ALL above]
|
||||
```
|
||||
|
||||
Packages 2 and 3 can execute in parallel after Package 1 completes.
|
||||
Package 4 requires form components from Package 3.
|
||||
Package 5 requires the data grid from Package 2 and form components from Package 3.
|
||||
Package 6 requires everything.
|
||||
|
||||
## Work Package Summary
|
||||
|
||||
| Package | Name | Est. Duration | Prerequisites | Key Deliverables |
|
||||
|---------|------|---------------|---------------|------------------|
|
||||
| 1 | Scaffold + Auth + Layout Shell | 4 weeks | None | Project setup, types, API client, auth flow, sidebar/nav, theming, placeholder routes |
|
||||
| 2 | Record Query | 6 weeks | Package 1 | Data grid, filters (basic/advanced), column config, pagination, saved views, export |
|
||||
| 3 | Record View + CRUD | 6 weeks | Package 1 | Record detail view, entity form (all 12 field types), create/edit/copy/delete |
|
||||
| 4 | Process Execution | 5 weeks | Packages 1, 3 | Step wizard, async job polling, bulk load, validation review, process results |
|
||||
| 5 | Dashboard + Widgets | 4 weeks | Packages 1, 2, 3 | App home, widget system, charts, statistics, record grid widget, block types |
|
||||
| 6 | Polish + Parity | 5 weeks | All above | Responsive audit, a11y, performance, Storybook, E2E tests, dev tools, command palette |
|
||||
|
||||
## Shared Conventions
|
||||
|
||||
### Metadata-Driven Rendering
|
||||
|
||||
- ALL components that render from metadata MUST accept the metadata type as a prop — never fetch it internally
|
||||
- No hardcoded table names, field names, or process names anywhere in the UI code
|
||||
- All user-facing text from metadata uses the `label` property, never `name`
|
||||
- UI structure (navigation, pages, forms, columns) derives entirely from backend metadata
|
||||
|
||||
### API Integration
|
||||
|
||||
- All API calls go through typed client functions in `src/lib/api/` — never raw `fetch` or `axios`
|
||||
- TanStack Query manages all server state caching, deduplication, and revalidation
|
||||
- Query keys follow convention: `['metadata']`, `['tableMetadata', tableName]`, `['records', tableName, filterHash]`
|
||||
- Mutations use `useMutation` with `onSuccess` invalidation of affected queries
|
||||
- 401 responses intercepted globally → redirect to login preserving current URL
|
||||
|
||||
### Component Patterns
|
||||
|
||||
- Server Components used for: root layouts, metadata-fetching route pages
|
||||
- Client Components used for: interactive UI (forms, grids, filters, modals)
|
||||
- Mark client components with `"use client"` directive at file top
|
||||
- Every interactive component must have a `data-qqq-id` attribute for CSS customization hooks
|
||||
- Naming convention: `data-qqq-id="{component-type}-{identifier}"` (e.g., `data-qqq-id="button-save"`, `data-qqq-id="field-firstName"`)
|
||||
|
||||
### State Management
|
||||
|
||||
- Server state: TanStack Query (data from API)
|
||||
- UI state: React context (theme, sidebar open/closed, modal stack, current user)
|
||||
- Form state: React Hook Form (form values, validation, dirty tracking)
|
||||
- URL state: Next.js searchParams (filters, pagination, sort — shareable URLs)
|
||||
- Local persistence: localStorage (density, column widths, saved view selections, widget dropdown choices)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Error boundaries at: route level (Next.js error.tsx), widget level (per-widget ErrorBoundary)
|
||||
- API errors: caught in TanStack Query `onError` → displayed via toast notification
|
||||
- Form validation: Zod schemas generated from field metadata (required, maxLength, type)
|
||||
- Loading states: Skeleton components matching final layout shape
|
||||
- Empty states: Descriptive messages with action buttons ("No records found. Create one?")
|
||||
|
||||
### Accessibility (WCAG 2.1 AA)
|
||||
|
||||
- All form inputs: `<label>` element with `htmlFor` or wrapping input, `aria-required`, `aria-invalid`
|
||||
- All buttons: descriptive text or `aria-label`
|
||||
- Focus management: trap focus in modals, restore on close
|
||||
- Keyboard navigation: all interactive elements reachable via Tab, actions via Enter/Space
|
||||
- Color contrast: minimum 4.5:1 for normal text, 3:1 for large text
|
||||
- Screen reader: `aria-live` regions for dynamic content updates (toasts, loading states)
|
||||
|
||||
### CSS Customization
|
||||
|
||||
- 60+ CSS custom properties (tokens) for theming — defined in `src/styles/qqq-theme.css`
|
||||
- `data-qqq-id` attributes on all interactive elements enable `customCss` injection
|
||||
- Light/dark mode via `prefers-color-scheme` media query + manual toggle stored in localStorage
|
||||
- Tailwind classes for all layout/spacing; CSS custom properties for brand colors only
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
- Components: PascalCase (`RecordQuery.tsx`, `FilterBuilder.tsx`)
|
||||
- Hooks: camelCase with `use-` prefix (`use-metadata.ts`, `use-query-records.ts`)
|
||||
- Utilities: kebab-case (`filter-utils.ts`, `value-utils.ts`)
|
||||
- Types: kebab-case (`metadata.ts`, `records.ts`)
|
||||
- API modules: kebab-case (`possible-values.ts`, `auth.ts`)
|
||||
- Tests: `*.test.ts(x)` co-located with source or in `tests/` directory
|
||||
|
||||
### Import Organization
|
||||
|
||||
```typescript
|
||||
// 1. React/Next.js
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// 2. External libraries
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
// 3. Internal: types
|
||||
import type { QTableMetaData, QRecord } from '@/types'
|
||||
|
||||
// 4. Internal: lib (API, hooks, utils)
|
||||
import { queryRecords } from '@/lib/api/tables'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
|
||||
// 5. Internal: components
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataGrid } from '@/components/query/DataGrid'
|
||||
```
|
||||
@@ -0,0 +1,565 @@
|
||||
# Requirements Coverage Cross-Check Report
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Date:** 2026-02-25
|
||||
**Status:** COMPLETE COVERAGE VERIFIED
|
||||
**Reviewed Sections:** Section 3 (API Contract) and Section 5 (New Admin UI Requirements)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report validates that **EVERY subsection** of Section 5 (New Admin UI Requirements) and Section 3 (API Contract) from the QQQ Admin UI Modernization Requirements document appears in at least one of the six work packages.
|
||||
|
||||
**Result:** ✅ **100% COVERAGE** - All requirements are mapped to work packages with clear ownership and no gaps.
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Semantic API Contract v1.0 Coverage
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Get Authentication Metadata | 3.2.1 | `GET /qqq/v1/metaData/authentication` (unsecured) | Package 1 (Auth Provider) | ✅ |
|
||||
| Create/Manage Session | 3.2.2 | `POST /qqq/v1/manageSession` with accessToken | Package 1 (Auth Provider) | ✅ |
|
||||
| Logout | 3.2.3 | `POST /qqq/v1/logout` clears session cookie | Package 1 (Auth Provider) | ✅ |
|
||||
| OIDC Back-Channel Logout | 3.2.4 | `POST /qqq/v1/oidc/backchannel-logout` | Package 1 (Auth Provider) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 3-4 provides complete auth interceptor, session management, and logout handling.
|
||||
|
||||
---
|
||||
|
||||
### Metadata Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Application Metadata | 3.3.1 | `GET /qqq/v1/metaData` returns QInstance | Package 1 (API Client) | ✅ |
|
||||
| Table Metadata | 3.3.2 | `GET /qqq/v1/metaData/table/{tableName}` returns QTableMetaData | Package 1 (API Client), Package 2 | ✅ |
|
||||
| Process Metadata | 3.3.3 | `GET /qqq/v1/metaData/process/{processName}` returns QProcessMetaData | Package 1 (API Client), Package 4 | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 3 and later steps in Packages 2/4 handle all metadata loading.
|
||||
|
||||
---
|
||||
|
||||
### Table Data Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Query Records | 3.4.1 | `POST /qqq/v1/table/{tableName}/query` with QQueryFilter | Package 2 (Step 2) | ✅ |
|
||||
| Count Records | 3.4.2 | `POST /qqq/v1/table/{tableName}/count` with optional includeDistinct | Package 2 (Step 2) | ✅ |
|
||||
| Get Single Record | 3.4.3 | `GET /qqq/v1/table/{tableName}/{primaryKey}` with tableVariant, includeAssociations | Package 3 (Step 1) | ✅ |
|
||||
| Insert Record | 3.4.4 | `POST /qqq/v1/table/{tableName}` with multipart/form-data | Package 3 (Step 1) | ✅ |
|
||||
| Update Record | 3.4.5 | `PUT /qqq/v1/table/{tableName}/{primaryKey}` with multipart/form-data | Package 3 (Step 1) | ✅ |
|
||||
| Delete Record | 3.4.6 | `DELETE /qqq/v1/table/{tableName}/{primaryKey}` | Package 3 (Step 1) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 handles query/count, Package 3 handles get/create/update/delete with full form integration.
|
||||
|
||||
---
|
||||
|
||||
### Process Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Initialize Process | 3.5.1 | `POST /qqq/v1/processes/{processName}/init` with values, recordsParam, file | Package 4 (Step 1) | ✅ |
|
||||
| Process Step | 3.5.2 | `POST /qqq/v1/processes/{processName}/{processUUID}/step/{stepName}` | Package 4 (Step 1) | ✅ |
|
||||
| Process Status (Poll) | 3.5.3 | `GET /qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}` | Package 4 (Step 1) | ✅ |
|
||||
| Process Records | 3.5.4 | `GET /qqq/v1/processes/{processName}/{processUUID}/records` with skip/limit | Package 4 (Step 1) | ✅ |
|
||||
| Cancel Process | 3.5.5 | `GET /qqq/v1/processes/{processName}/{processUUID}/cancel` | Package 4 (Step 1) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 4 Step 1 implements complete process API client with all endpoints and job polling.
|
||||
|
||||
---
|
||||
|
||||
### Additional Endpoints
|
||||
|
||||
| Requirement | Section | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Widget Endpoint | 3.6 | `GET /qqq/v1/widget/{widgetName}` with dynamic query params | Package 5 (Step 2) | ✅ |
|
||||
| Possible Values Endpoints | 3.7 | `POST /qqq/v1/table/{t}/possibleValues/{f}`, `/processes/{p}/possibleValues/{f}`, `/possibleValues/{f}` | Package 2 (Step 5), Package 3 (Step 1), Package 4 | ✅ |
|
||||
| Error Response Shape | 3.8 | Consistent error format with HTTP status codes | All packages (API client) | ✅ |
|
||||
| Pagination Contract | 3.9 | offset/limit based with skip and limit | Package 2 (Step 6) | ✅ |
|
||||
| Filtering Contract | 3.10 | Tree-structured QQueryFilter with boolean operators, subFilters, expressions | Package 2 (Step 4-5) | ✅ |
|
||||
| Backwards Compatibility Policy | 3.11 | SemVer with PATCH/MINOR/MAJOR deprecation cycles | Package 6 (documentation) | ✅ |
|
||||
|
||||
**Implementation Status:** Complete coverage across all packages.
|
||||
|
||||
---
|
||||
|
||||
## Section 5: New Admin UI Requirements Coverage
|
||||
|
||||
### 5.1: Core Principles
|
||||
|
||||
| Requirement | Details | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| 100% feature parity | Every page, component, interaction in qfmd | All Packages 1-6 | ✅ |
|
||||
| Responsive design | Fully functional at mobile/tablet/desktop | Packages 1, 2, 3, 4, 5, 6 | ✅ |
|
||||
| Metadata-driven rendering | No hardcoded tables, fields, pages | All Packages | ✅ |
|
||||
| WCAG 2.1 AA accessible | Keyboard navigation, ARIA, color contrast | Package 6 (Steps 7-13) | ✅ |
|
||||
| Themeable | Light/dark mode, custom brand colors, CSS variables | Package 1 (Step 9), Package 6 (Step 26) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
### 5.2: Page Inventory
|
||||
|
||||
#### 5.2.1 Authentication Pages
|
||||
|
||||
| Page | Route | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Login | `/login` | Package 1 (Step 4, Step 11) | ✅ |
|
||||
| No-Auth Screen | `/no-auth-screen` | Package 1 (Step 11) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 11 creates login page with error handling.
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.2 App Home Pages
|
||||
|
||||
| Page | Route | Components | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| App Dashboard/Home | `/app/{appName}` | Dashboard widgets, record counts, navigation | Package 5 (Step 12) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 5 Step 12 implements App Home page with full widget orchestration.
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.3 Record Pages
|
||||
|
||||
| Page | Route | Key Elements | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Record Query/List | `/app/{table}` | DataGrid, filters, pagination, toolbar | Package 2 (Steps 1-11) | ✅ |
|
||||
| Saved View | `/app/{table}/savedView/:id` | Pre-filtered query with saved config | Package 2 (Step 10) | ✅ |
|
||||
| Create Record | `/app/{table}/create` | Multi-section form, file upload, validation | Package 3 (Steps 5-6) | ✅ |
|
||||
| View Single Record | `/app/{table}/:id` | Record header, sections, widgets, audit trail | Package 3 (Step 6) | ✅ |
|
||||
| Edit Record | `/app/{table}/:id/edit` | Form with pre-populated values | Package 3 (Steps 5-6) | ✅ |
|
||||
| Copy Record | `/app/{table}/:id/copy` | Form with copied values (no ID) | Package 3 (Steps 5-6) | ✅ |
|
||||
| View by Key | `/app/{table}/key` | Record lookup by unique key | Package 3 (Step 6) | ✅ |
|
||||
| Table Developer View | `/app/{table}/dev` | Metadata inspector | Package 6 (Step 17) | ✅ |
|
||||
| Record Developer View | `/app/{table}/:id/dev` | Script editor, logs, test execution | Package 6 (Step 18) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 handles query/list (Steps 1-11), Package 3 handles create/view/edit/copy (Steps 5-6), Package 6 handles developer views (Steps 17-18).
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.4 Process Pages
|
||||
|
||||
| Page | Route | Components | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Standalone Process | `/app/{processName}` | Step wizard, forms, validation, results | Package 4 (Steps 2-8) | ✅ |
|
||||
| Table-Scoped Process | `/app/{table}/{processName}` | Same as standalone with record context | Package 4 (Steps 2-8) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 4 Steps 2-8 implement ProcessRun component for both standalone and embedded contexts.
|
||||
|
||||
---
|
||||
|
||||
#### 5.2.5 Report Pages
|
||||
|
||||
| Page | Route | Components | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Report Execution | `/app/{reportName}` | Report config form, output display | Package 6 (Step 19) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Step 19 reuses ProcessRun for report execution.
|
||||
|
||||
---
|
||||
|
||||
### 5.3: Component Inventory
|
||||
|
||||
#### 5.3.1 Layout Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Side Navigation | Hierarchical from appTree, collapsible, mini-mode, keyboard nav | Package 1 (Steps 7-8) | ✅ |
|
||||
| Top Navigation | Breadcrumbs, user menu, sidebar toggle, search | Package 1 (Steps 7-8), Package 6 (Step 14) | ✅ |
|
||||
| Header Banner | Custom branding, environment banners, dismissible | Package 1 (Step 11) | ✅ |
|
||||
| Footer | Minimal, optional | Package 1 (Step 7) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Steps 7-8 implement Sidebar, Header, Breadcrumbs. Package 6 Step 14 adds Command Palette.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.2 Data Display Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Data Grid | Server-side pagination, sorting, filtering, column reordering, row selection, density control, export | Package 2 (Steps 1-11) | ✅ |
|
||||
| Record Detail View | Section-organized fields, field adornments, audit trail, associated records | Package 3 (Step 6) | ✅ |
|
||||
| Statistics Cards | Number cards with icons, color coding, click-through | Package 5 (Step 8) | ✅ |
|
||||
| Charts | Line, bar, pie, stacked, doughnut with interactivity | Package 5 (Step 7) | ✅ |
|
||||
| Data Bags | JSON/structured data with syntax highlighting | Package 5 (Step 6) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 handles DataGrid (Steps 1-11), Package 3 handles RecordView (Step 6), Package 5 handles all widgets (Steps 6-8).
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.3 Form Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Entity Form | Multi-section, Zod validation, all 12 field types, conditional visibility | Package 3 (Steps 2-6) | ✅ |
|
||||
| Autocomplete/Select | Possible-values dropdown with server search, hierarchical | Package 3 (Step 4) | ✅ |
|
||||
| Boolean Fields | Toggle switch, tri-state | Package 3 (Step 4) | ✅ |
|
||||
| Chip Input | Multi-value with tags | Package 3 (Step 4) | ✅ |
|
||||
| File Upload | Single/multi with preview, drag-drop | Package 3 (Step 4) | ✅ |
|
||||
| Date/Time Fields | Date picker, time picker, range selection | Package 3 (Step 4) | ✅ |
|
||||
| Code Editor | Syntax-highlighted editing | Package 3 (Step 4), Package 6 (Step 18) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 3 Steps 2-6 implement all form components. Package 6 Step 18 adds developer code editor.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.4 Process Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Step Wizard | Linear stepper, forward/back navigation, progress | Package 4 (Steps 2-3) | ✅ |
|
||||
| Validation Review | Pre-commit warnings/errors, accept/reject/modify | Package 4 (Step 5) | ✅ |
|
||||
| Bulk Load | CSV mapping, value mapping, profile save/load | Package 4 (Step 6) | ✅ |
|
||||
| Process Results | Summary, record counts, error lists, downloads | Package 4 (Step 8) | ✅ |
|
||||
| Progress Display | Progress bar, message, current/total, cancel | Package 4 (Step 3) | ✅ |
|
||||
| Google Drive | Folder picker wrapper | Package 4 (Step 9) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 4 Steps 2-9 implement all process components.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.5 Query/Filter Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Filter Builder | Basic + advanced modes, 20 operators, AND/OR logic, sub-filters | Package 2 (Steps 4-5) | ✅ |
|
||||
| Quick Filter | Text search across columns | Package 2 (Step 4) | ✅ |
|
||||
| Column Configuration | Show/hide, reorder, persist to localStorage | Package 2 (Step 8) | ✅ |
|
||||
| Pagination | Page size selector, page navigation, total count | Package 2 (Step 6) | ✅ |
|
||||
| Saved Views | Save/restore filter + column + sort config | Package 2 (Step 10) | ✅ |
|
||||
| Export | CSV, PDF, Excel (extensible) | Package 2 (Step 11) | ✅ |
|
||||
| Table Variants | Select alternate views | Package 2 (Step 7) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 Steps 4-11 implement all query/filter components.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.6 Widget Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Widget Container | Label, dropdown, reload, export, help | Package 5 (Step 4) | ✅ |
|
||||
| Block Types (11 types) | Text, BigNumber, UpOrDown, IconBadge, Progress, Button, Icon, Image, Audio, Divider, InputField | Package 5 (Step 6) | ✅ |
|
||||
| Record Grid Widget | Embedded grid, add/edit/delete | Package 5 (Step 9) | ✅ |
|
||||
| Specialized Widgets (10 types) | Composite, Parent, DynamicForm, PivotTable, FilterColumns, Stepper, Cron, USMap, ScriptViewer, Custom | Package 5 (Step 10) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 5 Steps 4-11 implement all widget components.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.7 Feedback Components
|
||||
|
||||
| Component | Required Behavior | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Alerts | Inline success/error/warning/info | Package 1 (Step 11), All packages | ✅ |
|
||||
| Modals/Dialogs | Modal stack, confirmation, focus trap | Package 1 (Step 6), Package 6 (Step 8) | ✅ |
|
||||
| Tooltips/Help | Contextual help, rich HTML content | Package 3 (Step 3), All packages | ✅ |
|
||||
| Command Menu | Cmd+K quick navigation | Package 6 (Step 14) | ✅ |
|
||||
|
||||
**Implementation Status:** All feedback components covered across packages, with full keyboard/focus support in Package 6.
|
||||
|
||||
---
|
||||
|
||||
### 5.4: Responsive Design Requirements
|
||||
|
||||
#### 5.4.1 Breakpoints
|
||||
|
||||
| Breakpoint | Width | Layout | Covered By | Status |
|
||||
|---|---|---|---|---|
|
||||
| Mobile | < 768px | Single-column, off-canvas nav, bottom navigation | Packages 1, 2, 3, 4, 5, 6 | ✅ |
|
||||
| Tablet | 768px–1024px | Adaptive layout, mini-sidebar, two-column | Packages 1, 2, 3, 4, 5, 6 | ✅ |
|
||||
| Desktop | > 1024px | Full layout, persistent sidebar, multi-panel | Packages 1, 2, 3, 4, 5, 6 | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Steps 1-6 comprehensively audit and implement responsive design across all pages.
|
||||
|
||||
---
|
||||
|
||||
#### 5.4.2 Page-Specific Responsive Treatment
|
||||
|
||||
| Page | Mobile Treatment | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Record Query | Card-based list, simplified filter, 2-3 columns | Package 6 (Step 1) | ✅ |
|
||||
| Record View | Single-column stacked, collapsible sections | Package 6 (Step 2) | ✅ |
|
||||
| Entity Form | Single-column, sticky buttons | Package 6 (Step 2) | ✅ |
|
||||
| Process Execution | Full-screen steps, bottom navigation | Package 6 (Step 5) | ✅ |
|
||||
| App Home/Dashboard | Stacked widgets, horizontal scroll stats | Package 6 (Step 5) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Steps 1-6 implement comprehensive responsive treatment for all page types.
|
||||
|
||||
---
|
||||
|
||||
### 5.5: Authentication and Authorization
|
||||
|
||||
#### 5.5.1 Authentication Flows
|
||||
|
||||
| Flow | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| OAuth2/OIDC | Redirect, token exchange, session creation | Package 1 (Steps 3-4) | ✅ |
|
||||
| Auth0 | Auth0-specific configuration | Package 1 (Step 3) | ✅ |
|
||||
| Anonymous/Mock | For development and test instances | Package 1 (Steps 3-4) | ✅ |
|
||||
| Session Management | sessionUUID cookie-based | Package 1 (Step 3) | ✅ |
|
||||
| Back-Channel Logout | Server-side session invalidation | Package 1 (Step 4) | ✅ |
|
||||
| Explicit Logout | POST /logout clears session | Package 1 (Step 4) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Steps 3-4 implement complete auth system.
|
||||
|
||||
---
|
||||
|
||||
#### 5.5.2 Authorization (Role-Based UI)
|
||||
|
||||
| Control | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Table-level permissions | readPermission, insertPermission, editPermission, deletePermission control visibility | Packages 2, 3, 6 | ✅ |
|
||||
| Process-level permissions | hasPermission controls launch buttons | Package 4 | ✅ |
|
||||
| Capability-based toggling | TABLE_QUERY, TABLE_GET, TABLE_COUNT, etc. hide unavailable operations | Packages 2, 3 | ✅ |
|
||||
| Widget-level permissions | hasPermission controls visibility | Package 5 | ✅ |
|
||||
| Help content role filtering | Roles control help visibility | All packages | ✅ |
|
||||
|
||||
**Implementation Status:** Authorization checks integrated throughout all packages.
|
||||
|
||||
---
|
||||
|
||||
#### 5.5.3 Session Timeout
|
||||
|
||||
| Feature | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| 401 Detection | Global axios interceptor | Package 1 (Step 3) | ✅ |
|
||||
| Re-auth Modal | Non-disruptive modal prompt | Package 6 (Step 29) | ✅ |
|
||||
| State Preservation | Unsaved form data, scroll, filter state | Package 6 (Step 29) | ✅ |
|
||||
| Timeout Warning | Configurable warning (5 min before expiry) | Package 6 (Step 29) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 3 handles 401 globally, Package 6 Step 29 implements full session timeout UX.
|
||||
|
||||
---
|
||||
|
||||
### 5.6: Performance Requirements
|
||||
|
||||
#### 5.6.1 Load Time Targets
|
||||
|
||||
| Metric | Target | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| First Contentful Paint | < 1.5s | Server Components, code splitting | Package 1, Package 6 (Step 23) | ✅ |
|
||||
| Time to Interactive | < 3.0s | Lazy loading, optimizations | Package 6 (Steps 22-25) | ✅ |
|
||||
| Largest Contentful Paint | < 2.5s | Image optimization | Package 6 (Step 23) | ✅ |
|
||||
| Cumulative Layout Shift | < 0.1 | Skeleton loaders, reserved space | All packages | ✅ |
|
||||
| First Input Delay | < 100ms | Efficient event handlers | All packages | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Steps 22-25 implement comprehensive performance optimization.
|
||||
|
||||
---
|
||||
|
||||
#### 5.6.2 Caching Strategy
|
||||
|
||||
| Endpoint | Strategy | TTL | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| GET /metaData | Memoize in memory | Session | Package 1 (Step 3) | ✅ |
|
||||
| GET /metaData/table/{t} | Memoize per table | Session | Package 2 (Step 2) | ✅ |
|
||||
| GET /metaData/process/{p} | Memoize per process | Session | Package 4 (Step 1) | ✅ |
|
||||
| GET /metaData/authentication | localStorage TTL | 1 hour | Package 1 (Step 3) | ✅ |
|
||||
| POST /table/{t}/query | No cache | N/A | Package 2 (Step 2) | ✅ |
|
||||
| POST /table/{t}/count | Short-lived cache | 30 seconds | Package 2 (Step 2) | ✅ |
|
||||
| GET /widget/{w} | Request dedup | Cancel-replace | Package 5 (Step 2) | ✅ |
|
||||
| POST /possibleValues/{f} | Debounced | 300ms | Packages 3, 4 | ✅ |
|
||||
|
||||
**Implementation Status:** Caching strategy implemented across all packages with TanStack Query.
|
||||
|
||||
---
|
||||
|
||||
#### 5.6.3 Optimization Strategies
|
||||
|
||||
| Strategy | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Route-based code splitting | Next.js automatic | Package 1 (Step 1) | ✅ |
|
||||
| Lazy loading | Charts, code editor, file upload, rich text | Package 6 (Step 24) | ✅ |
|
||||
| Optimistic UI | Create/update/delete with immediate feedback | Packages 3, 4 | ✅ |
|
||||
| Virtual scrolling | DataGrid >100 rows, dropdowns >50 items | Package 6 (Step 24) | ✅ |
|
||||
| Server-side metadata | Server Components processing | Package 1 (Step 1) | ✅ |
|
||||
|
||||
**Implementation Status:** All optimization strategies covered across packages.
|
||||
|
||||
---
|
||||
|
||||
### 5.7: Developer Experience Requirements
|
||||
|
||||
#### 5.7.1 Component Documentation
|
||||
|
||||
| Requirement | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Storybook setup | All shared components documented | Package 6 (Step 20) | ✅ |
|
||||
| Component stories | Loading, empty, error, populated, disabled states | Package 6 (Step 20) | ✅ |
|
||||
| Interactive controls | Prop exploration in Storybook | Package 6 (Step 20) | ✅ |
|
||||
| Accessibility audit | axe-core integration in stories | Package 6 (Step 20) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 6 Step 20 implements comprehensive Storybook documentation.
|
||||
|
||||
---
|
||||
|
||||
#### 5.7.2 Testing
|
||||
|
||||
| Test Type | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| E2E Testing | Playwright for critical flows | Package 6 (Step 21) | ✅ |
|
||||
| Component Testing | Vitest + Testing Library | All packages (unit tests) | ✅ |
|
||||
| Visual Regression | Screenshot comparison | Package 6 (Step 21) | ✅ |
|
||||
| Accessibility Testing | axe-core in CI | Package 6 (Step 21) | ✅ |
|
||||
| API Contract Testing | Type definitions validation | Package 1 (Step 2) | ✅ |
|
||||
|
||||
**Implementation Status:** Comprehensive testing covered across packages and Package 6.
|
||||
|
||||
---
|
||||
|
||||
#### 5.7.3 Build and CI/CD
|
||||
|
||||
| Requirement | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Gradle/Maven integration | Part of QQQ build pipeline | Package 1 (Step 1) | ✅ |
|
||||
| HMR | Vite-based or Turbopack | Package 1 (Step 1) | ✅ |
|
||||
| Production build | Minification, tree-shaking, source maps | Package 1 (Step 1) | ✅ |
|
||||
| Bundle size monitoring | CI threshold check | Package 6 (Step 22) | ✅ |
|
||||
| Lint + Format enforcement | ESLint + Prettier in CI | Package 1 (Step 1) | ✅ |
|
||||
|
||||
**Implementation Status:** Package 1 Step 1 establishes build pipeline, Package 6 Step 22 adds monitoring.
|
||||
|
||||
---
|
||||
|
||||
#### 5.7.4 Type Safety
|
||||
|
||||
| Requirement | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Generated API client | From OpenAPI spec or shared types | Package 1 (Step 2) | ✅ |
|
||||
| Strict TypeScript | strict: true in tsconfig | Package 1 (Step 1) | ✅ |
|
||||
| Runtime validation | Zod for API responses | Package 3 (Step 2) | ✅ |
|
||||
|
||||
**Implementation Status:** Type safety implemented from Package 1 foundations across all packages.
|
||||
|
||||
---
|
||||
|
||||
### 5.8: Migration Strategy
|
||||
|
||||
#### 5.8.1 Phased Approach
|
||||
|
||||
| Phase | Duration | Coverage | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Phase 0 — Foundation | Weeks 1–4 | Project setup, auth, layout, routing | Package 1 | ✅ |
|
||||
| Phase 1 — Record Query | Weeks 5–10 | DataGrid with filtering, sorting, pagination | Package 2 | ✅ |
|
||||
| Phase 2 — Record CRUD | Weeks 11–16 | View, create, edit, delete forms | Package 3 | ✅ |
|
||||
| Phase 3 — Processes | Weeks 17–22 | Step wizard, bulk load, async execution | Package 4 | ✅ |
|
||||
| Phase 4 — Dashboard | Weeks 23–26 | Widgets, charts, statistics | Package 5 | ✅ |
|
||||
| Phase 5 — Polish | Weeks 27–32 | Responsive, a11y, testing, E2E | Package 6 | ✅ |
|
||||
|
||||
**Implementation Status:** All phases mapped to work packages 1-6.
|
||||
|
||||
---
|
||||
|
||||
#### 5.8.2 Coexistence Strategy
|
||||
|
||||
| Strategy | Implementation | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Dual UI support | New UI at `/v2/`, legacy at `/` | Package 1 (Step 1) | ✅ |
|
||||
| Shared auth | Both UIs use same sessionUUID | Package 1 (Step 3) | ✅ |
|
||||
| Feature flag | User preference for default UI | Package 1 (Step 6) | ✅ |
|
||||
|
||||
**Implementation Status:** Coexistence architecture built into Package 1.
|
||||
|
||||
---
|
||||
|
||||
#### 5.8.3 First Milestone Deliverable
|
||||
|
||||
| Deliverable | Scope | Covered By | Status |
|
||||
|---|---|---|---|
|
||||
| Record Query page | Single table fully functional | Package 2 (Complete) | ✅ |
|
||||
| Validates | Metadata-driven rendering, API integration, grid performance, filters, auth, responsive, keyboard nav | Package 2 + Package 1 | ✅ |
|
||||
|
||||
**Implementation Status:** Package 2 complete record query implementation is production-ready first milestone.
|
||||
|
||||
---
|
||||
|
||||
## Gaps Found
|
||||
|
||||
**Result: ZERO GAPS**
|
||||
|
||||
All subsections of Section 5 and Section 3 are covered by at least one work package. No requirements are left unmapped.
|
||||
|
||||
---
|
||||
|
||||
## Overlap Analysis
|
||||
|
||||
### Intentional Overlaps (No Conflicts)
|
||||
|
||||
| Requirement | Package A | Package B | Nature | Resolution |
|
||||
|---|---|---|---|---|
|
||||
| API Client setup | Package 1 | All packages | Foundational | Package 1 provides base, others extend with specialized functions |
|
||||
| TanStack Query | Package 1 | Packages 2-5 | Foundational | Package 1 sets up QueryClient, others define query keys and hooks |
|
||||
| Form components | Package 3 | Package 4 | Reuse | Package 4 reuses EntityForm, DynamicFormField from Package 3 |
|
||||
| Metadata loading | Package 1 | Packages 2-5 | Foundational | Package 1 provides loadMetaData, others load specific table/process metadata |
|
||||
| Authorization checks | Package 1 | Packages 2-6 | Distributed | Package 1 establishes context, others gate features based on permissions |
|
||||
| Responsive design | Packages 1-6 | Package 6 | Progressive | Packages 1-5 implement responsive layouts, Package 6 audits and polishes |
|
||||
| Testing | All packages | Package 6 | Progressive | Packages 1-5 include unit/component tests, Package 6 adds E2E/Storybook |
|
||||
| Accessibility | All packages | Package 6 | Progressive | Packages 1-5 build accessible components, Package 6 audits and adds advanced features |
|
||||
|
||||
**Conclusion:** All overlaps are intentional, complementary, and properly sequenced. No ownership conflicts.
|
||||
|
||||
---
|
||||
|
||||
## Coverage Summary Table
|
||||
|
||||
### By Work Package
|
||||
|
||||
| Package | Section 5 Coverage | Section 3 Coverage | Total Subsections |
|
||||
|---|---|---|---|
|
||||
| Package 1: Scaffold & Auth | 5.1, 5.2.1, 5.3.1, 5.3.7, 5.4, 5.5.1, 5.5.2, 5.6.1-3, 5.7.3-4, 5.8 | 3.2 (all), 3.3.1, 3.8, 3.11 | 17 |
|
||||
| Package 2: Record Query | 5.2.3 (query), 5.3.2 (grid), 5.3.5 | 3.4.1, 3.4.2, 3.7, 3.9, 3.10 | 8 |
|
||||
| Package 3: Record CRUD | 5.2.3 (create/edit/view), 5.3.2 (detail), 5.3.3 | 3.4.3, 3.4.4, 3.4.5, 3.4.6, 3.7 | 8 |
|
||||
| Package 4: Processes | 5.2.4, 5.3.4, 5.3.6 (process widgets) | 3.5 (all) | 8 |
|
||||
| Package 5: Dashboard | 5.2.2, 5.3.2 (stats/charts), 5.3.6 (all widgets) | 3.6 | 5 |
|
||||
| Package 6: Polish | 5.3.7 (advanced), 5.4.2, 5.5.3, 5.6.1-3, 5.7.1-2, 5.8.3 | N/A | 7 |
|
||||
|
||||
**Total Unique Subsections:** 53/53 = **100% Coverage**
|
||||
|
||||
---
|
||||
|
||||
## Verification Methodology
|
||||
|
||||
This cross-check was performed by:
|
||||
|
||||
1. **Extracting all section headers** from Section 3 (API Contract) and Section 5 (New Admin UI)
|
||||
2. **Reading each work package** and identifying implemented subsections
|
||||
3. **Mapping each subsection** to responsible package(s) and specific implementation step(s)
|
||||
4. **Verifying coverage** — every subsection appears in at least one package
|
||||
5. **Checking for overlaps** — ensuring no conflicts, all overlaps are intentional
|
||||
6. **Validating completeness** — no gaps, no floating requirements
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **For Implementation Teams:**
|
||||
- Use this matrix as a checklist during development
|
||||
- Cross-reference work package steps with requirement subsections
|
||||
- Update this report when new requirements are added
|
||||
|
||||
2. **For Project Management:**
|
||||
- Track progress against this coverage map
|
||||
- Use as acceptance criteria for package completion
|
||||
- Monitor for any new requirements that might appear during implementation
|
||||
|
||||
3. **For Architecture Reviews:**
|
||||
- Reference this document when evaluating design decisions
|
||||
- Ensure API client changes don't break type contracts defined in Section 3
|
||||
- Validate responsive design completeness per Section 5.4
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
**Report Created By:** Cross-Check Analysis
|
||||
**Date:** 2026-02-25
|
||||
**Status:** COMPLETE AND VERIFIED
|
||||
|
||||
**Conclusion:** ✅ All requirements from Section 3 (Semantic API Contract v1.0) and Section 5 (New Admin UI Requirements) are fully covered by the six work packages with clear ownership, no gaps, and intentional complementary overlaps.
|
||||
|
||||
The implementation plan is ready for execution.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Review Checklist: Package 1 — Scaffold, Auth, and Layout Shell
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 3.2: Auth endpoints (getAuthenticationMetaData, manageSession, logout) implemented
|
||||
- [ ] Section 5.1: Metadata-driven rendering foundation established
|
||||
- [ ] Section 5.5: Authentication flows (AUTH_0, OAUTH2, FULLY_ANONYMOUS) working
|
||||
- [ ] Section 5.3.1: Layout components (sidebar, navbar, breadcrumbs, banner) rendering
|
||||
- [ ] Section 5.5.2: Permission fields present on metadata types (readPermission, insertPermission, etc.)
|
||||
|
||||
### Integration Check
|
||||
- [ ] `pnpm dev` starts without errors
|
||||
- [ ] `pnpm build` completes successfully
|
||||
- [ ] `pnpm tsc --noEmit` passes with no type errors
|
||||
- [ ] All type definitions from shared-context/type-definitions.md present in /src/types/
|
||||
- [ ] API client base URL configurable via NEXT_PUBLIC_API_BASE_URL
|
||||
- [ ] TanStack Query provider wraps the app
|
||||
- [ ] Auth provider wraps the app
|
||||
- [ ] Theme provider wraps the app
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] GET /qqq/v1/metaData/authentication — correct path, no auth required
|
||||
- [ ] POST /qqq/v1/manageSession — multipart/form-data, accessToken field
|
||||
- [ ] POST /qqq/v1/logout — session cookie sent
|
||||
- [ ] GET /qqq/v1/metaData — correct path, returns QInstance shape
|
||||
- [ ] GET /qqq/v1/metaData/table/{tableName} — returns QTableMetaData
|
||||
- [ ] GET /qqq/v1/metaData/process/{processName} — returns QProcessMetaData
|
||||
- [ ] 401 responses trigger unauthorized callback
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Sidebar navigation built from appTree metadata (no hardcoded nav items)
|
||||
- [ ] Routes generated dynamically from app tree traversal
|
||||
- [ ] Breadcrumbs use pathToLabelMap from metadata
|
||||
- [ ] Branding (logo, appName, accentColor) from QBrandingMetaData
|
||||
- [ ] Banners from branding.banners metadata
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Sidebar collapses to mini-mode on tablet
|
||||
- [ ] Sidebar becomes off-canvas drawer on mobile (to be finalized in Package 6)
|
||||
- [ ] Header adapts to screen width
|
||||
- [ ] Layout doesn't overflow horizontally at any breakpoint
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] Sidebar has role="navigation"
|
||||
- [ ] All buttons have aria-label or text content
|
||||
- [ ] Breadcrumbs have aria-current="page" on last item
|
||||
- [ ] Banner has role="alert"
|
||||
- [ ] Focus visible on all interactive elements
|
||||
- [ ] Tab order is logical through sidebar → header → content
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] No `any` types in component props (except values: Record<string, any> from API)
|
||||
- [ ] All API functions have typed return values
|
||||
- [ ] QueryClient configured with proper generic types
|
||||
- [ ] Auth context type is fully defined (no partial types)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Review Checklist: Package 2 — Record Query
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.2.3: Record query page at /app/{tableName} renders
|
||||
- [ ] Section 5.3.2: Data grid with server-side pagination, sorting, filtering
|
||||
- [ ] Section 5.3.5: Basic filter (quick search) and advanced filter builder
|
||||
- [ ] Section 5.3.5: All 20 QCriteriaOperator values supported in filter builder
|
||||
- [ ] Section 5.3.5: AND/OR boolean logic and sub-filters
|
||||
- [ ] Section 5.3.5: Column configuration (show/hide, reorder)
|
||||
- [ ] Section 5.3.5: Saved views (save/load/delete)
|
||||
- [ ] Section 5.3.5: Export to CSV
|
||||
- [ ] Section 5.3.5: Pagination with page size selector (10/25/50/100)
|
||||
- [ ] Section 5.3.5: Table variant selection
|
||||
- [ ] Section 5.4.2: Mobile card view alternative
|
||||
- [ ] Section 3.4.1: POST /table/{t}/query with correct filter shape
|
||||
- [ ] Section 3.4.2: POST /table/{t}/count for total count
|
||||
- [ ] Section 3.7: Possible values for filter field dropdowns
|
||||
- [ ] Section 3.9: Offset/limit pagination (skip = (page-1) * pageSize)
|
||||
- [ ] Section 3.10: Tree-structured filters with subFilters
|
||||
|
||||
### Integration Check
|
||||
- [ ] Imports from Package 1 resolve: types, API client, queryKeys, QContext, layout
|
||||
- [ ] Page renders within dashboard layout with sidebar and breadcrumbs
|
||||
- [ ] Row click navigates to /app/{tableName}/{recordId} (Package 3 route)
|
||||
- [ ] Create button navigates to /app/{tableName}/create (Package 3 route)
|
||||
- [ ] Process launcher invokes process routes (Package 4)
|
||||
- [ ] Toolbar integrates with QContext (setPageHeader, setTableMetaData)
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] POST /qqq/v1/table/{tableName}/query — JSON body with filter object
|
||||
- [ ] POST /qqq/v1/table/{tableName}/count — same filter structure
|
||||
- [ ] POST /qqq/v1/table/{tableName}/possibleValues/{fieldName} — correct form data
|
||||
- [ ] Response shapes match: { records: QRecord[] } and { count, distinctCount? }
|
||||
- [ ] Query deduplication prevents stale results from overwriting fresh
|
||||
- [ ] Joins passed when exposedJoins present on table metadata
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Columns generated from QTableMetaData.fields (no hardcoded columns)
|
||||
- [ ] Cell renderers chosen by QFieldType and adornment type
|
||||
- [ ] Filter operators filtered per field type (e.g., no CONTAINS on INTEGER)
|
||||
- [ ] Create button shown only if insertPermission is true
|
||||
- [ ] Table label used in page header (not table name)
|
||||
- [ ] Hidden fields (isHidden) excluded from default column set
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Desktop: full data grid with toolbar, filters, pagination
|
||||
- [ ] Tablet: horizontal scroll data grid, sticky first column
|
||||
- [ ] Mobile: card-based list view as alternative to grid
|
||||
- [ ] Filter panel adapts to screen size (bottom sheet on mobile)
|
||||
- [ ] Pagination controls usable at all breakpoints
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] Data grid has role="grid" or role="table"
|
||||
- [ ] Column headers have scope="col"
|
||||
- [ ] Sort buttons have aria-sort attribute
|
||||
- [ ] Filter inputs have labels
|
||||
- [ ] Row selection checkboxes have aria-label
|
||||
- [ ] Keyboard navigation within grid (arrow keys, Tab)
|
||||
- [ ] Page size selector is a labeled select
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] QQueryFilter type used for all filter construction
|
||||
- [ ] QRecord[] typed for query results
|
||||
- [ ] Column definitions typed as ColumnDef<QRecord>[]
|
||||
- [ ] No `any` in filter builder state
|
||||
- [ ] Export function parameter typed
|
||||
@@ -0,0 +1,73 @@
|
||||
# Review Checklist: Package 3 — Record View and CRUD
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.2.3: Record view at /app/{tableName}/{recordId}
|
||||
- [ ] Section 5.2.3: Create at /app/{tableName}/create
|
||||
- [ ] Section 5.2.3: Edit at /app/{tableName}/{recordId}/edit
|
||||
- [ ] Section 5.2.3: Copy at /app/{tableName}/{recordId}/copy
|
||||
- [ ] Section 5.3.2: Section-organized field display (T1 and non-T1)
|
||||
- [ ] Section 5.3.2: Field value rendering for all 12 QFieldTypes
|
||||
- [ ] Section 5.3.2: All adornment types rendered (LINK, CHIP, SIZE, FILE_DOWNLOAD, etc.)
|
||||
- [ ] Section 5.3.3: Dynamic form generation from QFieldMetaData
|
||||
- [ ] Section 5.3.3: Possible values autocomplete with debounced search
|
||||
- [ ] Section 5.3.3: File upload fields
|
||||
- [ ] Section 5.3.3: Boolean switch (null/false/true)
|
||||
- [ ] Section 5.3.3: Date/time pickers
|
||||
- [ ] Section 5.3.3: Rich text editor for HTML fields
|
||||
- [ ] Section 5.4.2: Responsive view (single-column mobile, multi-column desktop)
|
||||
- [ ] Section 5.4.2: Responsive form (single-column mobile, gridColumns tablet+)
|
||||
- [ ] Section 3.4.3: GET /table/{t}/{id} for single record
|
||||
- [ ] Section 3.4.4: POST /table/{t} for insert
|
||||
- [ ] Section 3.4.5: PUT /table/{t}/{id} for update
|
||||
- [ ] Section 3.4.6: DELETE /table/{t}/{id} for delete
|
||||
- [ ] Section 3.7: Possible values endpoint integration
|
||||
|
||||
### Integration Check
|
||||
- [ ] Imports from Package 1: types, API client, queryKeys, QContext
|
||||
- [ ] Record View navigates back to Record Query (Package 2)
|
||||
- [ ] Action menu can launch processes (Package 4 route)
|
||||
- [ ] Widget sections delegate to Package 5 components (or stubs)
|
||||
- [ ] Delete navigates back to query page
|
||||
- [ ] Unsaved changes prompt before navigation
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] GET /qqq/v1/table/{tableName}/{primaryKey} — includes associations param
|
||||
- [ ] POST /qqq/v1/table/{tableName} — multipart/form-data with field values
|
||||
- [ ] PUT /qqq/v1/table/{tableName}/{primaryKey} — multipart/form-data
|
||||
- [ ] DELETE /qqq/v1/table/{tableName}/{primaryKey} — returns deletedCount
|
||||
- [ ] POST .../possibleValues/{fieldName} — searchTerm, debounced 300ms
|
||||
- [ ] Error responses (403, 404, 500) handled gracefully
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Form fields generated from QTableMetaData.sections → fieldNames → QFieldMetaData
|
||||
- [ ] Section layout follows sections metadata (T1 at top)
|
||||
- [ ] gridColumns from section metadata controls form layout
|
||||
- [ ] Field labels from metadata (never field names)
|
||||
- [ ] isEditable respected (read-only fields in edit mode)
|
||||
- [ ] isRequired reflected in Zod schema and visual indicator
|
||||
- [ ] isHidden fields excluded from view and form
|
||||
- [ ] isHeavy fields lazy-loaded or excluded from initial fetch
|
||||
- [ ] Possible values sourced from possibleValueSourceName metadata
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Record View: single-column stacked sections on mobile
|
||||
- [ ] Record View: collapsible sections on mobile
|
||||
- [ ] Entity Form: single-column on mobile, gridColumns on desktop
|
||||
- [ ] Action menu: bottom sheet on mobile, dropdown on desktop
|
||||
- [ ] Save/Cancel buttons: sticky at bottom on mobile
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] All form inputs have associated <label> elements
|
||||
- [ ] Required fields marked with aria-required="true"
|
||||
- [ ] Invalid fields marked with aria-invalid="true" and aria-describedby
|
||||
- [ ] Delete confirmation dialog traps focus
|
||||
- [ ] Keyboard shortcuts documented and displayed (n, e, c, d, a)
|
||||
- [ ] Help tooltips accessible via keyboard (not just hover)
|
||||
- [ ] File upload has accessible label and drag-drop instructions
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] Form state typed with Zod schema inference (z.infer<typeof schema>)
|
||||
- [ ] QRecord type used for all record data
|
||||
- [ ] Field rendering switches on QFieldType enum (exhaustive)
|
||||
- [ ] Adornment rendering switches on AdornmentType enum
|
||||
- [ ] No `any` in form submission handlers
|
||||
@@ -0,0 +1,63 @@
|
||||
# Review Checklist: Package 4 — Process Execution
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.2.4: Standalone process at /app/{processName}
|
||||
- [ ] Section 5.2.4: Table-scoped process with recordIds
|
||||
- [ ] Section 5.2.4: Table-scoped process with filterJSON
|
||||
- [ ] Section 5.3.4: Step wizard with stepper UI
|
||||
- [ ] Section 5.3.4: All 14 component types rendered
|
||||
- [ ] Section 5.3.4: Validation review screen
|
||||
- [ ] Section 5.3.4: Bulk load workflows (file mapping, value mapping, profiles)
|
||||
- [ ] Section 5.3.4: Process results summary
|
||||
- [ ] Section 5.3.4: Async job progress display
|
||||
- [ ] Section 3.5.1: POST /processes/{p}/init
|
||||
- [ ] Section 3.5.2: POST /processes/{p}/{uuid}/step/{stepName}
|
||||
- [ ] Section 3.5.3: GET /processes/{p}/{uuid}/status/{jobUUID}
|
||||
- [ ] Section 3.5.4: GET /processes/{p}/{uuid}/records
|
||||
- [ ] Section 3.5.5: GET /processes/{p}/{uuid}/cancel
|
||||
|
||||
### Integration Check
|
||||
- [ ] Form components reused from Package 3 (EntityForm, DynamicFormField)
|
||||
- [ ] Possible values autocomplete from Package 3
|
||||
- [ ] Process can be launched from Record Query toolbar (Package 2)
|
||||
- [ ] Process can be launched from Record View action menu (Package 3)
|
||||
- [ ] Process results link back to affected records (Package 3 routes)
|
||||
- [ ] Widget component type delegates to Package 5
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] POST /processes/{p}/init — multipart/form-data with values, recordsParam, file
|
||||
- [ ] POST /processes/{p}/{uuid}/step/{s} — multipart/form-data with values, file
|
||||
- [ ] GET /processes/{p}/{uuid}/status/{j} — returns QJobRunning | QJobComplete | QJobError
|
||||
- [ ] GET /processes/{p}/{uuid}/records — query params skip, limit
|
||||
- [ ] GET /processes/{p}/{uuid}/cancel — returns boolean
|
||||
- [ ] Exponential backoff polling: 1.5s initial, 1.5x multiplier, 12s max
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Steps rendered from QFrontendStepMetaData.components
|
||||
- [ ] Form fields from step.formFields metadata
|
||||
- [ ] View fields from step.viewFields metadata
|
||||
- [ ] Process label from metadata (not process name)
|
||||
- [ ] ProcessMetaDataAdjustment handled (step list and field updates)
|
||||
- [ ] minInputRecords/maxInputRecords enforced
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Full-screen steps on mobile
|
||||
- [ ] Stepper horizontal scrollable on mobile
|
||||
- [ ] Bottom-anchored navigation buttons on mobile
|
||||
- [ ] File upload drop zone works on touch devices
|
||||
- [ ] Progress display readable at all breakpoints
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] Stepper has aria-label and step indicators
|
||||
- [ ] Current step has aria-current="step"
|
||||
- [ ] Progress bar has role="progressbar" with aria-valuenow/min/max
|
||||
- [ ] Cancel button has confirmation dialog
|
||||
- [ ] Form steps use accessible form patterns from Package 3
|
||||
- [ ] Error messages linked to relevant fields via aria-describedby
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] Process response union type properly discriminated (QJobStarted | QJobComplete | QJobError)
|
||||
- [ ] ProcessMetaDataAdjustment typed
|
||||
- [ ] Step component type discriminated on QComponentType enum
|
||||
- [ ] File upload typed with File/FormData
|
||||
- [ ] No `any` in process state management
|
||||
@@ -0,0 +1,57 @@
|
||||
# Review Checklist: Package 5 — Dashboard and Widgets
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.2.2: App Home page with dashboard layout
|
||||
- [ ] Section 5.3.6: Widget container (label, reload, export, dropdown, help)
|
||||
- [ ] Section 5.3.6: All chart types (line, bar, horizontal bar, stacked bar, pie)
|
||||
- [ ] Section 5.3.6: Statistics cards (mini, multi, standard)
|
||||
- [ ] Section 5.3.6: All block types (text, bigNumber, upOrDown, progress, button, icon, image, audio, divider, input)
|
||||
- [ ] Section 5.3.6: Record grid widget with CRUD
|
||||
- [ ] Section 5.3.6: Composite and parent widgets
|
||||
- [ ] Section 5.3.6: Widget dropdown selections with possible values
|
||||
- [ ] Section 5.3.6: Specialized widgets (pivot table, CRON, script viewer, custom component)
|
||||
- [ ] Section 3.6: GET /widget/{widgetName} with dynamic params
|
||||
|
||||
### Integration Check
|
||||
- [ ] DataGrid reused from Package 2 for record grid widget
|
||||
- [ ] EntityForm reused from Package 3 for dynamic form widget
|
||||
- [ ] Widget sections on Record View (Package 3) can render these widgets
|
||||
- [ ] Process step type WIDGET delegates to widget container
|
||||
- [ ] App Home accessible via sidebar navigation (Package 1)
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] GET /qqq/v1/widget/{widgetName} — correct path with dynamic query params
|
||||
- [ ] Dropdown selection values passed as query params
|
||||
- [ ] Request deduplication: abort previous request on dropdown change
|
||||
- [ ] Widget data response handled polymorphically per widget type
|
||||
- [ ] Error responses show per-widget error (not crash entire page)
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Widget list from QAppMetaData.widgets (no hardcoded widget names)
|
||||
- [ ] Widget grid layout from QWidgetMetaData.gridColumns
|
||||
- [ ] Dropdown options from QWidgetMetaData.dropdowns + possibleValues API
|
||||
- [ ] Widget label from metadata
|
||||
- [ ] showReloadButton/showExportButton from metadata
|
||||
- [ ] Help content from metadata
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Widget grid: 1-column on mobile, 2-column on tablet, N-column on desktop
|
||||
- [ ] Charts resize responsively (ResponsiveContainer)
|
||||
- [ ] Statistics cards stack vertically on mobile
|
||||
- [ ] Record grid widget scrolls horizontally on small screens
|
||||
- [ ] Widget dropdowns usable on touch devices
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] Charts have aria-label with data summary
|
||||
- [ ] Statistics cards have descriptive text (not just numbers)
|
||||
- [ ] Reload/export buttons have aria-label
|
||||
- [ ] Widget container has role="region" with aria-label
|
||||
- [ ] Audio player has accessible controls
|
||||
- [ ] Image blocks have alt text
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] Widget data typed per widget type (no raw `any` for response)
|
||||
- [ ] Block type discriminated union
|
||||
- [ ] Chart dataset typed
|
||||
- [ ] Record grid widget uses QRecord[] type
|
||||
- [ ] Dropdown values typed as QPossibleValue[]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Review Checklist: Package 6 — Polish, Parity, and Production Readiness
|
||||
|
||||
### Requirements Coverage
|
||||
- [ ] Section 5.4: Responsive design at all 3 breakpoints for all pages
|
||||
- [ ] Section 5.1.4: WCAG 2.1 AA accessibility compliance
|
||||
- [ ] Section 5.6.1: Performance targets (FCP <1.5s, TTI <3s, LCP <2.5s)
|
||||
- [ ] Section 5.6.2: Caching strategy implemented per endpoint
|
||||
- [ ] Section 5.6.3: Code splitting, lazy loading, virtual scrolling
|
||||
- [ ] Section 5.7.1: Storybook with all component stories
|
||||
- [ ] Section 5.7.2: Playwright E2E test suite for critical flows
|
||||
- [ ] Section 5.7.3: Build pipeline with lint/format enforcement
|
||||
- [ ] Section 5.7.4: Strict TypeScript, no `any` in props
|
||||
- [ ] Section 5.5.3: Session timeout UX with state preservation
|
||||
- [ ] Section 5.1.5: Theme tokens (60+ CSS custom properties)
|
||||
- [ ] Section 5.1.5: customCss injection via data-qqq-id selectors
|
||||
- [ ] Command palette (Cmd+K) implemented
|
||||
- [ ] Keyboard shortcuts for all pages
|
||||
- [ ] Audit trail on Record View
|
||||
- [ ] Developer tools pages (table dev, record dev, script editor)
|
||||
- [ ] Report execution pages
|
||||
|
||||
### Integration Check
|
||||
- [ ] All Package 1-5 components work together end-to-end
|
||||
- [ ] Command palette searches across all metadata (tables, processes, reports)
|
||||
- [ ] Keyboard shortcuts don't conflict across pages
|
||||
- [ ] Developer view routes render within dashboard layout
|
||||
- [ ] Report pages reuse ProcessRun from Package 4
|
||||
- [ ] Theme changes propagate to all components
|
||||
|
||||
### API Contract Compliance
|
||||
- [ ] Developer endpoints: GET /data/{t}/{id}/developer
|
||||
- [ ] Script endpoints: POST .../associatedScript/{f}, GET .../logs, POST .../test
|
||||
- [ ] Audit endpoints integrated (if available)
|
||||
- [ ] All API calls use correct v1 paths
|
||||
|
||||
### Metadata-Driven Check
|
||||
- [ ] Command palette items from metadata (not hardcoded)
|
||||
- [ ] Developer view shows metadata structure dynamically
|
||||
- [ ] Theme tokens from QThemeMetaData
|
||||
- [ ] customCss loaded from backend and injected
|
||||
|
||||
### Responsive Check
|
||||
- [ ] Mobile card view for Record Query verified functional
|
||||
- [ ] All forms single-column on mobile
|
||||
- [ ] Off-canvas sidebar drawer on mobile
|
||||
- [ ] Command palette fullscreen on mobile
|
||||
- [ ] Touch targets ≥ 44px verified across all pages
|
||||
- [ ] No horizontal overflow at any breakpoint
|
||||
|
||||
### Accessibility Check
|
||||
- [ ] axe-core CI integration with zero violations
|
||||
- [ ] Skip-to-content link present
|
||||
- [ ] Focus trap in all modals/dialogs
|
||||
- [ ] aria-live regions for toasts and loading states
|
||||
- [ ] Color contrast ≥ 4.5:1 (normal) and ≥ 3:1 (large)
|
||||
- [ ] All keyboard shortcuts have visible reference
|
||||
- [ ] Screen reader tested for critical flows
|
||||
|
||||
### Type Safety Check
|
||||
- [ ] Zero TypeScript errors in `pnpm tsc --noEmit`
|
||||
- [ ] No `any` types in any component props across all packages
|
||||
- [ ] All API responses validated at boundary (Zod or equivalent)
|
||||
- [ ] All event handlers properly typed
|
||||
- [ ] Exhaustive switch statements for all enum types
|
||||
@@ -0,0 +1,559 @@
|
||||
# Semantic API Contract v1.0
|
||||
|
||||
## 3.1 Overview
|
||||
|
||||
This section formalizes the implicit QQQ REST API into an explicit, versioned contract. Any frontend implementation (not just qfmd) can be built against this specification.
|
||||
|
||||
**Version:** `v1.0`
|
||||
**Base Path:** `/qqq/v1/`
|
||||
**Content-Type:** `application/json` (responses), `multipart/form-data` (process/CRUD requests with file uploads)
|
||||
**Authentication:** Cookie-based session (`sessionUUID` cookie) for all secured endpoints
|
||||
|
||||
## 3.2 Authentication Endpoints
|
||||
|
||||
### 3.2.1 Get Authentication Metadata
|
||||
|
||||
```
|
||||
GET /qqq/v1/metaData/authentication
|
||||
Authentication: None (public)
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"name": "string",
|
||||
"type": "AUTH_0 | OAUTH2 | FULLY_ANONYMOUS | MOCK",
|
||||
"values": {
|
||||
"clientId": "string",
|
||||
"baseUrl": "string",
|
||||
"audience": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2.2 Create/Manage Session
|
||||
|
||||
```
|
||||
POST /qqq/v1/manageSession
|
||||
Authentication: None (public)
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `accessToken` | string | Yes | OAuth2/OIDC access token |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"uuid": "string",
|
||||
"values": { }
|
||||
}
|
||||
```
|
||||
Sets `sessionUUID` cookie with the session UUID.
|
||||
|
||||
### 3.2.3 Logout
|
||||
|
||||
```
|
||||
POST /qqq/v1/logout
|
||||
Authentication: Required (sessionUUID cookie)
|
||||
```
|
||||
|
||||
**Response 200:** Empty body. Removes `sessionUUID` cookie.
|
||||
|
||||
### 3.2.4 OIDC Back-Channel Logout
|
||||
|
||||
```
|
||||
POST /qqq/v1/oidc/backchannel-logout
|
||||
Authentication: None (JWT in body)
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `logout_token` | string | Yes | JWT from Identity Provider containing `sub` or `sid` claims |
|
||||
|
||||
**Response 200:** Empty body.
|
||||
|
||||
## 3.3 Metadata Endpoints
|
||||
|
||||
### 3.3.1 Application Metadata
|
||||
|
||||
```
|
||||
GET /qqq/v1/metaData
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `frontendName` | string | No | Frontend identifier (e.g., "qqq-frontend-material-dashboard") |
|
||||
| `frontendVersion` | string | No | Frontend version string |
|
||||
| `applicationName` | string | No | Application instance name |
|
||||
| `applicationVersion` | string | No | Application version |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"apps": {
|
||||
"{appName}": {
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"children": ["QAppTreeNode"],
|
||||
"iconName": "string",
|
||||
"widgets": ["string"],
|
||||
"sections": [{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"icon": { "name": "string", "path": "string", "color": "string" },
|
||||
"tables": ["string"],
|
||||
"processes": ["string"],
|
||||
"reports": ["string"]
|
||||
}]
|
||||
}
|
||||
},
|
||||
"appTree": [{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"type": "TABLE | PROCESS | REPORT | APP",
|
||||
"children": ["QAppTreeNode"],
|
||||
"iconName": "string",
|
||||
"icon": { "name": "string", "path": "string", "color": "string" }
|
||||
}],
|
||||
"tables": {
|
||||
"{tableName}": {
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"isHidden": "boolean",
|
||||
"primaryKeyField": "string",
|
||||
"iconName": "string",
|
||||
"capabilities": ["TABLE_QUERY", "TABLE_GET", "TABLE_COUNT", "TABLE_INSERT", "TABLE_UPDATE", "TABLE_DELETE"],
|
||||
"readPermission": "boolean",
|
||||
"insertPermission": "boolean",
|
||||
"editPermission": "boolean",
|
||||
"deletePermission": "boolean"
|
||||
}
|
||||
},
|
||||
"processes": {
|
||||
"{processName}": {
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"tableName": "string",
|
||||
"isHidden": "boolean",
|
||||
"iconName": "string",
|
||||
"hasPermission": "boolean"
|
||||
}
|
||||
},
|
||||
"reports": { },
|
||||
"widgets": { },
|
||||
"branding": {
|
||||
"companyName": "string",
|
||||
"companyUrl": "string",
|
||||
"appName": "string",
|
||||
"logo": "string",
|
||||
"icon": "string",
|
||||
"accentColor": "string",
|
||||
"banners": { }
|
||||
},
|
||||
"helpContents": { },
|
||||
"environmentValues": { }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3.2 Table Metadata
|
||||
|
||||
```
|
||||
GET /qqq/v1/metaData/table/{tableName}
|
||||
Authentication: Required + READ permission on table
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"isHidden": "boolean",
|
||||
"primaryKeyField": "string",
|
||||
"fields": {
|
||||
"{fieldName}": {
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"type": "STRING | INTEGER | LONG | DECIMAL | BOOLEAN | DATE | TIME | DATE_TIME | TEXT | HTML | PASSWORD | BLOB",
|
||||
"isRequired": "boolean",
|
||||
"isEditable": "boolean",
|
||||
"isHeavy": "boolean",
|
||||
"isHidden": "boolean",
|
||||
"defaultValue": "any",
|
||||
"possibleValueSourceName": "string",
|
||||
"displayFormat": "string",
|
||||
"maxLength": "number",
|
||||
"gridColumns": "number",
|
||||
"adornments": [{
|
||||
"type": "LINK | CHIP | SIZE | ERROR | RENDER_HTML | REVEAL | CODE_EDITOR | FILE_DOWNLOAD | FILE_UPLOAD | TOOLTIP",
|
||||
"values": { }
|
||||
}],
|
||||
"helpContents": [],
|
||||
"behaviors": []
|
||||
}
|
||||
},
|
||||
"sections": [{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"tier": "string",
|
||||
"iconName": "string",
|
||||
"fieldNames": ["string"],
|
||||
"widgetName": "string",
|
||||
"isHidden": "boolean",
|
||||
"gridColumns": "number"
|
||||
}],
|
||||
"exposedJoins": [{
|
||||
"label": "string",
|
||||
"isMany": "boolean",
|
||||
"joinTable": "QTableMetaData",
|
||||
"joinPath": [{ "name": "string", "type": "ONE_TO_ONE | ONE_TO_MANY | MANY_TO_ONE", "leftTable": "string", "rightTable": "string" }]
|
||||
}],
|
||||
"capabilities": ["string"],
|
||||
"readPermission": "boolean",
|
||||
"insertPermission": "boolean",
|
||||
"editPermission": "boolean",
|
||||
"deletePermission": "boolean",
|
||||
"usesVariants": "boolean",
|
||||
"variantTableLabel": "string",
|
||||
"helpContent": { },
|
||||
"supplementalTableMetaData": { },
|
||||
"shareableTableMetaData": { }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3.3 Process Metadata
|
||||
|
||||
```
|
||||
GET /qqq/v1/metaData/process/{processName}
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"tableName": "string",
|
||||
"isHidden": "boolean",
|
||||
"iconName": "string",
|
||||
"hasPermission": "boolean",
|
||||
"stepFlow": "LINEAR",
|
||||
"minInputRecords": "number",
|
||||
"maxInputRecords": "number",
|
||||
"frontendSteps": [{
|
||||
"name": "string",
|
||||
"label": "string",
|
||||
"format": "string",
|
||||
"components": [{
|
||||
"type": "HELP_TEXT | BULK_EDIT_FORM | BULK_LOAD_FILE_MAPPING_FORM | BULK_LOAD_VALUE_MAPPING_FORM | BULK_LOAD_PROFILE_FORM | VALIDATION_REVIEW_SCREEN | EDIT_FORM | VIEW_FORM | DOWNLOAD_FORM | RECORD_LIST | PROCESS_SUMMARY_RESULTS | GOOGLE_DRIVE_SELECT_FOLDER | WIDGET | HTML",
|
||||
"values": { }
|
||||
}],
|
||||
"formFields": ["QFieldMetaData"],
|
||||
"viewFields": ["QFieldMetaData"],
|
||||
"recordListFields": ["QFieldMetaData"],
|
||||
"helpContents": []
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## 3.4 Table Data Endpoints
|
||||
|
||||
### 3.4.1 Query Records
|
||||
|
||||
```
|
||||
POST /qqq/v1/table/{tableName}/query
|
||||
Authentication: Required + READ permission
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"filter": {
|
||||
"criteria": [{
|
||||
"fieldName": "string",
|
||||
"operator": "EQUALS | NOT_EQUALS | NOT_EQUALS_OR_IS_NULL | IN | NOT_IN | STARTS_WITH | ENDS_WITH | CONTAINS | NOT_STARTS_WITH | NOT_ENDS_WITH | NOT_CONTAINS | LESS_THAN | LESS_THAN_OR_EQUALS | GREATER_THAN | GREATER_THAN_OR_EQUALS | IS_BLANK | IS_NOT_BLANK | BETWEEN | NOT_BETWEEN",
|
||||
"values": ["any"],
|
||||
"otherFieldName": "string"
|
||||
}],
|
||||
"orderBys": [{
|
||||
"fieldName": "string",
|
||||
"isAscending": "boolean"
|
||||
}],
|
||||
"subFilters": ["QQueryFilter"],
|
||||
"booleanOperator": "AND | OR",
|
||||
"skip": "number",
|
||||
"limit": "number"
|
||||
},
|
||||
"joins": [{
|
||||
"joinTable": "string",
|
||||
"select": "boolean",
|
||||
"type": "INNER | LEFT | RIGHT | FULL",
|
||||
"baseTableOrAlias": "string",
|
||||
"alias": "string",
|
||||
"joinName": "string"
|
||||
}],
|
||||
"tableVariant": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"records": [{
|
||||
"tableName": "string",
|
||||
"recordLabel": "string",
|
||||
"values": { "{fieldName}": "any" },
|
||||
"displayValues": { "{fieldName}": "string" },
|
||||
"errors": ["string"],
|
||||
"warnings": ["string"]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4.2 Count Records
|
||||
|
||||
```
|
||||
POST /qqq/v1/table/{tableName}/count
|
||||
Authentication: Required + READ permission
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `includeDistinct` | boolean | No | Whether to include distinct count |
|
||||
|
||||
**Request Body:** Same filter/joins structure as query.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"count": "number",
|
||||
"distinctCount": "number"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4.3 Get Single Record (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
GET /qqq/v1/table/{tableName}/{primaryKey}
|
||||
Authentication: Required + READ permission
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `tableVariant` | string | No | Table variant identifier |
|
||||
| `includeAssociations` | boolean | No | Include associated records |
|
||||
| `queryJoins` | string (JSON) | No | Join specifications |
|
||||
|
||||
**Response 200:** `QRecord`
|
||||
|
||||
### 3.4.4 Insert Record (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
POST /qqq/v1/table/{tableName}
|
||||
Authentication: Required + INSERT permission
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:** Form fields matching table field names.
|
||||
|
||||
**Response 200:** `QRecord` (inserted record with generated ID)
|
||||
|
||||
### 3.4.5 Update Record (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
PUT /qqq/v1/table/{tableName}/{primaryKey}
|
||||
Authentication: Required + EDIT permission
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:** Form fields with updated values.
|
||||
|
||||
**Response 200:** `QRecord` (updated record)
|
||||
|
||||
### 3.4.6 Delete Record (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
DELETE /qqq/v1/table/{tableName}/{primaryKey}
|
||||
Authentication: Required + DELETE permission
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"deletedCount": "number"
|
||||
}
|
||||
```
|
||||
|
||||
## 3.5 Process Endpoints
|
||||
|
||||
### 3.5.1 Initialize Process
|
||||
|
||||
```
|
||||
POST /qqq/v1/processes/{processName}/init
|
||||
Authentication: Required
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `values` | string (JSON) | No | Initial process values |
|
||||
| `recordsParam` | string | No | One of: `recordIds`, `filterJSON`, `filterId` |
|
||||
| `recordIds` | string | Conditional | Comma-separated primary keys (when recordsParam=recordIds) |
|
||||
| `filterJSON` | string | Conditional | QQueryFilter JSON (when recordsParam=filterJSON) |
|
||||
| `stepTimeoutMillis` | number | No | Async timeout in ms (default: 3000) |
|
||||
| `file` | binary | No | File upload |
|
||||
|
||||
**Response 200:** `QJobStarted | QJobComplete | QJobError` (see union type in Section 2.1)
|
||||
|
||||
### 3.5.2 Process Step
|
||||
|
||||
```
|
||||
POST /qqq/v1/processes/{processName}/{processUUID}/step/{stepName}
|
||||
Authentication: Required
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `values` | string (JSON) | No | Step form values |
|
||||
| `stepTimeoutMillis` | number | No | Async timeout in ms (default: 3000) |
|
||||
| `file` | binary | No | File upload for this step |
|
||||
|
||||
**Response 200:** `QJobStarted | QJobComplete | QJobError`
|
||||
|
||||
### 3.5.3 Process Status (Poll)
|
||||
|
||||
```
|
||||
GET /qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Response 200:** `QJobRunning | QJobComplete | QJobError`
|
||||
|
||||
### 3.5.4 Process Records (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
GET /qqq/v1/processes/{processName}/{processUUID}/records
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `skip` | number | No | Records to skip |
|
||||
| `limit` | number | No | Max records to return |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"totalRecords": "number",
|
||||
"records": ["QRecord"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5.5 Cancel Process (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
GET /qqq/v1/processes/{processName}/{processUUID}/cancel
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Response 200:** `boolean`
|
||||
|
||||
## 3.6 Widget Endpoints (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
GET /qqq/v1/widget/{widgetName}
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Query Parameters:** Dynamic, widget-specific key-value pairs.
|
||||
|
||||
**Response 200:** Widget-specific JSON payload. Shape varies by widget type (chart data, record grids, statistics, HTML content, etc.).
|
||||
|
||||
## 3.7 Possible Values Endpoints (Not Yet in v1 Specs)
|
||||
|
||||
```
|
||||
POST /qqq/v1/table/{tableName}/possibleValues/{fieldName}
|
||||
POST /qqq/v1/processes/{processName}/possibleValues/{fieldName}
|
||||
POST /qqq/v1/possibleValues/{fieldName}
|
||||
Authentication: Required
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `searchTerm` | string | No | Text search filter |
|
||||
| `ids` | string | No | Specific IDs to look up |
|
||||
| `labels` | string | No | Specific labels to look up |
|
||||
| `values` | string | No | Pre-selected values |
|
||||
| `useCase` | string | No | Context hint for filtering |
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
[{
|
||||
"id": "number | string",
|
||||
"label": "string"
|
||||
}]
|
||||
```
|
||||
|
||||
## 3.8 Error Response Shape
|
||||
|
||||
All error responses follow a consistent shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP Status Codes:**
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 200 | Success |
|
||||
| 401 | Unauthorized (no valid session) |
|
||||
| 403 | Forbidden (insufficient permissions) |
|
||||
| 404 | Not Found (table, process, or record does not exist) |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
## 3.9 Pagination Contract
|
||||
|
||||
Based on analysis of `QQueryFilter` and `TableQuerySpecV1`:
|
||||
|
||||
The pagination model is **offset/limit** based:
|
||||
- `filter.skip` (number): Number of records to skip (offset). Default: 0.
|
||||
- `filter.limit` (number): Maximum number of records to return. No enforced maximum from the API; the frontend typically defaults to 10, 25, 50, or 100.
|
||||
|
||||
There is no cursor-based pagination. The frontend manages page state by computing `skip = (pageNumber - 1) * pageSize` and sending it with each query. Total count is obtained via a separate `/count` call.
|
||||
|
||||
## 3.10 Filtering Contract
|
||||
|
||||
Based on analysis of `QQueryFilter`, `QFilterCriteria`, and `QCriteriaOperator`:
|
||||
|
||||
Filters are tree-structured with boolean operators:
|
||||
- Top-level `QQueryFilter` contains `criteria[]`, `orderBys[]`, and optional `subFilters[]`
|
||||
- `booleanOperator` (`AND` | `OR`) controls how criteria are combined
|
||||
- `subFilters` allow nested boolean logic (e.g., `(A AND B) OR (C AND D)`)
|
||||
- Each `QFilterCriteria` specifies `fieldName`, `operator`, and `values[]`
|
||||
- Special expression types are supported in values: `FilterVariableExpression`, `NowExpression`, `NowWithOffsetExpression`, `ThisOrLastPeriodExpression`
|
||||
|
||||
## 3.11 Backwards Compatibility Policy
|
||||
|
||||
Per the QQQ Semantic Versioning Policy (from wiki):
|
||||
|
||||
- **Public API surfaces** subject to SemVer include REST endpoints, request/response shapes, query parameter names, and HTTP status codes.
|
||||
- **PATCH versions** may include bug fixes, security patches, and performance optimizations without changing the API contract.
|
||||
- **MINOR versions** may add new optional fields to responses, new optional query parameters, new endpoints, and new enum values — all backward-compatible additions.
|
||||
- **MAJOR versions** may include breaking changes: removing endpoints, changing required parameters, altering response shapes, or removing enum values.
|
||||
- **Deprecation policy**: Two-version deprecation cycle. Deprecated features are annotated and kept available for at least one major version before removal.
|
||||
- **Recommendation**: The v1 API contract defined in this document should be frozen once formalized. New capabilities should be added as backward-compatible extensions (new optional fields, new endpoints). Breaking changes require a v2 contract.
|
||||
@@ -0,0 +1,879 @@
|
||||
# QQQ Admin UI Coding Conventions
|
||||
|
||||
This document establishes the foundational coding standards for the QQQ Admin UI modernization. All implementation agents must follow these conventions strictly to ensure consistency, maintainability, and compatibility across the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Server Components vs Client Components
|
||||
|
||||
**Context:** The new UI will use Next.js App Router, which supports both Server Components (default) and Client Components.
|
||||
|
||||
### Server Components (Default in App Router)
|
||||
|
||||
Use Server Components for:
|
||||
- Route pages (`page.tsx` files) that fetch metadata, layout wrappers, and static content
|
||||
- Components that access server-side resources (databases, private APIs, files)
|
||||
- Authentication checks and permission gating
|
||||
- Data that doesn't require client-side interactivity
|
||||
|
||||
**Characteristics:**
|
||||
- No JavaScript shipped to client for these components
|
||||
- Can directly query databases, call private APIs, and access secrets
|
||||
- Cannot use hooks (useState, useEffect, useContext)
|
||||
- Cannot use browser APIs (window, localStorage, DOM events)
|
||||
- Reduces initial bundle size and improves Time to Interactive
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// app/records/page.tsx - Server Component
|
||||
import { fetchMetadata, fetchRecords } from "@/lib/api";
|
||||
import RecordGrid from "@/components/RecordGrid";
|
||||
|
||||
export default async function RecordsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { table: string };
|
||||
}) {
|
||||
const tableMetadata = await fetchMetadata(searchParams.table);
|
||||
const records = await fetchRecords(searchParams.table);
|
||||
|
||||
return <RecordGrid metadata={tableMetadata} records={records} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Client Components (`"use client"`)
|
||||
|
||||
Use Client Components for:
|
||||
- Any component with `useState`, `useEffect`, `useReducer`, or other hooks
|
||||
- Any component with `onClick`, `onChange`, form handlers, or user events
|
||||
- Components using `useContext`, `useQuery`, `useMutation`, or custom hooks
|
||||
- Data grids, forms, modals, dropdowns—any interactive UI
|
||||
|
||||
**Characteristics:**
|
||||
- Include `"use client"` directive at top of file
|
||||
- Ship JavaScript to client (included in bundle)
|
||||
- Can use React hooks and browser APIs
|
||||
- Can subscribe to context and use custom hooks
|
||||
- Most QQQ components will be Client Components
|
||||
|
||||
**Boundary Pattern:**
|
||||
```typescript
|
||||
// page.tsx - Server Component fetches data
|
||||
export default async function Page({ params }) {
|
||||
const data = await fetchServerData(params.id);
|
||||
return <ClientComponent initialData={data} />;
|
||||
}
|
||||
|
||||
// components/ClientComponent.tsx - Client Component handles interaction
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ClientComponent({ initialData }) {
|
||||
const [state, setState] = useState(initialData);
|
||||
return <div onClick={() => setState(...)}>{state}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Decision Tree:**
|
||||
- Does this component use hooks (useState, useEffect, useContext)? → Client Component
|
||||
- Does this component have onClick, onChange, or other event handlers? → Client Component
|
||||
- Is this a layout wrapper or data-fetching shell? → Consider Server Component
|
||||
- Does this component render other Client Components? → Can be Server Component
|
||||
- Does this component need browser APIs? → Client Component
|
||||
|
||||
---
|
||||
|
||||
## State Management Approach
|
||||
|
||||
QQQ's state management uses multiple complementary patterns depending on scope and lifecycle.
|
||||
|
||||
### TanStack Query for Server State
|
||||
|
||||
Use **TanStack Query** (`react-query` or `@tanstack/react-query`) for all server-side data:
|
||||
- API responses (metadata, records, counts)
|
||||
- Asynchronous data fetching with loading/error states
|
||||
- Automatic caching and invalidation
|
||||
- Optimistic updates
|
||||
- Background refetching
|
||||
|
||||
**Read Data Pattern (useQuery):**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
|
||||
export default function RecordList({ tableName }: { tableName: string }) {
|
||||
const { data: records, isLoading, error } = useQuery({
|
||||
queryKey: ["table", tableName, "query"],
|
||||
queryFn: () => api.query(tableName, filter),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
if (error) return <Error error={error} />;
|
||||
return <Grid records={records} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Mutate Data Pattern (useMutation):**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
|
||||
export default function CreateButton() {
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values) => api.create(tableName, values),
|
||||
onSuccess: (newRecord) => {
|
||||
// Invalidate list to trigger refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["table", tableName, "query"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button onClick={() => createMutation.mutate(formValues)}>
|
||||
{createMutation.isPending ? "Saving..." : "Create"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
```typescript
|
||||
// lib/queryClient.ts
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### React Context for Global UI State
|
||||
|
||||
Use **React Context** for application-wide UI state that doesn't need server persistence:
|
||||
- Current theme (light/dark mode)
|
||||
- Sidebar open/closed state
|
||||
- Modal stack (which modals are open)
|
||||
- Authenticated user info
|
||||
- UI layout preferences (density, sidebar width)
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// context/ThemeContext.tsx
|
||||
"use client";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: "light" | "dark";
|
||||
setTheme: (t: "light" | "dark") => void;
|
||||
} | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
```
|
||||
|
||||
### React Hook Form for Form State
|
||||
|
||||
Use **React Hook Form** for all form state management:
|
||||
- Field values, dirty tracking, validation state
|
||||
- Integrates with Zod schemas for validation
|
||||
- Minimal re-renders
|
||||
- Efficient field-level updates
|
||||
- Built-in submit handling
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name required"),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export default function RecordForm({ initialValues }) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
watch,
|
||||
} = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register("name")} />
|
||||
{errors.name && <span>{errors.name.message}</span>}
|
||||
<button disabled={!isDirty}>Save</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### URL SearchParams for Shareable State
|
||||
|
||||
Use **URL search parameters** for state that should be shareable and bookmarkable:
|
||||
- Table filters, sort order, pagination
|
||||
- View/variant selection
|
||||
- Record ID (current record being viewed)
|
||||
- Tab/section selection
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function RecordTable() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const page = parseInt(searchParams.get("page") ?? "1");
|
||||
const filter = searchParams.get("filter");
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("sort", field);
|
||||
params.set("page", "1"); // Reset to page 1
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
return <Grid onSort={handleSort} />;
|
||||
}
|
||||
```
|
||||
|
||||
### localStorage for User Preferences
|
||||
|
||||
Use **localStorage** for persistent user preferences (not synced to server):
|
||||
- Column widths and ordering
|
||||
- UI density (compact/normal/spacious)
|
||||
- Sidebar width
|
||||
- Last selected view per table
|
||||
- Expanded/collapsed section preferences
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "qqq-table-column-widths";
|
||||
|
||||
export function useColumnWidths(tableId: string) {
|
||||
const [widths, setWidths] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate from localStorage
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const all = JSON.parse(stored);
|
||||
setWidths(all[tableId] ?? {});
|
||||
}
|
||||
}, [tableId]);
|
||||
|
||||
const updateWidth = (fieldName: string, width: number) => {
|
||||
const updated = { ...widths, [fieldName]: width };
|
||||
setWidths(updated);
|
||||
|
||||
// Persist to localStorage
|
||||
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
|
||||
all[tableId] = updated;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
};
|
||||
|
||||
return { widths, updateWidth };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### API Errors: TanStack Query Error States
|
||||
|
||||
Errors from API calls are automatically caught by TanStack Query. Always display feedback:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function RecordList({ tableName }) {
|
||||
const { data, isLoading, error, isError } = useQuery({
|
||||
queryKey: ["table", tableName],
|
||||
queryFn: () => api.query(tableName, {}),
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Failed to load records: {error instanceof Error ? error.message : "Unknown error"}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
return <Grid records={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Form Validation: Zod + React Hook Form
|
||||
|
||||
Validation errors are tied to fields and displayed inline:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.string().email("Invalid email"),
|
||||
age: z.number().min(18, "Must be 18+"),
|
||||
});
|
||||
|
||||
export default function Form() {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
} = useForm({ resolver: zodResolver(schema) });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Input
|
||||
{...register("name")}
|
||||
placeholder="Name"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
<Input
|
||||
{...register("email")}
|
||||
placeholder="Email"
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Route Errors: Next.js error.tsx Boundary
|
||||
|
||||
Each route segment can have an `error.tsx` file that creates an error boundary:
|
||||
|
||||
```typescript
|
||||
// app/records/[id]/error.tsx
|
||||
"use client";
|
||||
|
||||
export default function RecordError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold mb-4">Error Loading Record</h2>
|
||||
<p className="text-red-600 mb-4">{error.message}</p>
|
||||
<button onClick={reset} className="px-4 py-2 bg-blue-500 text-white">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Errors: ErrorBoundary Component
|
||||
|
||||
Wrap interactive components that may fail in an error boundary:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary }) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded">
|
||||
<p className="font-bold">Failed to load widget</p>
|
||||
<p className="text-sm text-gray-600">{error.message}</p>
|
||||
<button onClick={resetErrorBoundary} className="mt-2 text-blue-600 underline">
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WidgetContainer() {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<MyWidget />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Handling: Axios Interceptor
|
||||
|
||||
Set up a global interceptor to catch 401 responses and redirect to login:
|
||||
|
||||
```typescript
|
||||
// lib/axiosInstance.ts
|
||||
import axios from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function setupAxios(router: ReturnType<typeof useRouter>) {
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const returnTo = encodeURIComponent(window.location.pathname);
|
||||
router.push(`/login?returnTo=${returnTo}`);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### General Rule
|
||||
|
||||
Never swallow errors silently. Always surface them to the user via one of: Alert component, toast notification, field-level error, or error page.
|
||||
|
||||
---
|
||||
|
||||
## Tailwind Class Ordering Convention
|
||||
|
||||
Follow the official **Tailwind CSS IntelliSense** convention for class ordering:
|
||||
`Position → Display → Sizing → Spacing → Typography → Visual → Animation`
|
||||
|
||||
**Example:**
|
||||
```html
|
||||
<div class="relative flex w-full p-4 text-sm font-medium text-gray-900 bg-white border rounded-lg shadow-sm hover:bg-gray-50 transition-colors">
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
**Breakdown:**
|
||||
- `relative` — Position
|
||||
- `flex` — Display
|
||||
- `w-full` — Sizing
|
||||
- `p-4` — Spacing
|
||||
- `text-sm font-medium text-gray-900` — Typography
|
||||
- `bg-white border rounded-lg shadow-sm` — Visual
|
||||
- `hover:bg-gray-50 transition-colors` — Animation/Interaction
|
||||
|
||||
**Multi-line (for long class lists):**
|
||||
```tsx
|
||||
<button
|
||||
className={cn(
|
||||
// Position & Display
|
||||
"relative inline-flex items-center justify-center",
|
||||
// Sizing
|
||||
"w-full px-4 py-2",
|
||||
// Typography
|
||||
"text-sm font-medium text-white",
|
||||
// Visual
|
||||
"bg-blue-600 border border-blue-700 rounded-lg shadow-sm",
|
||||
// Interaction
|
||||
"hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
// Animation
|
||||
"transition-colors duration-200"
|
||||
)}
|
||||
>
|
||||
Click Me
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Requirements
|
||||
|
||||
All QQQ components must meet **WCAG 2.1 AA** standards minimum:
|
||||
|
||||
### Form Inputs and Labels
|
||||
|
||||
- Every form input must have an associated label
|
||||
- Use `htmlFor` to link labels to inputs by ID
|
||||
- For icon-only buttons, use `aria-label`
|
||||
|
||||
```typescript
|
||||
<label htmlFor="name-input">Name:</label>
|
||||
<input id="name-input" type="text" />
|
||||
|
||||
<button aria-label="Delete record" title="Delete record">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- All interactive elements (buttons, links, inputs) must be reachable via Tab key
|
||||
- Tab order should follow visual left-to-right, top-to-bottom flow
|
||||
- Escape key should close modals
|
||||
- Enter/Space should activate buttons
|
||||
|
||||
```typescript
|
||||
<input type="text" onKeyDown={(e) => e.key === "Enter" && handleSubmit()} />
|
||||
<Modal onEscapePress={closeModal} />
|
||||
```
|
||||
|
||||
### Focus Visibility
|
||||
|
||||
- All interactive elements must have visible focus rings
|
||||
- Use Tailwind's `focus:ring-2 focus:ring-offset-2 focus:ring-blue-500` pattern
|
||||
- Never remove focus rings without providing an alternative
|
||||
|
||||
```html
|
||||
<button class="focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Click
|
||||
</button>
|
||||
```
|
||||
|
||||
### ARIA Attributes
|
||||
|
||||
- Use `aria-label` on icon buttons (e.g., `<button aria-label="Close">×</button>`)
|
||||
- Use `aria-live="polite"` for dynamic content updates
|
||||
- Use `aria-expanded` for collapsible sections
|
||||
- Use `aria-disabled` for disabled elements
|
||||
- Use `aria-hidden="true"` for decorative icons
|
||||
|
||||
```typescript
|
||||
<button
|
||||
aria-label="Toggle sidebar"
|
||||
aria-expanded={sidebarOpen}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{message}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Color Contrast
|
||||
|
||||
- Minimum 4.5:1 contrast ratio for normal text (14px or smaller)
|
||||
- Minimum 3:1 contrast ratio for large text (18px+ or 14px+ bold)
|
||||
- Never rely on color alone to convey meaning (always add icon/text)
|
||||
|
||||
Use tools like [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) to verify.
|
||||
|
||||
### Images and Icons
|
||||
|
||||
- All images must have meaningful alt text or `aria-hidden="true"` if decorative
|
||||
- SVG icons should have a title element or aria-label on parent
|
||||
|
||||
```tsx
|
||||
<img src="avatar.jpg" alt="User: John Doe" />
|
||||
<svg aria-label="Star" className="w-5 h-5">
|
||||
<title>Star icon</title>
|
||||
<path d="..." />
|
||||
</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## data-qqq-id Naming Convention
|
||||
|
||||
All QQQ components must include `data-qqq-id` attributes for:
|
||||
- Automated testing (selector stability)
|
||||
- Custom CSS injection via `customCss` metadata
|
||||
- Accessibility tooling integration
|
||||
|
||||
**Pattern:** `{component-type}-{metadata-name}`
|
||||
|
||||
Where `{metadata-name}` is the `name` property from the metadata object (not the `label`).
|
||||
|
||||
### Examples by Component Type
|
||||
|
||||
**Navigation:**
|
||||
```html
|
||||
<nav data-qqq-id="sidebar-nav">
|
||||
<button data-qqq-id="sidebar-item-employees">Employees</button>
|
||||
<button data-qqq-id="sidebar-item-projects">Projects</button>
|
||||
</nav>
|
||||
```
|
||||
|
||||
**Fields (Forms and Grids):**
|
||||
```html
|
||||
<label htmlFor="field-email" data-qqq-id="field-label-email">Email</label>
|
||||
<input id="field-email" data-qqq-id="field-email" type="email" />
|
||||
|
||||
<th data-qqq-id="grid-header-email">Email</th>
|
||||
<td data-qqq-id="grid-cell-email">user@example.com</td>
|
||||
```
|
||||
|
||||
**Buttons:**
|
||||
```html
|
||||
<button data-qqq-id="button-create">Create Record</button>
|
||||
<button data-qqq-id="button-save">Save</button>
|
||||
<button data-qqq-id="button-delete">Delete</button>
|
||||
<button data-qqq-id="button-cancel">Cancel</button>
|
||||
```
|
||||
|
||||
**Filters:**
|
||||
```html
|
||||
<div data-qqq-id="filter-row-0">
|
||||
<select data-qqq-id="filter-field-0">Field</select>
|
||||
<select data-qqq-id="filter-operator-0">Operator</select>
|
||||
<input data-qqq-id="filter-value-0" type="text" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Widgets:**
|
||||
```html
|
||||
<section data-qqq-id="widget-salesChart">
|
||||
<h3 data-qqq-id="widget-label-salesChart">Sales Chart</h3>
|
||||
<canvas data-qqq-id="widget-content-salesChart"></canvas>
|
||||
</section>
|
||||
```
|
||||
|
||||
**General Rule:** If metadata has a `name: "employeeId"`, use `data-qqq-id="field-employeeId"` or `data-qqq-id="grid-header-employeeId"`, never `data-qqq-id="field-Employee ID"` or `data-qqq-id="field-employee_id"`.
|
||||
|
||||
---
|
||||
|
||||
## QQQ Theme Token System
|
||||
|
||||
The application defines **60+ CSS custom properties** in `src/styles/qqq-theme.css` for theming and customization.
|
||||
|
||||
### Core Color Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Primary & Accent */
|
||||
--qqq-primary-color: #1976d2;
|
||||
--qqq-secondary-color: #9c27b0;
|
||||
--qqq-accent-color: var(--qqq-primary-color); /* From QBrandingMetaData.accentColor */
|
||||
--qqq-success-color: #4caf50;
|
||||
--qqq-warning-color: #ff9800;
|
||||
--qqq-error-color: #f44336;
|
||||
--qqq-info-color: #2196f3;
|
||||
}
|
||||
```
|
||||
|
||||
### Sidebar Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--qqq-sidebar-background: #1e293b; /* Dark slate */
|
||||
--qqq-sidebar-text: #e2e8f0; /* Light text */
|
||||
--qqq-sidebar-hover: rgba(255, 255, 255, 0.1);
|
||||
--qqq-sidebar-active: var(--qqq-accent-color);
|
||||
--qqq-sidebar-border: #334155;
|
||||
}
|
||||
```
|
||||
|
||||
### Table/Grid Tokens
|
||||
|
||||
```css
|
||||
:root {
|
||||
--qqq-grid-header-bg: #f8fafc; /* Light gray header */
|
||||
--qqq-grid-row-hover: #f1f5f9;
|
||||
--qqq-grid-border: #e2e8f0;
|
||||
--qqq-grid-text: #1e293b;
|
||||
--qqq-grid-text-muted: #64748b;
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode Overrides
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--qqq-grid-header-bg: #1e293b;
|
||||
--qqq-grid-row-hover: #334155;
|
||||
--qqq-grid-border: #475569;
|
||||
--qqq-grid-text: #f1f5f9;
|
||||
--qqq-grid-text-muted: #cbd5e1;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
|
||||
Reference these tokens via CSS custom properties in Tailwind or inline styles:
|
||||
|
||||
```tsx
|
||||
// In Tailwind (configure in tailwind.config.ts)
|
||||
<div className="bg-[var(--qqq-primary-color)] text-[var(--qqq-sidebar-text)]">
|
||||
Content
|
||||
</div>
|
||||
|
||||
// Or in inline styles
|
||||
<div style={{ color: "var(--qqq-accent-color)" }}>
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
**Tailwind Config Integration:**
|
||||
```typescript
|
||||
// tailwind.config.ts
|
||||
export default {
|
||||
theme: {
|
||||
colors: {
|
||||
qqq: {
|
||||
primary: "var(--qqq-primary-color)",
|
||||
secondary: "var(--qqq-secondary-color)",
|
||||
accent: "var(--qqq-accent-color)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Handle QQQ customCss
|
||||
|
||||
QQQ allows backend to inject custom CSS via the `customCss` metadata field. The mechanism:
|
||||
|
||||
1. **Backend provides CSS:** Custom CSS rules are defined targeting `[data-qqq-id="..."]` selectors
|
||||
2. **Frontend injects dynamically:** Create a `<style>` tag in the document head and insert the CSS
|
||||
3. **All components must have data-qqq-id:** Without these identifiers, custom selectors won't match
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// hooks/useCustomCSS.ts
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useCustomCSS(customCss: string | undefined) {
|
||||
useEffect(() => {
|
||||
if (!customCss) return;
|
||||
|
||||
// Create or update style tag
|
||||
let styleTag = document.getElementById("qqq-custom-css");
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement("style");
|
||||
styleTag.id = "qqq-custom-css";
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
|
||||
styleTag.textContent = customCss;
|
||||
|
||||
return () => {
|
||||
// Keep style tag in DOM (don't remove on unmount)
|
||||
// Custom CSS should persist for the session
|
||||
};
|
||||
}, [customCss]);
|
||||
}
|
||||
```
|
||||
|
||||
### Example Backend-Provided CSS
|
||||
|
||||
```css
|
||||
/* From backend customCss field */
|
||||
[data-qqq-id="field-salary"] {
|
||||
font-weight: bold;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
[data-qqq-id="button-delete"] {
|
||||
background-color: #d32f2f !important;
|
||||
}
|
||||
|
||||
[data-qqq-id="grid-header-status"] {
|
||||
background-color: #1976d2 !important;
|
||||
color: white;
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Always include `!important` in custom CSS (to override component styles)
|
||||
- Selectors should use exact `data-qqq-id` matches, not partial/complex selectors
|
||||
- Never allow inline `style` attributes from backend (XSS vulnerability)
|
||||
- Validate custom CSS syntax and length before injection
|
||||
- Log any CSS injection errors to console for debugging
|
||||
|
||||
---
|
||||
|
||||
## Mock API Layer (MSW)
|
||||
|
||||
A full **MSW (Mock Service Worker) v2** mock is implemented at `src/mocks/`. It intercepts all `/qqq/v1/*` requests and returns realistic fixture data, enabling frontend development with no backend required.
|
||||
|
||||
### Activating the Mock
|
||||
|
||||
```bash
|
||||
cp .env.mock .env.local && pnpm dev
|
||||
# OR
|
||||
NEXT_PUBLIC_MOCK_API=true pnpm dev
|
||||
```
|
||||
|
||||
### Mock Data Available
|
||||
|
||||
| App | Table | Records |
|
||||
|-----|-------|---------|
|
||||
| CRM | `person` (People) | 25 |
|
||||
| CRM | `company` (Companies) | 10 |
|
||||
| CRM | `order` (Orders) | 20 |
|
||||
| Inventory | `product` (Products) | 15 |
|
||||
| Inventory | `supplier` (Suppliers) | 8 |
|
||||
|
||||
Auth type is `FULLY_ANONYMOUS` — the app auto-sessions with no login interaction.
|
||||
|
||||
Dashboard widgets: 4 statistics tiles, bar chart, line chart, record grid.
|
||||
|
||||
### Adding Handlers for New Packages
|
||||
|
||||
When a new package introduces new API endpoints (e.g., a new process, a new widget type), **add a corresponding handler** in `src/mocks/handlers/`. The existing handlers show the pattern:
|
||||
|
||||
```
|
||||
src/mocks/handlers/
|
||||
├── auth.ts # /metaData/authentication, /manageSession, /logout
|
||||
├── metadata.ts # /metaData, /metaData/table/:name, /metaData/process/:name
|
||||
├── tables.ts # /table/:name/query|count|:pk (full CRUD, in-memory store)
|
||||
├── processes.ts # /processes/:name/init|step|status|cancel
|
||||
├── widgets.ts # /widget/:widgetName
|
||||
├── possible-values.ts
|
||||
└── index.ts # combines all handlers
|
||||
```
|
||||
|
||||
In-memory mutations (POST/PUT/DELETE) persist within the browser session and reset on reload.
|
||||
|
||||
### Vitest Integration
|
||||
|
||||
The MSW node server is wired into the Vitest setup (`tests/setup.ts`). Unit tests automatically intercept API calls — no real network requests are made.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
These conventions ensure:
|
||||
- **Consistency:** All code follows the same patterns
|
||||
- **Type Safety:** Full TypeScript coverage with strict mode
|
||||
- **Maintainability:** Shared understanding of architecture and patterns
|
||||
- **Accessibility:** WCAG 2.1 AA compliance across all components
|
||||
- **Customizability:** Custom CSS injection, theming, and metadata-driven UI
|
||||
- **Performance:** Strategic use of Server Components, TanStack Query caching, and code splitting
|
||||
|
||||
Every implementation agent must adhere to these standards. Deviations require explicit justification and team consensus.
|
||||
@@ -0,0 +1,563 @@
|
||||
# QQQ TypeScript Type Definitions
|
||||
|
||||
> These types are ported from `qqq-frontend-core/src/model/`. Every implementation agent must use these types exactly as defined.
|
||||
|
||||
## Core Data Types
|
||||
|
||||
### QRecord
|
||||
Single database record with values, display representations, and metadata.
|
||||
```typescript
|
||||
export interface QRecord {
|
||||
tableName: string; // Name of the source table
|
||||
recordLabel: string; // Display-friendly record identifier
|
||||
values: Record<string, any>; // Raw field values keyed by field name
|
||||
displayValues: Record<string, string>; // Formatted/display string representations
|
||||
associatedRecords?: Record<string, QRecord[]>; // Related records from joins
|
||||
errors?: string[]; // Validation errors encountered
|
||||
warnings?: string[]; // Non-fatal warnings
|
||||
}
|
||||
```
|
||||
|
||||
### QPossibleValue
|
||||
Represents a selectable option from a dropdown or autocomplete field.
|
||||
```typescript
|
||||
export interface QPossibleValue {
|
||||
id: number | string; // Unique identifier for the value
|
||||
label: string; // Display label shown to user
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metadata Types
|
||||
|
||||
### QInstance
|
||||
Top-level metadata container describing the entire application instance.
|
||||
```typescript
|
||||
export interface QInstance {
|
||||
apps: Record<string, QAppMetaData>; // All defined apps keyed by name
|
||||
appTree: QAppTreeNode[]; // Hierarchical navigation tree
|
||||
tables: Record<string, QTableMetaData>; // All tables keyed by name
|
||||
processes: Record<string, QProcessMetaData>; // All processes keyed by name
|
||||
reports: Record<string, any>; // All reports keyed by name
|
||||
widgets: Record<string, QWidgetMetaData>; // All widgets keyed by name
|
||||
branding: QBrandingMetaData; // Application branding and theme
|
||||
helpContents: Record<string, QHelpContent>; // Context-specific help
|
||||
environmentValues: Record<string, string>; // Environment configuration
|
||||
}
|
||||
```
|
||||
|
||||
### QTableMetaData
|
||||
Complete definition of a table, including fields, sections, permissions, and UI hints.
|
||||
```typescript
|
||||
export interface QTableMetaData {
|
||||
name: string; // Unique table identifier
|
||||
label: string; // User-facing table name
|
||||
isHidden: boolean; // Hide from navigation if true
|
||||
primaryKeyField: string; // Name of the primary key field
|
||||
fields: Record<string, QFieldMetaData>; // Field definitions
|
||||
sections: QTableSection[]; // Form/view section organization
|
||||
exposedJoins: QExposedJoin[]; // Relationships to other tables
|
||||
capabilities: Capability[]; // Operations enabled (QUERY, GET, INSERT, UPDATE, DELETE, COUNT)
|
||||
readPermission: boolean; // User can read records
|
||||
insertPermission: boolean; // User can create records
|
||||
editPermission: boolean; // User can modify records
|
||||
deletePermission: boolean; // User can delete records
|
||||
usesVariants: boolean; // Table has multiple views
|
||||
variantTableLabel: string; // Label for variant selector
|
||||
helpContent?: QHelpContent; // Table-level help
|
||||
supplementalTableMetaData?: Record<string, any>; // Additional metadata
|
||||
shareableTableMetaData?: Record<string, any>; // Shareable configuration
|
||||
}
|
||||
```
|
||||
|
||||
### QFieldMetaData
|
||||
Defines a single field within a table, including type, constraints, and display behavior.
|
||||
```typescript
|
||||
export interface QFieldMetaData {
|
||||
name: string; // Field identifier
|
||||
label: string; // User-facing field name
|
||||
type: QFieldType; // Data type (STRING, INTEGER, BOOLEAN, DATE, etc.)
|
||||
isRequired: boolean; // Must have a value
|
||||
isEditable: boolean; // User can modify this field
|
||||
isHeavy: boolean; // Large data; load on demand
|
||||
isHidden: boolean; // Don't display in UI
|
||||
defaultValue?: any; // Initial value for new records
|
||||
possibleValueSourceName?: string; // Reference to dropdown options endpoint
|
||||
displayFormat?: string; // Formatting pattern for display (e.g., "currency", "percentage")
|
||||
maxLength?: number; // Maximum string/text length
|
||||
gridColumns?: number; // Width hint for responsive layouts
|
||||
adornments: FieldAdornment[]; // Visual/interactive enhancements (icons, links, file uploads)
|
||||
helpContents?: QHelpContent[]; // Field-level help
|
||||
behaviors?: string[]; // Special behaviors (e.g., "readonly_after_create")
|
||||
}
|
||||
```
|
||||
|
||||
### QProcessMetaData
|
||||
Definition of a process or workflow with its steps and configuration.
|
||||
```typescript
|
||||
export interface QProcessMetaData {
|
||||
name: string; // Process identifier
|
||||
label: string; // User-facing process name
|
||||
tableName: string; // Associated table name
|
||||
isHidden: boolean; // Hide from navigation
|
||||
iconName: string; // Icon reference for UI
|
||||
hasPermission: boolean; // User can execute this process
|
||||
stepFlow: "LINEAR"; // Step flow type (only LINEAR currently documented)
|
||||
minInputRecords: number; // Minimum records required as input
|
||||
maxInputRecords: number; // Maximum records allowed as input
|
||||
frontendSteps: QFrontendStepMetaData[]; // Step definitions
|
||||
}
|
||||
```
|
||||
|
||||
### QFrontendStepMetaData
|
||||
Describes a single step within a process workflow.
|
||||
```typescript
|
||||
export interface QFrontendStepMetaData {
|
||||
name: string; // Step identifier
|
||||
label: string; // Display name
|
||||
format?: string; // Layout format hint
|
||||
components: QFrontendComponent[]; // UI components to render
|
||||
formFields?: QFieldMetaData[]; // Fields for input forms
|
||||
viewFields?: QFieldMetaData[]; // Fields for display-only views
|
||||
recordListFields?: QFieldMetaData[]; // Fields for record list display
|
||||
helpContents?: QHelpContent[]; // Step-level help
|
||||
}
|
||||
```
|
||||
|
||||
### QAppMetaData
|
||||
Defines an application or dashboard with its sections and widgets.
|
||||
```typescript
|
||||
export interface QAppMetaData {
|
||||
name: string; // App identifier
|
||||
label: string; // Display name
|
||||
children: QAppTreeNode[]; // Child apps or items
|
||||
iconName: string; // Icon reference
|
||||
widgets: string[]; // Dashboard widget names
|
||||
sections: QAppSection[]; // Logical sections within app
|
||||
}
|
||||
```
|
||||
|
||||
### QAppTreeNode
|
||||
Represents a single node in the hierarchical application navigation tree.
|
||||
```typescript
|
||||
export interface QAppTreeNode {
|
||||
name: string; // Node identifier
|
||||
label: string; // Display name
|
||||
type: QAppNodeType; // Node type (TABLE, PROCESS, REPORT, APP)
|
||||
children?: QAppTreeNode[]; // Nested children
|
||||
iconName?: string; // Icon reference
|
||||
icon?: QIcon; // Detailed icon configuration
|
||||
}
|
||||
```
|
||||
|
||||
### QAppNodeType
|
||||
Type discriminator for navigation tree nodes.
|
||||
```typescript
|
||||
export type QAppNodeType = "TABLE" | "PROCESS" | "REPORT" | "APP";
|
||||
```
|
||||
|
||||
### QAppSection
|
||||
Represents a section within an app (e.g., "Users", "Settings") containing tables/processes/reports.
|
||||
```typescript
|
||||
export interface QAppSection {
|
||||
name: string; // Section identifier
|
||||
label: string; // Display name
|
||||
icon?: QIcon; // Section icon
|
||||
tables: string[]; // Table names in section
|
||||
processes: string[]; // Process names in section
|
||||
reports: string[]; // Report names in section
|
||||
}
|
||||
```
|
||||
|
||||
### QWidgetMetaData
|
||||
Configuration for a dashboard widget.
|
||||
```typescript
|
||||
export interface QWidgetMetaData {
|
||||
name: string; // Widget identifier
|
||||
label: string; // Display name
|
||||
type?: string; // Widget type (chart, grid, statistic, etc.)
|
||||
hasPermission: boolean; // User can view this widget
|
||||
dropdowns?: Record<string, any>; // Dropdown selections for widget
|
||||
helpContent?: QHelpContent; // Widget-level help
|
||||
}
|
||||
```
|
||||
|
||||
### QAuthenticationMetaData
|
||||
Authentication system configuration.
|
||||
```typescript
|
||||
export interface QAuthenticationMetaData {
|
||||
name: string; // Authentication provider name
|
||||
type: "AUTH_0" | "OAUTH2" | "FULLY_ANONYMOUS" | "MOCK"; // Auth type
|
||||
values: {
|
||||
clientId?: string; // OAuth client ID
|
||||
baseUrl?: string; // Provider base URL
|
||||
audience?: string; // OAuth audience claim
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### QBrandingMetaData
|
||||
Application branding, theming, and custom styling.
|
||||
```typescript
|
||||
export interface QBrandingMetaData {
|
||||
companyName: string; // Company/organization name
|
||||
companyUrl: string; // Company website URL
|
||||
appName: string; // Application name
|
||||
logo?: string; // Company logo URL or base64
|
||||
icon?: string; // Application icon URL or base64
|
||||
accentColor?: string; // Primary accent color (hex code)
|
||||
banners?: Record<string, Banner>; // Environment/status banners
|
||||
}
|
||||
```
|
||||
|
||||
### QTableSection
|
||||
Organizes fields within a table form or view into logical groupings.
|
||||
```typescript
|
||||
export interface QTableSection {
|
||||
name: string; // Section identifier
|
||||
label: string; // Display name
|
||||
tier?: string; // Priority tier (e.g., "basic", "advanced")
|
||||
iconName?: string; // Icon reference
|
||||
fieldNames: string[]; // Field names in this section
|
||||
widgetName?: string; // Optional embedded widget
|
||||
isHidden: boolean; // Hide entire section
|
||||
gridColumns?: number; // Column count for responsive layout
|
||||
}
|
||||
```
|
||||
|
||||
### QExposedJoin
|
||||
Represents a relationship between tables.
|
||||
```typescript
|
||||
export interface QExposedJoin {
|
||||
label: string; // Relationship display name
|
||||
isMany: boolean; // One-to-many (true) or one-to-one (false)
|
||||
joinTable?: QTableMetaData; // Target table metadata
|
||||
joinPath?: QJoinMetaData[]; // Path steps through intermediate tables
|
||||
}
|
||||
```
|
||||
|
||||
### QJoinMetaData
|
||||
Single step in a join path between tables.
|
||||
```typescript
|
||||
export interface QJoinMetaData {
|
||||
name: string; // Join identifier
|
||||
type: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_ONE"; // Relationship type
|
||||
leftTable: string; // Source table name
|
||||
rightTable: string; // Target table name
|
||||
}
|
||||
```
|
||||
|
||||
### QReportMetaData
|
||||
Definition of a report (typically backed by a process).
|
||||
```typescript
|
||||
export interface QReportMetaData {
|
||||
name: string; // Report identifier
|
||||
label: string; // Display name
|
||||
isHidden: boolean; // Hide from navigation
|
||||
hasPermission: boolean; // User can access report
|
||||
}
|
||||
```
|
||||
|
||||
### QHelpContent
|
||||
Contextual help text and rich content for UI elements.
|
||||
```typescript
|
||||
export interface QHelpContent {
|
||||
title?: string; // Help section title
|
||||
content?: string; // Help text or HTML
|
||||
links?: Array<{ label: string; url: string }>; // Related links
|
||||
roles?: string[]; // Show only to specific user roles
|
||||
}
|
||||
```
|
||||
|
||||
### QIcon
|
||||
Icon configuration with name, path, and styling.
|
||||
```typescript
|
||||
export interface QIcon {
|
||||
name: string; // Icon identifier
|
||||
path?: string; // SVG path or icon URL
|
||||
color?: string; // Icon color (hex or CSS color)
|
||||
}
|
||||
```
|
||||
|
||||
### QTableVariant
|
||||
Alternative view/configuration of a table.
|
||||
```typescript
|
||||
export interface QTableVariant {
|
||||
name: string; // Variant identifier
|
||||
label: string; // Display name
|
||||
description?: string; // Variant description
|
||||
}
|
||||
```
|
||||
|
||||
### Banner
|
||||
Status or environment banner configuration.
|
||||
```typescript
|
||||
export interface Banner {
|
||||
text: string; // Banner text
|
||||
severity: "info" | "warning" | "error"; // Visual severity level
|
||||
color?: string; // Custom color (hex)
|
||||
dismissible: boolean; // User can close banner
|
||||
}
|
||||
```
|
||||
|
||||
### FieldAdornment
|
||||
Visual or interactive enhancement applied to a field (links, chips, file uploads, etc.).
|
||||
```typescript
|
||||
export interface FieldAdornment {
|
||||
type: AdornmentType; // Adornment category
|
||||
values?: Record<string, any>; // Configuration-specific values
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enum Types
|
||||
|
||||
### QFieldType
|
||||
Enumeration of all supported field data types.
|
||||
```typescript
|
||||
export type QFieldType =
|
||||
| "STRING" // Short text
|
||||
| "INTEGER" // Whole number
|
||||
| "LONG" // Large whole number
|
||||
| "DECIMAL" // Floating-point number
|
||||
| "BOOLEAN" // True/false
|
||||
| "DATE" // Date (no time)
|
||||
| "TIME" // Time (no date)
|
||||
| "DATE_TIME" // Date and time
|
||||
| "TEXT" // Long text
|
||||
| "HTML" // HTML content
|
||||
| "PASSWORD" // Sensitive text (masked in UI)
|
||||
| "BLOB"; // Binary large object
|
||||
```
|
||||
|
||||
### QComponentType
|
||||
Component types available within process steps.
|
||||
```typescript
|
||||
export type QComponentType =
|
||||
| "HELP_TEXT" // Static help text
|
||||
| "BULK_EDIT_FORM" // Multi-record edit form
|
||||
| "BULK_LOAD_FILE_MAPPING_FORM" // CSV column mapping
|
||||
| "BULK_LOAD_VALUE_MAPPING_FORM" // Source-to-DB value mapping
|
||||
| "BULK_LOAD_PROFILE_FORM" // Saved bulk load profile
|
||||
| "VALIDATION_REVIEW_SCREEN" // Review validation errors
|
||||
| "EDIT_FORM" // Record edit form
|
||||
| "VIEW_FORM" // Record display form
|
||||
| "DOWNLOAD_FORM" // File download form
|
||||
| "RECORD_LIST" // Records display
|
||||
| "PROCESS_SUMMARY_RESULTS" // Process completion summary
|
||||
| "GOOGLE_DRIVE_SELECT_FOLDER" // Google Drive folder picker
|
||||
| "WIDGET" // Custom widget
|
||||
| "HTML"; // Custom HTML content
|
||||
```
|
||||
|
||||
### AdornmentType
|
||||
Types of field enhancements.
|
||||
```typescript
|
||||
export type AdornmentType =
|
||||
| "LINK" // Clickable hyperlink
|
||||
| "CHIP" // Tag/chip display
|
||||
| "SIZE" // Bytesize formatter
|
||||
| "ERROR" // Error state indicator
|
||||
| "RENDER_HTML" // Render as HTML
|
||||
| "REVEAL" // Show/hide toggle
|
||||
| "CODE_EDITOR" // Code editing interface
|
||||
| "FILE_DOWNLOAD" // Download link
|
||||
| "FILE_UPLOAD" // File upload control
|
||||
| "TOOLTIP"; // Hover tooltip
|
||||
```
|
||||
|
||||
### Capability
|
||||
Feature toggle enumerations for table operations.
|
||||
```typescript
|
||||
export type Capability =
|
||||
| "TABLE_QUERY" // List/query records
|
||||
| "TABLE_GET" // Fetch single record
|
||||
| "TABLE_COUNT" // Count records
|
||||
| "TABLE_INSERT" // Create new record
|
||||
| "TABLE_UPDATE" // Edit record
|
||||
| "TABLE_DELETE"; // Delete record
|
||||
```
|
||||
|
||||
### QCriteriaOperator
|
||||
Filter operator enumerations for building query criteria.
|
||||
```typescript
|
||||
export type QCriteriaOperator =
|
||||
| "EQUALS" // Exact match
|
||||
| "NOT_EQUALS" // Not equal
|
||||
| "NOT_EQUALS_OR_IS_NULL" // Not equal or missing
|
||||
| "IN" // Within list
|
||||
| "NOT_IN" // Not in list
|
||||
| "STARTS_WITH" // String prefix
|
||||
| "ENDS_WITH" // String suffix
|
||||
| "CONTAINS" // Substring match
|
||||
| "NOT_STARTS_WITH" // Exclude prefix
|
||||
| "NOT_ENDS_WITH" // Exclude suffix
|
||||
| "NOT_CONTAINS" // Exclude substring
|
||||
| "LESS_THAN" // Numeric/date <
|
||||
| "LESS_THAN_OR_EQUALS" // Numeric/date <=
|
||||
| "GREATER_THAN" // Numeric/date >
|
||||
| "GREATER_THAN_OR_EQUALS" // Numeric/date >=
|
||||
| "IS_BLANK" // Empty/null
|
||||
| "IS_NOT_BLANK" // Non-empty/not null
|
||||
| "BETWEEN" // Within range (inclusive)
|
||||
| "NOT_BETWEEN"; // Outside range
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Types
|
||||
|
||||
### QQueryFilter
|
||||
Tree-structured query definition with criteria, ordering, and pagination.
|
||||
```typescript
|
||||
export interface QQueryFilter {
|
||||
criteria: QFilterCriteria[]; // Filter conditions
|
||||
orderBys?: QFilterOrderBy[]; // Sort specifications
|
||||
subFilters?: QQueryFilter[]; // Nested boolean logic
|
||||
booleanOperator: "AND" | "OR"; // How criteria/subFilters combine
|
||||
skip: number; // Offset for pagination
|
||||
limit: number; // Max records to return
|
||||
}
|
||||
```
|
||||
|
||||
### QFilterCriteria
|
||||
Single filter condition within a QQueryFilter.
|
||||
```typescript
|
||||
export interface QFilterCriteria {
|
||||
fieldName: string; // Field being filtered
|
||||
operator: QCriteriaOperator; // Comparison operator
|
||||
values: (string | number | boolean | FilterVariableExpression | NowExpression | NowWithOffsetExpression | ThisOrLastPeriodExpression)[]; // Values to compare
|
||||
otherFieldName?: string; // For field-to-field comparisons
|
||||
}
|
||||
```
|
||||
|
||||
### QFilterOrderBy
|
||||
Sort specification within a query.
|
||||
```typescript
|
||||
export interface QFilterOrderBy {
|
||||
fieldName: string; // Field to sort by
|
||||
isAscending: boolean; // Sort direction
|
||||
}
|
||||
```
|
||||
|
||||
### QueryJoin
|
||||
Join specification for including related tables in a query.
|
||||
```typescript
|
||||
export interface QueryJoin {
|
||||
joinTable: string; // Table name to join
|
||||
select: boolean; // Include in results
|
||||
type: "INNER" | "LEFT" | "RIGHT" | "FULL"; // Join type
|
||||
baseTableOrAlias?: string; // Base table or alias
|
||||
alias?: string; // Join alias
|
||||
joinName?: string; // Named join identifier
|
||||
}
|
||||
```
|
||||
|
||||
### FilterVariableExpression
|
||||
Placeholder for dynamic filter variable substitution.
|
||||
```typescript
|
||||
export interface FilterVariableExpression {
|
||||
type: "FILTER_VARIABLE"; // Expression type marker
|
||||
variableName: string; // Variable name to substitute
|
||||
}
|
||||
```
|
||||
|
||||
### NowExpression
|
||||
Represents the current date/time in filter expressions.
|
||||
```typescript
|
||||
export interface NowExpression {
|
||||
type: "NOW"; // Expression type marker
|
||||
}
|
||||
```
|
||||
|
||||
### NowWithOffsetExpression
|
||||
Represents current date/time with offset (e.g., +7 days, -1 month).
|
||||
```typescript
|
||||
export interface NowWithOffsetExpression {
|
||||
type: "NOW_WITH_OFFSET"; // Expression type marker
|
||||
offsetValue: number; // Offset magnitude
|
||||
offsetUnit: "DAY" | "WEEK" | "MONTH" | "YEAR"; // Offset unit
|
||||
isNegativeOffset: boolean; // Add (false) or subtract (true)
|
||||
}
|
||||
```
|
||||
|
||||
### ThisOrLastPeriodExpression
|
||||
Represents "this period" or "last period" relative expressions (week, month, quarter, year).
|
||||
```typescript
|
||||
export interface ThisOrLastPeriodExpression {
|
||||
type: "THIS_OR_LAST_PERIOD"; // Expression type marker
|
||||
period: "WEEK" | "MONTH" | "QUARTER" | "YEAR"; // Time period
|
||||
isLast: boolean; // This period (false) or last period (true)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Process Job Types
|
||||
|
||||
### QJobStarted
|
||||
Indicates async job has been initiated; frontend should poll for status.
|
||||
```typescript
|
||||
export interface QJobStarted {
|
||||
processUUID: string; // Process execution ID
|
||||
jobUUID: string; // Async job ID for polling
|
||||
}
|
||||
```
|
||||
|
||||
### QJobRunning
|
||||
Indicates job is in progress with optional progress information.
|
||||
```typescript
|
||||
export interface QJobRunning {
|
||||
processUUID: string; // Process execution ID
|
||||
message: string; // Status message
|
||||
current?: number; // Current progress count
|
||||
total?: number; // Total progress count
|
||||
}
|
||||
```
|
||||
|
||||
### QJobComplete
|
||||
Indicates job completed successfully with next step information.
|
||||
```typescript
|
||||
export interface QJobComplete {
|
||||
processUUID: string; // Process execution ID
|
||||
values: Record<string, any>; // Step output values
|
||||
nextStep?: string; // Name of next step to execute
|
||||
backStep?: string; // Name of previous step (for back navigation)
|
||||
processMetaDataAdjustment?: ProcessMetaDataAdjustment; // Dynamic step/field changes
|
||||
}
|
||||
```
|
||||
|
||||
### QJobError
|
||||
Indicates job failed with error details.
|
||||
```typescript
|
||||
export interface QJobError {
|
||||
processUUID: string; // Process execution ID
|
||||
error: string; // Technical error details
|
||||
userFacingError?: string; // User-friendly error message
|
||||
}
|
||||
```
|
||||
|
||||
### ProcessMetaDataAdjustment
|
||||
Dynamic modifications to process metadata after a step completes.
|
||||
```typescript
|
||||
export interface ProcessMetaDataAdjustment {
|
||||
addedSteps?: QFrontendStepMetaData[]; // Steps to insert
|
||||
removedSteps?: string[]; // Step names to remove
|
||||
modifiedFields?: Record<string, Partial<QFieldMetaData>>; // Field changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- All type names prefixed with `Q` follow the QQQ naming convention
|
||||
- Use `interface` (not `class`) as these represent data contracts
|
||||
- Import and use these types in all frontend implementations to ensure API contract alignment
|
||||
- Never create custom type aliases; always reference these canonical definitions
|
||||
- Mark fields as optional using `?` only when explicitly documented as optional
|
||||
- All `Record<string, X>` mappings use string keys (field/table/process/widget names)
|
||||
- Process job types form a discriminated union; use the `processUUID` and type presence to determine state
|
||||
- Filter expressions support runtime variable substitution via `FilterVariableExpression` types
|
||||
@@ -0,0 +1,451 @@
|
||||
# Master Architect Agent Prompt
|
||||
|
||||
## Your Role
|
||||
|
||||
You are the **Master Architect Agent** for the QQQ Admin UI Modernization project. Your job is to take the requirements specification and produce a set of **detailed implementation plans** — one per work package — that implementation agents can execute independently to build the new admin UI.
|
||||
|
||||
You do NOT write application code yourself. You produce plans that are precise enough that an implementation agent (an AI coding agent with access to the codebase) can follow them and produce correct, integrating code on the first pass.
|
||||
|
||||
---
|
||||
|
||||
## Inputs Available to You
|
||||
|
||||
You have access to the full codebase. The following files are critical:
|
||||
|
||||
### Requirements Specification
|
||||
- **`docs/qqq-admin-modernization-requirements.md`** — The complete requirements document. Read this FIRST, in its entirety, before doing anything else.
|
||||
|
||||
### Existing Source Code (Reference Implementation)
|
||||
These files represent the current system you are replacing. Read them to understand existing patterns, edge cases, and integration points:
|
||||
|
||||
- **API Client Layer:**
|
||||
- `qqq-frontend-core/src/controllers/QController.ts` (~1169 lines) — Current API client, all HTTP calls
|
||||
- `qqq-frontend-core/src/controllers/QControllerV1.ts` (~1117 lines) — V1 API client
|
||||
- `qqq-frontend-core/src/model/**/*.ts` (~42 files) — All TypeScript domain models
|
||||
|
||||
- **Current Frontend UI:**
|
||||
- `qqq-frontend-material-dashboard/src/App.tsx` — Route generation, auth flow, layout structure
|
||||
- `qqq-frontend-material-dashboard/src/qqq/pages/**` — All page components
|
||||
- `qqq-frontend-material-dashboard/src/qqq/components/**` — All shared components (~217 files)
|
||||
|
||||
- **Backend API (source of truth for endpoints):**
|
||||
- `qqq/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/` — All v1 endpoint specs
|
||||
- `qqq/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/QApplicationJavalinServer.java` — Route registration
|
||||
|
||||
---
|
||||
|
||||
## What You Must Produce
|
||||
|
||||
### Output Structure
|
||||
|
||||
Create a directory `docs/implementation-plans/` containing:
|
||||
|
||||
```
|
||||
docs/implementation-plans/
|
||||
├── 00-project-overview.md # Dependency graph, sequencing, shared conventions
|
||||
├── 01-scaffold-and-auth.md # Work Package 1
|
||||
├── 02-record-query.md # Work Package 2
|
||||
├── 03-record-view-and-crud.md # Work Package 3
|
||||
├── 04-process-execution.md # Work Package 4
|
||||
├── 05-dashboard-and-widgets.md # Work Package 5
|
||||
├── 06-polish-and-parity.md # Work Package 6
|
||||
├── shared-context/
|
||||
│ ├── api-contract.md # Extracted from requirements Section 3 — given to every agent
|
||||
│ ├── type-definitions.md # Key TypeScript types every agent needs
|
||||
│ └── coding-conventions.md # Project-wide patterns and rules
|
||||
└── review-checklists/
|
||||
├── review-01-scaffold.md # Review criteria for Package 1
|
||||
├── review-02-query.md # Review criteria for Package 2
|
||||
├── review-03-crud.md # Review criteria for Package 3
|
||||
├── review-04-processes.md # Review criteria for Package 4
|
||||
├── review-05-widgets.md # Review criteria for Package 5
|
||||
└── review-06-polish.md # Review criteria for Package 6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Build Each Work Package Plan
|
||||
|
||||
### Step 1: Read the Requirements and Code
|
||||
|
||||
Before writing any plans, read:
|
||||
1. The full requirements doc (`docs/qqq-admin-modernization-requirements.md`)
|
||||
2. The current `App.tsx` to understand route generation
|
||||
3. The current `QController.ts` and `QControllerV1.ts` to understand every API call
|
||||
4. A representative sample of page components (RecordQuery, RecordView, EntityForm, ProcessRun) to understand the current implementation patterns
|
||||
5. A representative sample of shared components to understand the component architecture
|
||||
|
||||
### Step 2: Produce the Project Overview (`00-project-overview.md`)
|
||||
|
||||
This file establishes the shared foundation. It must contain:
|
||||
|
||||
**Technology Stack Decisions:**
|
||||
- Next.js App Router (as recommended in requirements Section 4.3)
|
||||
- TypeScript in strict mode
|
||||
- Tailwind CSS for styling (utility-first, replaces MUI's styling system)
|
||||
- shadcn/ui as the component primitive library (headless, composable, Tailwind-native)
|
||||
- TanStack Table for the data grid (replaces MUI X DataGrid Pro)
|
||||
- React Hook Form + Zod for forms (replaces Formik + Yup)
|
||||
- TanStack Query for data fetching and caching
|
||||
- Vitest for unit tests, Playwright for E2E
|
||||
|
||||
**Project Structure:**
|
||||
Define the exact directory layout for the new project. Example:
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ ├── (auth)/ # Auth routes (login, callback)
|
||||
│ ├── (dashboard)/ # Authenticated routes
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── [appName]/
|
||||
│ │ │ ├── [tableName]/
|
||||
│ │ │ │ ├── page.tsx # RecordQuery
|
||||
│ │ │ │ ├── create/page.tsx # Create record
|
||||
│ │ │ │ ├── [recordId]/
|
||||
│ │ │ │ │ ├── page.tsx # RecordView
|
||||
│ │ │ │ │ └── edit/page.tsx # Edit record
|
||||
│ │ │ └── [processName]/
|
||||
│ │ │ └── page.tsx # ProcessRun
|
||||
│ │ └── layout.tsx # Dashboard layout (sidebar + nav)
|
||||
│ └── layout.tsx # Root layout
|
||||
├── components/
|
||||
│ ├── layout/ # Sidebar, navbar, breadcrumbs
|
||||
│ ├── records/ # Record-related components
|
||||
│ ├── forms/ # Dynamic form components
|
||||
│ ├── query/ # Filter builder, column config
|
||||
│ ├── process/ # Process step components
|
||||
│ ├── widgets/ # Widget system
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── lib/
|
||||
│ ├── api/ # API client (typed, generated from contract)
|
||||
│ ├── auth/ # Auth providers and session management
|
||||
│ ├── hooks/ # Shared React hooks
|
||||
│ └── utils/ # Utility functions
|
||||
├── types/ # Shared TypeScript types (ported from qqq-frontend-core)
|
||||
└── styles/ # Global styles, Tailwind config, theme tokens
|
||||
```
|
||||
|
||||
**Dependency Graph:**
|
||||
```
|
||||
Package 1 (Scaffold + Auth)
|
||||
↓
|
||||
Package 2 (Record Query) ←→ Package 3 (Record View + CRUD)
|
||||
↓ ↓
|
||||
Package 4 (Process Execution)
|
||||
↓
|
||||
Package 5 (Dashboard + Widgets)
|
||||
↓
|
||||
Package 6 (Polish + Parity)
|
||||
```
|
||||
Packages 2 and 3 can run in parallel after Package 1. Package 4 depends on form components from Package 3. Package 5 depends on the data grid from Package 2 and forms from Package 3. Package 6 depends on everything.
|
||||
|
||||
**Shared Conventions** (also extracted into `shared-context/coding-conventions.md`):
|
||||
- All components that render from metadata must accept the metadata type as a prop — never fetch it internally
|
||||
- API calls go through the typed client in `lib/api/` — never raw `fetch`
|
||||
- No hardcoded table names, field names, or process names anywhere in the UI
|
||||
- Every component must have a `data-qqq-id` attribute for CSS customization
|
||||
- All user-facing text that comes from metadata must use the `label` field, never `name`
|
||||
- Error boundaries at the route level and the widget level
|
||||
|
||||
### Step 3: Produce Each Work Package Plan
|
||||
|
||||
Each work package file (`01-scaffold-and-auth.md` through `06-polish-and-parity.md`) must follow this exact structure:
|
||||
|
||||
```markdown
|
||||
# Work Package [N]: [Name]
|
||||
|
||||
## Prerequisites
|
||||
- List which previous packages must be complete
|
||||
- List specific files/components from previous packages that this package depends on
|
||||
|
||||
## Requirements Traceability
|
||||
- List every section/subsection from the requirements doc that this package implements
|
||||
- Example: "Section 5.5 (Authentication), Section 5.3.1 (Layout Components), Section 3.2 (Auth Endpoints)"
|
||||
|
||||
## Shared Context Files
|
||||
- List which files from shared-context/ the implementation agent needs
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
- Specific list of features/components to build
|
||||
|
||||
### Out of Scope
|
||||
- What is explicitly NOT part of this package (to prevent scope creep)
|
||||
|
||||
## Detailed Implementation Steps
|
||||
|
||||
### Step 1: [Action]
|
||||
**What:** [Precise description of what to create/modify]
|
||||
**Files to create:**
|
||||
- `src/path/to/file.tsx` — [Purpose and key exports]
|
||||
- `src/path/to/file.ts` — [Purpose and key exports]
|
||||
|
||||
**Key implementation details:**
|
||||
- [Specific technical detail the agent needs to know]
|
||||
- [Edge case to handle]
|
||||
- [Pattern to follow from the existing codebase — reference specific files]
|
||||
|
||||
**API integration:**
|
||||
- Endpoint: `[METHOD] /qqq/v1/[path]`
|
||||
- Request: [Shape]
|
||||
- Response: [Shape]
|
||||
- Error handling: [What to do on 401, 404, 500]
|
||||
|
||||
**Reference implementation:**
|
||||
- See `qqq-frontend-material-dashboard/src/qqq/[path]` for the current implementation of this feature
|
||||
- Key behaviors to preserve: [list]
|
||||
- Key behaviors to change/improve: [list]
|
||||
|
||||
### Step 2: [Action]
|
||||
[Same structure...]
|
||||
|
||||
[Continue for all steps...]
|
||||
|
||||
## Component Specifications
|
||||
|
||||
For each component this package creates, specify:
|
||||
|
||||
### `ComponentName`
|
||||
**File:** `src/components/[path]/ComponentName.tsx`
|
||||
**Props:**
|
||||
```typescript
|
||||
interface ComponentNameProps {
|
||||
// exact prop types
|
||||
}
|
||||
```
|
||||
**Behavior:**
|
||||
- [What it renders in each state: loading, empty, error, populated]
|
||||
- [User interactions it handles]
|
||||
- [Events it emits / callbacks it invokes]
|
||||
|
||||
**Metadata-driven aspects:**
|
||||
- [Which metadata fields control its rendering]
|
||||
- [How it adapts to different field types / configurations]
|
||||
|
||||
**Responsive behavior:**
|
||||
- Mobile: [specific behavior]
|
||||
- Tablet: [specific behavior]
|
||||
- Desktop: [specific behavior]
|
||||
|
||||
## API Client Functions
|
||||
|
||||
For each API function this package requires:
|
||||
|
||||
### `functionName`
|
||||
**File:** `src/lib/api/[module].ts`
|
||||
**Signature:** `(params: Type) => Promise<ReturnType>`
|
||||
**Endpoint:** `[METHOD] /qqq/v1/[path]`
|
||||
**Caching:** [TanStack Query key, stale time, cache time]
|
||||
**Error handling:** [Specific error scenarios and how to handle them]
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
- [Specific components/functions to test]
|
||||
- [Key scenarios to cover]
|
||||
- [Mock data patterns]
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
- [User flow 1: description and steps]
|
||||
- [User flow 2: description and steps]
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
Each criterion must be binary (pass/fail) and testable:
|
||||
|
||||
- [ ] [Specific, measurable criterion]
|
||||
- [ ] [Specific, measurable criterion]
|
||||
- [ ] [Example: "The sidebar renders all apps from appTree metadata with correct nesting and icons"]
|
||||
- [ ] [Example: "Clicking a table in the sidebar navigates to /app/{tableName} and the RecordQuery page loads"]
|
||||
- [ ] [Example: "A 401 response from any API call redirects to the login page without losing the current URL"]
|
||||
```
|
||||
|
||||
### Step 4: Produce Shared Context Files
|
||||
|
||||
**`shared-context/api-contract.md`:**
|
||||
Extract Section 3 from the requirements doc verbatim. Every implementation agent receives this file. It is the single source of truth for API integration.
|
||||
|
||||
**`shared-context/type-definitions.md`:**
|
||||
Port the key TypeScript types from `qqq-frontend-core/src/model/` into a single reference document. Include:
|
||||
- All metadata types (QInstance, QTableMetaData, QFieldMetaData, QProcessMetaData, QAppMetaData, QWidgetMetaData, etc.)
|
||||
- All data types (QRecord, QPossibleValue)
|
||||
- All query types (QQueryFilter, QFilterCriteria, QCriteriaOperator, QFilterOrderBy, QueryJoin)
|
||||
- All process types (QJobStarted, QJobRunning, QJobComplete, QJobError)
|
||||
- All enum types (QFieldType, QComponentType, AdornmentType, Capability, QAppNodeType)
|
||||
|
||||
Annotate each type with a one-line description of when/where it's used.
|
||||
|
||||
**`shared-context/coding-conventions.md`:**
|
||||
Define project-wide rules:
|
||||
- File naming conventions
|
||||
- Component patterns (server components vs. client components — when to use each)
|
||||
- State management approach (TanStack Query for server state, React context for UI state)
|
||||
- Error handling patterns
|
||||
- Import organization
|
||||
- Tailwind class ordering convention
|
||||
- Accessibility requirements (ARIA attributes, keyboard navigation, focus management)
|
||||
- `data-qqq-id` naming convention for CSS customization hooks
|
||||
- How to handle the QQQ theme tokens (CSS custom properties)
|
||||
|
||||
### Step 5: Produce Review Checklists
|
||||
|
||||
Each review checklist (`review-checklists/review-0N-*.md`) must contain:
|
||||
|
||||
1. **Requirements coverage:** For each requirement traced to this package, a yes/no check that it's implemented
|
||||
2. **Integration check:** Verify the package integrates with previous packages (imports resolve, types match, navigation works)
|
||||
3. **API contract compliance:** Verify every API call matches Section 3 exactly (correct path, correct request shape, correct response handling)
|
||||
4. **Metadata-driven check:** Verify no hardcoded table/field/process names exist
|
||||
5. **Responsive check:** Verify mobile/tablet/desktop behavior per Section 5.4
|
||||
6. **Accessibility check:** Keyboard navigation, ARIA attributes, color contrast
|
||||
7. **Type safety check:** No `any` types in component props, all API responses validated
|
||||
|
||||
---
|
||||
|
||||
## Work Package Definitions
|
||||
|
||||
Here is the scope for each package. Use these as starting points, then refine based on what you learn from reading the code.
|
||||
|
||||
### Package 1: Project Scaffold, Auth, and Layout Shell
|
||||
**Requirements:** Sections 5.1, 5.5, 5.3.1 (Layout Components), 3.2 (Auth Endpoints)
|
||||
**Delivers:**
|
||||
- Next.js project initialization with all dependencies
|
||||
- TypeScript types ported from qqq-frontend-core models
|
||||
- API client with typed functions for auth and metadata endpoints
|
||||
- Auth flow (OAuth2/OIDC via `getAuthenticationMetaData` → redirect → `manageSession` → session cookie)
|
||||
- Root layout with sidebar navigation generated from `appTree` metadata
|
||||
- Top navbar with breadcrumbs
|
||||
- 401 interceptor with re-auth flow
|
||||
- Branding/theming from `QBrandingMetaData` (accent colors, logo, banners)
|
||||
- Light/dark mode toggle
|
||||
- Empty placeholder pages for all dynamic routes (so navigation works end-to-end)
|
||||
|
||||
**Critical reading:** `App.tsx` (route generation logic), `QController.getAuthenticationMetaData()`, `QController.manageSession()`, `QController.loadMetaData()`, SideNav components, NavBar component, BrandedHeaderBar
|
||||
|
||||
### Package 2: Record Query Page
|
||||
**Requirements:** Sections 5.2.3 (query route), 5.3.2 (Data Grid), 5.3.5 (Query/Filter Components), 5.4.2 (responsive for query), 3.4.1 (Query), 3.4.2 (Count), 3.7 (Possible Values)
|
||||
**Delivers:**
|
||||
- Record Query page with full data grid (TanStack Table)
|
||||
- Server-side pagination, sorting, and filtering via `POST /table/{t}/query` and `POST /table/{t}/count`
|
||||
- Dynamic column generation from `QTableMetaData.fields`
|
||||
- Cell renderers per field type (string, number, date, boolean, etc.) and per adornment type (link, chip, file download, etc.)
|
||||
- Basic filter mode (quick text search) and advanced filter builder (full QQueryFilter with all operators, boolean logic, sub-filters)
|
||||
- Column configuration panel (show/hide, reorder)
|
||||
- Row selection (single and multi)
|
||||
- Export (CSV at minimum)
|
||||
- Saved views (save/load filter + column configurations)
|
||||
- Pagination controls with page size selector
|
||||
- Table variant selection
|
||||
- Row click → navigate to Record View
|
||||
- Toolbar with create button (if insertPermission), process launcher, column config, density control
|
||||
|
||||
**Critical reading:** RecordQuery.tsx (the entire file — this is the most complex page), CustomFilterPanel, FilterCriteriaRow, BasicAndAdvancedQueryControls, SavedViews, CustomColumnsPanel, ExportMenuItem
|
||||
|
||||
### Package 3: Record View and CRUD
|
||||
**Requirements:** Sections 5.2.3 (view/create/edit/copy routes), 5.3.2 (Record Detail View), 5.3.3 (Form Components), 5.4.2 (responsive for view and form), 3.4.3–3.4.6 (Get, Insert, Update, Delete), 3.7 (Possible Values)
|
||||
**Delivers:**
|
||||
- Record View page with section-organized field display
|
||||
- Field value rendering per type and adornment
|
||||
- Action menu (edit, delete, copy, launch process, share)
|
||||
- Entity Form component for create/edit/copy
|
||||
- Dynamic form generation from `QFieldMetaData` (all 12 field types)
|
||||
- Form validation (Zod schemas generated from field metadata: required, maxLength, type constraints)
|
||||
- Possible values autocomplete (`POST /possibleValues/{f}`) with debounced search
|
||||
- File upload fields
|
||||
- Boolean switch fields
|
||||
- Date/time pickers
|
||||
- Rich text fields (HTML type)
|
||||
- Section-based form layout with `gridColumns` support
|
||||
- Record delete with confirmation dialog
|
||||
- Record copy (pre-populate form without ID)
|
||||
- Associated records display
|
||||
- Help content tooltips on fields
|
||||
|
||||
**Critical reading:** RecordView.tsx, EntityForm.tsx, DynamicForm.tsx, DynamicFormField.tsx, DynamicSelect.tsx, BooleanFieldSwitch.tsx, FileInputField.tsx, all field adornment rendering logic
|
||||
|
||||
### Package 4: Process Execution
|
||||
**Requirements:** Sections 5.2.4 (process routes), 5.3.4 (Process Components), 3.5 (Process Endpoints), 3.7 (Possible Values)
|
||||
**Delivers:**
|
||||
- Process Run page with step wizard (stepper UI)
|
||||
- Dynamic step rendering based on `QFrontendStepMetaData`
|
||||
- Form steps (reuse EntityForm components from Package 3)
|
||||
- View-only steps
|
||||
- Validation review screen
|
||||
- Bulk load workflows: file upload, column-to-field mapping, value mapping, profile save/load
|
||||
- Async job handling: detect `QJobStarted`, poll `GET /status/{jobUUID}`, display `QJobRunning` progress, handle `QJobComplete` and `QJobError`
|
||||
- Process results summary (record counts, error lists, download links)
|
||||
- Process records endpoint (`GET /processes/{p}/{uuid}/records`) for paginated result viewing
|
||||
- Process cancellation
|
||||
- Support for both standalone processes and table-scoped processes (with pre-selected records via recordIds or filterJSON)
|
||||
|
||||
**Critical reading:** ProcessRun.tsx (the entire file), ProcessViewForm, ProcessSummaryResults, ValidationReview, all BulkLoad* components, QJobStarted/Running/Complete/Error types
|
||||
|
||||
### Package 5: Dashboard and Widgets
|
||||
**Requirements:** Sections 5.2.2 (App Home), 5.3.6 (Widget Components), 3.6 (Widget Endpoint)
|
||||
**Delivers:**
|
||||
- App Home page with dashboard layout
|
||||
- Widget container component (label, reload, export, dropdown menus, help content)
|
||||
- Widget grid layout with `gridColumns` support
|
||||
- All chart widgets (line, bar, horizontal bar, stacked bar, pie) — use Recharts or Chart.js
|
||||
- Statistics cards (MiniStatisticsCard, MultiStatisticsCard)
|
||||
- Record Grid Widget (embedded data grid with add/edit/delete)
|
||||
- Block system (TextBlock, BigNumberBlock, UpOrDownNumberBlock, ProgressBarBlock, ButtonBlock, IconBlock, ImageBlock, AudioBlock, DividerBlock, InputFieldBlock)
|
||||
- Composite and parent widget containers
|
||||
- Widget dropdown selections with possible-value sources
|
||||
- Data fetching with abort/cancel-and-replace for widget requests
|
||||
- Custom component widget mount point (for app-specific React components)
|
||||
- Specialized widgets: pivot table setup, filter/columns setup, CRON builder, US map (if feasible), script viewer
|
||||
|
||||
**Critical reading:** DashboardWidgets, Widget.tsx, all widget block components, RecordGridWidget, ChartSubheaderWithData, all chart components, CompositeWidget, ParentWidget
|
||||
|
||||
### Package 6: Polish, Parity, and Production Readiness
|
||||
**Requirements:** Sections 5.4 (all responsive), 5.6 (Performance), 5.7 (Developer Experience)
|
||||
**Delivers:**
|
||||
- Responsive design audit and fixes across all breakpoints for all pages
|
||||
- Mobile card view for Record Query
|
||||
- Command palette (Cmd+K) for quick navigation
|
||||
- Keyboard shortcuts
|
||||
- Audit trail display on Record View
|
||||
- Developer tools pages (script editor, logs, test execution)
|
||||
- Report execution pages
|
||||
- Storybook setup with stories for all shared components
|
||||
- Playwright E2E test suite for critical flows (login, query, CRUD, process execution)
|
||||
- Performance optimization (bundle analysis, lazy loading, caching tuning)
|
||||
- Accessibility audit (axe-core) and remediation
|
||||
- Theme token system (CSS custom properties) for all 60+ tokens from current qfmd
|
||||
- `data-qqq-id` attributes on all interactive elements
|
||||
- `customCss` injection support
|
||||
|
||||
**Critical reading:** All responsive-related code in current qfmd, CommandMenu, AuditBody, RecordDeveloperView, TableDeveloperView, ScriptViewer, ReportRun
|
||||
|
||||
---
|
||||
|
||||
## Quality Standards for Your Plans
|
||||
|
||||
Your plans will be judged on:
|
||||
|
||||
1. **Precision:** An implementation agent should not need to make architectural decisions. Every file path, component name, prop interface, and API integration point should be specified.
|
||||
|
||||
2. **Completeness:** Every feature in the requirements must appear in exactly one work package. Nothing can fall through the cracks. If a feature spans packages, specify which package owns it and what interface the other package consumes.
|
||||
|
||||
3. **Traceability:** Every implementation step must trace back to a specific requirement (section number). Every requirement must trace forward to a specific implementation step.
|
||||
|
||||
4. **Realism:** Steps should be ordered so that each step can be tested after completion. Don't put all the types first, then all the components, then all the wiring — interleave so that after each step, something new works.
|
||||
|
||||
5. **Edge case awareness:** Read the existing implementation. Note error handling, loading states, empty states, permission checks, and null cases. Your plans must address these. The current qfmd has years of edge case handling baked in — don't lose it.
|
||||
|
||||
6. **Integration specificity:** When Package N depends on Package N-1, specify the exact imports, types, and components it depends on. Don't say "use the API client from Package 1" — say "import `queryRecords` from `@/lib/api/tables` which returns `Promise<QRecord[]>`".
|
||||
|
||||
---
|
||||
|
||||
## Execution Instructions
|
||||
|
||||
1. Read the full requirements document first.
|
||||
2. Read the current codebase files listed above (use parallel reads where possible).
|
||||
3. Produce `00-project-overview.md` first — this establishes all shared decisions.
|
||||
4. Produce the three `shared-context/` files.
|
||||
5. Produce work packages 01 through 06 in order.
|
||||
6. Produce review checklists for each package.
|
||||
7. Do a final cross-check: scan every subsection of Section 5 in the requirements and verify it appears in at least one work package's scope.
|
||||
|
||||
Begin now.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,78 @@
|
||||
import nextConfig from 'eslint-config-next'
|
||||
import prettierConfig from 'eslint-config-prettier'
|
||||
import jsdoc from 'eslint-plugin-jsdoc'
|
||||
|
||||
const eslintConfig = [
|
||||
...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
|
||||
@@ -0,0 +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',
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "0.2.1",
|
||||
"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": "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",
|
||||
"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.15.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@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.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.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.4.16",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.485.0",
|
||||
"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.5.0",
|
||||
"ts-md5": "^1.3.1",
|
||||
"universal-cookie": "^7.2.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.1",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@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/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.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.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
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
@@ -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.1</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,8 @@
|
||||
/** @type {import('postcss').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.12.10'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /api/
|
||||
Disallow: /login
|
||||
Disallow: /callback
|
||||
Allow: /
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +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 OAuth2 / Auth0 callback page — handles the PKCE redirect from the authorization server.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
// 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 } from 'react'
|
||||
|
||||
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 (
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +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'
|
||||
|
||||
// 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, 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'
|
||||
|
||||
/** 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.',
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" 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 (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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'
|
||||
|
||||
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, loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
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 = useRouteParams<{ slug: string; recordId: string }>()
|
||||
return <CopyPageContent key={JSON.stringify(params)} slug={params.slug} recordId={params.recordId} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
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) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [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">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{!hasCapability(tableMetaData, 'TABLE_INSERT')
|
||||
? `${tableMetaData.label} records cannot be created.`
|
||||
: `You do not have permission to create ${tableMetaData.label} records.`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !record) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-8 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
{recordLoadFailure(tableMetaData.label, recordId, error)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
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 = useRouteParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Dev: ${slug} #${recordId}`)
|
||||
}, [slug, recordId, setPageHeader])
|
||||
|
||||
const { data: record, isLoading: recordLoading, error: recordError } = useQuery({
|
||||
queryKey: queryKeys.tableRecord(slug, recordId),
|
||||
queryFn: () => getRecord(slug, recordId),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
|
||||
const { data: metaData, isLoading: metaLoading } = useQuery({
|
||||
queryKey: queryKeys.tableMetadata(slug),
|
||||
queryFn: () => loadTableMetaData(slug),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const isLoading = recordLoading || metaLoading
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`record-dev-${slug}-${recordId}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Record Developer View:{' '}
|
||||
<span className="font-mono text-primary">{slug}</span>
|
||||
{' '}
|
||||
<span className="font-mono text-muted-foreground">#{recordId}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordError && (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Failed to load record: {recordError instanceof Error ? recordError.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && record && (
|
||||
<div className="space-y-4">
|
||||
{/* Field values summary */}
|
||||
{metaData && (
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="min-w-full text-sm" data-qqq-id="record-dev-field-table">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Field
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(record.values).map(([fieldName, value]) => {
|
||||
const field = metaData.fields[fieldName]
|
||||
return (
|
||||
<tr
|
||||
key={fieldName}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs text-primary">
|
||||
{fieldName}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">
|
||||
{field?.type ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-foreground">
|
||||
{value === null || value === undefined
|
||||
? <span className="text-muted-foreground italic">null</span>
|
||||
: String(value)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JsonBlock label="Raw Record" value={record} />
|
||||
{metaData && <JsonBlock label="Table Metadata" value={metaData} defaultOpen={false} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
label: string
|
||||
value: unknown
|
||||
defaultOpen?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring"
|
||||
aria-expanded={open}
|
||||
data-qqq-id="button-json-block-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<pre
|
||||
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
|
||||
data-qqq-id="json-output"
|
||||
>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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 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 { 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 = useRouteParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
|
||||
const { data: metaData, isError: metadataError } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: canEditRecords(tableMetaData),
|
||||
includeAssociations: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Edit ${tableMetaData?.label ?? slug} #${recordId}`)
|
||||
if (tableMetaData) {
|
||||
setTableMetaData(tableMetaData)
|
||||
}
|
||||
}, [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">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{!hasCapability(tableMetaData, 'TABLE_UPDATE')
|
||||
? `${tableMetaData.label} records cannot be edited.`
|
||||
: `You do not have permission to edit ${tableMetaData.label} records.`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !record) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-8 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-sm text-destructive">
|
||||
{recordLoadFailure(tableMetaData.label, recordId, error)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
|
||||
<EntityForm tableMetaData={tableMetaData} record={record} widgets={metaData?.widgets} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||