Compare commits
377
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c2567f02
|
||
|
|
26ab79f752
|
||
|
|
18d1fd6e37 | ||
|
|
eee644a280 | ||
|
|
7682aa1ff0 | ||
|
|
8e334cd944 | ||
|
|
255554a184 | ||
|
|
e1d60b9634 | ||
|
|
d81b7ed5ae | ||
|
|
b8313b6819 | ||
|
|
22c9e02f20 | ||
|
|
138c3c6db5 | ||
|
|
70f26e9c9a | ||
|
|
2bc75d779e | ||
|
|
17e22edaee | ||
|
|
66d02c7b6a | ||
|
|
20fe7b7b9b | ||
|
|
4190ee01ba | ||
|
|
62d1e77847 | ||
|
|
a23fd997db | ||
|
|
f367756b8c | ||
|
|
4a055463bd | ||
|
|
c1a4efd7d7 | ||
|
|
0222f0dcad | ||
|
|
4fea636f24 | ||
|
|
5ca3e0b17a | ||
|
|
e0fb2598b0 | ||
|
|
61286717e5 | ||
|
|
a32ef74a9d | ||
|
|
d292c76530 | ||
|
|
e3fc4b2f52 | ||
|
|
b5f8be7645 | ||
|
|
8f552e2e29 | ||
|
|
1f18575abe | ||
|
|
6a5c1bb237 | ||
|
|
58a896345f | ||
|
|
bd904fe4ac | ||
|
|
09c271384c | ||
|
|
05763450ff | ||
|
|
aa0129f024 | ||
|
|
2bdc048440 | ||
|
|
7bd157dd81 | ||
|
|
2535e98c7e | ||
|
|
2853b9b148 | ||
|
|
29cb5584fc | ||
|
|
f36d8709c9 | ||
|
|
f7b2aa52a8 | ||
|
|
a1d3e48419 | ||
|
|
ea73118fca | ||
|
|
c629eb0b6c | ||
|
|
571ee7bc88 | ||
|
|
12cf7745f6 | ||
|
|
0f6581b6d7 | ||
|
|
46a1d5d7ec | ||
|
|
226efa841f | ||
|
|
dd130238be | ||
|
|
d8c803ef5f | ||
|
|
5e3738b73f | ||
|
|
289eca19d4 | ||
|
|
7ff90f1a63 | ||
|
|
50a935a4a5 | ||
|
|
40e987d4d0 | ||
|
|
71f5a39b09 | ||
|
|
45f99f27ce | ||
|
|
d17e880e79 | ||
|
|
f8fdb8e997 | ||
|
|
ad154b5b56 | ||
|
|
c6a58933a6 | ||
|
|
26e3a0d004 | ||
|
|
c9d7fcd393 | ||
|
|
b3e1ddda8b | ||
|
|
8d4668cdab | ||
|
|
254a0a2050 | ||
|
|
52cc038fa6 | ||
|
|
6090f2f9b4 | ||
|
|
32a9560970 | ||
|
|
96085c72e2 | ||
|
|
c029ad2a70 | ||
|
|
32a3b281ff | ||
|
|
a0a18cfa76 | ||
|
|
354ee2709d | ||
|
|
3c7c0e88b7 | ||
|
|
0679672aec | ||
|
|
6d0862284a | ||
|
|
cd3d4731d1 | ||
|
|
2eb075610e | ||
|
|
a317f0292d | ||
|
|
14d69667dc | ||
|
|
f0f0eae98e | ||
|
|
66ccd34642 | ||
|
|
c954005332 | ||
|
|
33cfb0b322 | ||
|
|
b9b1f3da84 | ||
|
|
f65cf6bfbd | ||
|
|
d5f275f85e | ||
|
|
d4795c80e6 | ||
|
|
b8b361d8da | ||
|
|
12c21a7560 | ||
|
|
d4e3289c61 | ||
|
|
7ddf45fa4d | ||
|
|
df33189ab4 | ||
|
|
bad28d9e21 | ||
|
|
8900d47e28 | ||
|
|
fcf08769d6 | ||
|
|
ac22946975 | ||
|
|
fbe9d148ec | ||
|
|
90a456ddcf | ||
|
|
263f92f7bd | ||
|
|
977c1dfda3 | ||
|
|
a8e4d3b210 | ||
|
|
9fb613d04c | ||
|
|
b1c0cedbe0 | ||
|
|
0316c7a980 | ||
|
|
3c409f9186 | ||
|
|
f2cdf03bf0 | ||
|
|
3676fe9980 | ||
|
|
1dbe1d91ef | ||
|
|
5be45cd0e1 | ||
|
|
b1495c4a44 | ||
|
|
1283bac07e | ||
|
|
b971340c39 | ||
|
|
5ae0c8c339 | ||
|
|
10094ee885 | ||
|
|
f7ae67f13d | ||
|
|
cb51aa614e | ||
|
|
f1a6a3a168 | ||
|
|
ca9b5374f4 | ||
|
|
1bd7bdcb94 | ||
|
|
dc640530f9 | ||
|
|
ef1f407993 | ||
|
|
e71ed784bd | ||
|
|
73adda133c | ||
|
|
9e98626d9d | ||
|
|
0905cfb0cd | ||
|
|
542ac47593 | ||
|
|
222199916e | ||
|
|
defb3abc82 | ||
|
|
e87fe90d91 | ||
|
|
d346d9bffd | ||
|
|
201a73901e | ||
|
|
d937507a2e | ||
|
|
bf8fa4e1c2 | ||
|
|
95847c25ab | ||
|
|
303ba61fe1 | ||
|
|
44c36fc87d | ||
|
|
eeab664e34 | ||
|
|
0fcd28344d | ||
|
|
b9a49ad29d | ||
|
|
69766f1902 | ||
|
|
e901df9101 | ||
|
|
49e51158aa | ||
|
|
381cce27e6 | ||
|
|
1395a8b7d7 | ||
|
|
093b8d758c | ||
|
|
654aa28a9f | ||
|
|
9732c56c99 | ||
|
|
113785600d | ||
|
|
a0d63ae981 | ||
|
|
a03a0ab7da | ||
|
|
30db3098ad | ||
|
|
e56ef0d7c4 | ||
|
|
5f47ffc630 | ||
|
|
8484779d51 | ||
|
|
121468b049 | ||
|
|
16dd6a7ba9 | ||
|
|
d969630e50 | ||
|
|
06cf1b0c91 | ||
|
|
d1db0cd002 | ||
|
|
096c55adbe | ||
|
|
4748b6c594 | ||
|
|
5077c78afd | ||
|
|
c44261376a | ||
|
|
6439944150 | ||
|
|
6027e65065 | ||
|
|
93b7b5a433 | ||
|
|
0f96c81dc8 | ||
|
|
9d29663fda | ||
|
|
0e71bb03c6 | ||
|
|
083db18e40 | ||
|
|
384c360c03 | ||
|
|
5cb2f5a922 | ||
|
|
75044c1781 | ||
|
|
1af7254bc4 | ||
|
|
214cb9c88e | ||
|
|
7524057454 | ||
|
|
bf97d71e22 | ||
|
|
97dd622fe1 | ||
|
|
c89da89ce5 | ||
|
|
38c75a49eb | ||
|
|
609d016c50 | ||
|
|
1694580521 | ||
|
|
658116dd83 | ||
|
|
c14e5aedb5 | ||
|
|
0aa65e2a26 | ||
|
|
f4faeeab56 | ||
|
|
5d7e76e6d2 | ||
|
|
ca400d5cee | ||
|
|
c7835b8968 | ||
|
|
7b2386dc75 | ||
|
|
6407176134 | ||
|
|
37bb68e659 | ||
|
|
ddda45d7de | ||
|
|
04df7cc7a4 | ||
|
|
3358f53db8 | ||
|
|
7736a6e815 | ||
|
|
642a350e2a | ||
|
|
6ff2272fa1 | ||
|
|
bac4c4e287 | ||
|
|
2b123fd1b3 | ||
|
|
ed95420e66 | ||
|
|
15a9918f8f | ||
|
|
84c4846a16 | ||
|
|
3e4d7c9520 | ||
|
|
3b20c412c5 | ||
|
|
3e5bed9c9c | ||
|
|
f1e92f3be8 | ||
|
|
b55bc57f6a | ||
|
|
560379501c | ||
|
|
d11741f2e0 | ||
|
|
c46dfc02dc | ||
|
|
7f299e2d03 | ||
|
|
a5322e553a | ||
|
|
1a31c75808 | ||
|
|
803a7ecd77 | ||
|
|
368d702581 | ||
|
|
e09ba6f79e | ||
|
|
3437e6d8b2 | ||
|
|
d61f12f28f | ||
|
|
10f0744b86 | ||
|
|
0dfc4d9ed2 | ||
|
|
dbf600e894 | ||
|
|
72f5a07ea6 | ||
|
|
9b4142c1b0 | ||
|
|
49b3e1720b | ||
|
|
4e51b5ddd8 | ||
|
|
2336e39573 | ||
|
|
962d48869c | ||
|
|
de94de58e4 | ||
|
|
f4b15f4616 | ||
|
|
20a5befbdb | ||
|
|
e9676e97af | ||
|
|
4c7918f785 | ||
|
|
19353e6562 | ||
|
|
510fc6e6dc | ||
|
|
74042c056e | ||
|
|
ec04caf2f7 | ||
|
|
3c4b24758c | ||
|
|
39bc6ae4f0 | ||
|
|
89b9708bb2 | ||
|
|
6d53c14bff | ||
|
|
9b19cd184a | ||
|
|
f543bfe1f0 | ||
|
|
037d0a86f8 | ||
|
|
cf3c3ad6e9 | ||
|
|
e3bea6e6a7 | ||
|
|
e42ad2b2bc | ||
|
|
622e273dc1 | ||
|
|
3e9960d3a4 | ||
|
|
c421e37e18 | ||
|
|
a7601087c7 | ||
|
|
fe4297eeff | ||
|
|
a16f4cd519 | ||
|
|
2774be8c26 | ||
|
|
cbfa7a9299 | ||
|
|
dbff3b49f7 | ||
|
|
c7ab4c6699 | ||
|
|
b79446c65f | ||
|
|
f1b58f50e5 | ||
|
|
144a340204 | ||
|
|
1276df8a4b | ||
|
|
710ec015c5 | ||
|
|
e54650291e | ||
|
|
6bb90cf76b | ||
|
|
0f4ae0a48d | ||
|
|
ce670a1499 | ||
|
|
4b202c4d8f | ||
|
|
336ec87be0 | ||
|
|
8450428445 | ||
|
|
301c5f6a51 | ||
|
|
35eaaa59e8 | ||
|
|
a186853397 | ||
|
|
6087831f26 | ||
|
|
4ba3254468 | ||
|
|
05a1076979 | ||
|
|
e5dba6c896 | ||
|
|
e05f3ece06 | ||
|
|
9593bbf2d8 | ||
|
|
e9fc79b5cb | ||
|
|
f46ae2aa09 | ||
|
|
3b84e3782d | ||
|
|
33227986ea | ||
|
|
7d34d0b6c9 | ||
|
|
df02feff26 | ||
|
|
df0712ee05 | ||
|
|
7af15dea7f | ||
|
|
289bc4126a | ||
|
|
a4e51003b7 | ||
|
|
555a90444f | ||
|
|
3d3bb6acf5 | ||
|
|
01e35477ce | ||
|
|
979bb06049 | ||
|
|
3498f7046f | ||
|
|
1127e2f659 | ||
|
|
b7f90cb27a | ||
|
|
2a29872394 | ||
|
|
33f7884035 | ||
|
|
4f833aac39 | ||
|
|
a2b3d93856 | ||
|
|
0caa294929 | ||
|
|
993d79d88c | ||
|
|
9691312b13 | ||
|
|
167e2a3a35 | ||
|
|
8fa7d6a046 | ||
|
|
fcf240d800 | ||
|
|
f108367db6 | ||
|
|
455c937845 | ||
|
|
b5aac3c9ff | ||
|
|
792f635941 | ||
|
|
f1bcddb8f2 | ||
|
|
3cafb4d09a | ||
|
|
2d051dc2ea | ||
|
|
f1589c3421 | ||
|
|
375901fc34 | ||
|
|
e8675bd7e3 | ||
|
|
9fedda9ac8 | ||
|
|
9ca9aa8e3b | ||
|
|
abe9bd7664 | ||
|
|
262f5a5bc8 | ||
|
|
010bb4e020 | ||
|
|
423cb3e48e | ||
|
|
465fa77855 | ||
|
|
4b54e026ac | ||
|
|
ddec18d8ad | ||
|
|
4e100a34f3 | ||
|
|
16fa3cf0c6 | ||
|
|
58a4b8cbe3 | ||
|
|
50c6c97565 | ||
|
|
09b2ebad71 | ||
|
|
5b0480dac2 | ||
|
|
90615a34f9 | ||
|
|
4627ca7198 | ||
|
|
ca0e2cb7e7 | ||
|
|
aeba4f7060 | ||
|
|
23c3073831 | ||
|
|
a178f66272 | ||
|
|
d19d522b6d | ||
|
|
0d272edd4f | ||
|
|
16fe7ed518 | ||
|
|
835acb5c79 | ||
|
|
3b2536b91b | ||
|
|
be49ac3299 | ||
|
|
e5e5b8ea72 | ||
|
|
ed98aec576 | ||
|
|
b2c7e71712 | ||
|
|
a1c68fdbc7 | ||
|
|
e48ad3173d | ||
|
|
f3b036b375 | ||
|
|
92851bf8b0 | ||
|
|
db0c6d86aa | ||
|
|
e57201ef55 | ||
|
|
0ee169b798 | ||
|
|
5e9f013376 | ||
|
|
40efee5f8b | ||
|
|
d3fad8cbda | ||
|
|
709edfbf15 | ||
|
|
d7b2ee8cd7 | ||
|
|
58b952445a | ||
|
|
1a71b4988d | ||
|
|
a8860f6d0d | ||
|
|
4bb147ea4e | ||
|
|
8147a29e3b | ||
|
|
d0818d1a61 | ||
|
|
53bc5d377c | ||
|
|
1f57dbf657 | ||
|
|
06c47ee3be | ||
|
|
604c44c7b9 | ||
|
|
caeadfb078 |
No files matched your search
+20
-1
@@ -8,14 +8,34 @@ orbs:
|
||||
# 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: Verify the immutable release version
|
||||
command: |
|
||||
release_version="${CIRCLE_TAG#v}"
|
||||
frontend_version=$(node -p "require('./package.json').version")
|
||||
maven_version=$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)
|
||||
test "$frontend_version" = "$release_version"
|
||||
test "$maven_version" = "$release_version"
|
||||
- run:
|
||||
name: Build the static export
|
||||
command: |
|
||||
@@ -25,7 +45,6 @@ jobs:
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build:export
|
||||
- qqq-orb/mvn_manage_version
|
||||
- qqq-orb/mvn_jar_deploy
|
||||
|
||||
# ─── Workflows ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,8 +32,8 @@ jobs:
|
||||
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]
|
||||
# Chromium provides the primary workflow gate independently of touch coverage.
|
||||
browser: [chromium, 'mobile,tablet', firefox, webkit]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
@@ -72,10 +72,15 @@ jobs:
|
||||
- name: Acceptance (${{ matrix.browser }})
|
||||
env:
|
||||
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
|
||||
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
|
||||
# Fixed NAV023/NAV056 ownership capture only; original assertions and diagnostics remain strict.
|
||||
QQQ_ACCEPTANCE_BROWSER_TRACE: ${{ (matrix.browser == 'webkit' || matrix.browser == 'mobile,tablet') && 'navigation' || '0' }}
|
||||
# Retain native browser crashes alongside traces (QQQ#904, QQQ#960).
|
||||
DEBUG: pw: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 }}
|
||||
name: acceptance-${{ matrix.browser == 'mobile,tablet' && 'touch' || matrix.browser }}
|
||||
path: test-results/acceptance/
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,108 @@
|
||||
name: CI
|
||||
|
||||
# Fast PR checks (QRun-IO/qqq#709, bundle budget #710). The real-backend gate, including the
|
||||
# runtime performance budgets (specs/performance), runs in acceptance.yml.
|
||||
# Each job installs on its own so they run in parallel; none needs a backend.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NEXT_TELEMETRY_DISABLED: '1'
|
||||
STORYBOOK_DISABLE_TELEMETRY: '1'
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Typecheck, lint, unit tests and coverage
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- name: Unit tests with src/lib coverage thresholds (vitest.config.ts)
|
||||
run: pnpm test:coverage
|
||||
|
||||
e2e:
|
||||
name: Mocked e2e (Playwright, chromium)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- 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
|
||||
- name: Production build
|
||||
run: pnpm build
|
||||
- name: Mocked e2e suite
|
||||
env:
|
||||
QQQ_E2E_SERVER: production
|
||||
run: pnpm test:e2e
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure()
|
||||
with:
|
||||
name: e2e-report
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 14
|
||||
|
||||
storybook:
|
||||
name: Storybook build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- run: pnpm build-storybook --quiet
|
||||
|
||||
performance:
|
||||
name: Bundle budget (static export)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Static export
|
||||
run: pnpm build:export
|
||||
- name: First-load JS per route, total JS and largest chunk against perf-budget.json
|
||||
run: pnpm perf:budget
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: bundle-budget
|
||||
path: test-results/performance/bundle.json
|
||||
retention-days: 30
|
||||
@@ -40,3 +40,4 @@ target/
|
||||
/playwright-report/
|
||||
/log/
|
||||
/.playwright-mcp/
|
||||
/storybook-static/
|
||||
+26
-8
@@ -1,21 +1,39 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs'
|
||||
import type { StorybookConfig } from '@storybook/nextjs-vite'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// The Vite builder: the webpack-based @storybook/nextjs framework bundled Node core
|
||||
// polyfills (crypto-browserify -> elliptic, GHSA-848j-6mx2-7j84) that no story needs.
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-docs',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/nextjs',
|
||||
name: '@storybook/nextjs-vite',
|
||||
options: {},
|
||||
},
|
||||
webpackFinal: async (config) => {
|
||||
if (config.resolve) {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@': fileURLToPath(new URL('../src', import.meta.url)),
|
||||
}
|
||||
viteFinal: async (config) => {
|
||||
const src = fileURLToPath(new URL('../src', import.meta.url))
|
||||
const alias = config.resolve?.alias
|
||||
config.resolve = {
|
||||
...config.resolve,
|
||||
alias: Array.isArray(alias)
|
||||
? [...alias, { find: '@', replacement: src }]
|
||||
: { ...alias, '@': src },
|
||||
}
|
||||
// Rollup drops the Next.js "use client" directives (meaningless in Storybook) and warns
|
||||
// once per client module; keep the build log to the warnings that matter.
|
||||
const onwarn = config.build?.rollupOptions?.onwarn
|
||||
config.build = {
|
||||
...config.build,
|
||||
rollupOptions: {
|
||||
...config.build?.rollupOptions,
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE' || warning.code === 'SOURCEMAP_ERROR') return
|
||||
if (onwarn) onwarn(warning, warn)
|
||||
else warn(warning)
|
||||
},
|
||||
},
|
||||
}
|
||||
return config
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Preview } from '@storybook/react'
|
||||
import type { Preview } from '@storybook/nextjs-vite'
|
||||
import '../src/styles/globals.css'
|
||||
|
||||
const preview: Preview = {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
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.
|
||||
|
||||
**This checkout targets Next UI 1.0.0-RC.11**, with searchable-selector and script-completion input-race corrections. Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing; see the [RC11 notes](docs/releases/1.0.0-RC.11.md) for the preparation checkpoint and verification scope. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
**Current roadmap and known issues:** [Next UI 1.0 tracker](https://github.com/QRun-IO/qqq/issues/713). GitHub issues are the source of truth for status and remaining work; see [issue tracking](docs/ISSUE-TRACKING.md).
|
||||
|
||||
## 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:
|
||||
@@ -10,12 +14,15 @@ The dashboard is the default admin UI for QQQ 4.1+. Add the jar (managed by `qqq
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.11</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
The explicit version overrides an older BOM-managed frontend. RC11 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
|
||||
|
||||
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)).
|
||||
Feature coverage is recorded in 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
|
||||
|
||||
@@ -62,12 +69,14 @@ Copy `.env.example` to `.env.local` and adjust as needed.
|
||||
| `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 test:coverage` | Run Vitest with V8 coverage; fails below the `src/lib` thresholds in `vitest.config.ts` |
|
||||
| `pnpm test:e2e` | Run the mocked Playwright e2e suite (`tests/e2e`, no backend); `QQQ_E2E_SERVER=production` serves the `pnpm build` output, `QQQ_E2E_PORT` picks the port |
|
||||
| `pnpm perf:budget` | Check the static export (`out/`) against the bundle budget in `perf-budget.json` ([`docs/acceptance/performance.md`](docs/acceptance/performance.md)) |
|
||||
| `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 |
|
||||
| `pnpm build-storybook` | Build the static Storybook (`storybook-static/`) |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ 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
|
||||
# server.js wrapped with the dashboard security headers (QRun-IO/qqq#734); add origins the
|
||||
# dashboard must reach (QuickSight, custom component bundles) with QQQ_DASHBOARD_CSP_SOURCES
|
||||
COPY --from=frontend --chown=node:node /app/standalone/qqq-server.mjs /app/standalone/security-headers.mjs /app/standalone/branding-assets.mjs /app/standalone/backend-prefixes.json ./
|
||||
COPY LICENSE ./LICENSE
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "qqq-server.mjs"]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Material CSS hooks in Next UI
|
||||
|
||||
QQQ application themes can provide `supplementalInstanceMetaData.materialDashboardTheme.customCss`. Next UI keeps the main Material Dashboard `data-qqq-id` hooks so application CSS can target metadata-driven controls. The source of truth for ID generation is [`src/lib/utils/qqq-id.ts`](../src/lib/utils/qqq-id.ts); [NAV-055](acceptance/feature-matrix.md) exercises representative selectors against the real sample server.
|
||||
|
||||
## ID rules
|
||||
|
||||
The ID suffix is lowercased, each run of characters outside `a-z` and `0-9` becomes one dash, leading and trailing dashes are removed, and the result is cut at 50 characters. Camel case is not split: `firstName` becomes `firstname`. Use the metadata label for navigation entries and the metadata name for fields, tables and sections.
|
||||
|
||||
| Control | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Button | `button-{id or text}` | `button-save`, `button-query-mode-advanced` |
|
||||
| Editable text field | `input-{field}` | `input-firstname` |
|
||||
| Possible-value field | `select-{field}` | `select-speciesid` |
|
||||
| Boolean field | `switch-{field}` | `switch-isemployed` |
|
||||
| Navigation item | `sidenav-{label}` | `sidenav-people-app` |
|
||||
| Menu item | `menu-item-{text}` | `menu-item-bulk-edit` |
|
||||
| Tab | `tab-{label}` | `tab-related` |
|
||||
| Table header | `table-header-{field}` | `table-header-firstname` |
|
||||
| Link | `link-{text}` | `link-back-to-person` |
|
||||
|
||||
The ID may be on a layout-neutral wrapper around a native button or menu item. Select the wrapper when applying layout or visibility rules, and select its button descendant for button-specific styles. Inspect the rendered element before changing an application's CSS.
|
||||
|
||||
## Stable named hooks
|
||||
|
||||
- Navigation: `sidenav-root`, `sidenav-logo-area`, `sidenav-menu-list`, `sidenav-logout-button`; `.qqq-sidebar-active` marks the active item. `data-qqq-sidenav-item-type` identifies `user-profile` and `top-level-parent-app` entries.
|
||||
- App home and chrome: `app-card-{appName}-icon`, `.banner.info`/`.banner.warning`/`.banner.error`, and `.qqq-branded-header-bar`.
|
||||
- Query: `button-filter-builder`, `button-views`, `quick-views-container`, `button-query-mode-basic`, `button-query-mode-advanced`, `table-header-{field}`, and `menu-item-{action}`.
|
||||
- Record view: `.recordView`, `record-sidebar`, `record-view-header-{table}`, `record-view-title-{table}`, `record-view-button-bar-{table}`, `record-view-actions-menu`, and `delete-confirmation-*` with `button-delete-yes`/`button-delete-no`.
|
||||
- Forms: `.entityForm`, `record-create-{table}`/`record-edit-{table}`, `form-section-{section}`, `.field-wrapper.is-visible`, and `.stickyBottomButtonBar`. Default action buttons expose `data-button-variant` values such as `gradient`, `contained`, and `outlined`.
|
||||
|
||||
The record view has a phone accordion and action sheet, a tablet tab list without the large-screen sidebar, and a large-screen tab list with the sidebar. A selector can be present in the DOM while its control is hidden at a narrower width. Test custom CSS at all three widths, including focus and contrast, before shipping it.
|
||||
|
||||
## Display mode and primary text
|
||||
|
||||
Next's persisted light/dark choice controls both theme tokens and Tailwind `dark:` variants. OS color preference does not override it; a configured Material application theme keeps light mode active. `--color-primary` controls branded fills, while `--text-color-primary` controls the existing `text-primary` classes and opacity variants. In light mode the text token follows the primary color; in dark mode it mixes the brand hue with white for readable text on dark surfaces. Explicit application theme and custom CSS rules still take precedence. Verify text and filled-button contrast separately when overriding these tokens.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Next UI issue tracking
|
||||
|
||||
GitHub issues are the source of truth for scope, current status, remaining work and release decisions. Start at [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713), which links the release baseline, known failures, visual review, external-provider checks and existing feature tickets.
|
||||
|
||||
## Where work lives
|
||||
|
||||
| Area | Authoritative issue |
|
||||
|---|---|
|
||||
| Roadmap and complete issue index | [qqq#713](https://github.com/QRun-IO/qqq/issues/713) |
|
||||
| Feature acceptance and default adoption | [qqq#649](https://github.com/QRun-IO/qqq/issues/649) |
|
||||
| Behavior and metadata compatibility | [qqq#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| Original Next visual review and owner approval | [qqq#711](https://github.com/QRun-IO/qqq/issues/711) |
|
||||
| Browser failures and incomplete run evidence | [qqq#985](https://github.com/QRun-IO/qqq/issues/985) |
|
||||
| Real application soak and final release hold | [qqq#712](https://github.com/QRun-IO/qqq/issues/712) |
|
||||
| Cross-application launch/restart acceptance | [qqq#650](https://github.com/QRun-IO/qqq/issues/650) |
|
||||
| ESB integration and acceptance | [qqq#986](https://github.com/QRun-IO/qqq/issues/986) |
|
||||
| Paired runtime provenance and CI handoff | [qqq#905](https://github.com/QRun-IO/qqq/issues/905) |
|
||||
|
||||
Create **all Next UI tickets in [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues)** and add them to the [QQQ Roadmap, Project 12](https://github.com/orgs/QRun-IO/projects/12). Use existing issues for the same requirement or failure, and link each focused ticket to #713 and its feature or investigation parent. Frontend issues #12 and #10 were transferred to central issues #985 and #986; use the central identifiers and preserve their report history.
|
||||
|
||||
## Required ticket fields
|
||||
|
||||
Every new ticket needs these fields on the issue and its Project 12 item; complete them before work advances to **Ready**:
|
||||
|
||||
| Field | Required value |
|
||||
|---|---|
|
||||
| Issue Type | Native issue: Bug, Feature, Task or Epic. Mirror it in the Project field, using Planning Epic for Epic; Improvement is a Project-only category. |
|
||||
| Assignees | The person accountable for completing or coordinating the ticket |
|
||||
| Milestone | The agreed delivery milestone; keep it consistent with the target version |
|
||||
| Priority | Critical, High, Medium or Low, with an impact-based rationale in the issue |
|
||||
| Component | The owning implementation component: `qqq-frontend-next` for Next source, or the actual backend/sample component for a dependency. |
|
||||
| Target Version | The agreed version of the owning component or an explicit pending release decision. A backend dependency in the Next UI 1.0 milestone does not imply a backend 1.0 release. Never imply delivery in an already published RC. |
|
||||
| Status | The current workflow state below |
|
||||
|
||||
Record unresolved ownership or release decisions explicitly while a ticket remains in **Backlog**. Do not invent dates, estimates or a new release version to fill a field. A browser assertion failure alone does not establish high product severity.
|
||||
|
||||
## Workflow and evidence
|
||||
|
||||
- **Backlog / Ready:** capture the expected behavior, scope, acceptance criteria, required fields and dependencies; Ready means actionable work with an owner.
|
||||
- **In progress:** link the active branch or work and record reproduction, confirmed cause, implementation progress and remaining blockers.
|
||||
- **In review:** link the reviewable PR, source commit and verification evidence; keep unmet acceptance criteria visible. A local fix or passing subset does not imply merge or release.
|
||||
- **Done:** close only after the accepted fix is integrated and every acceptance criterion is verified, or an explicit approved disposition is recorded. Link the merged PR/commit and evidence. Identify whether the change is unreleased or delivered, with the version and release link when published.
|
||||
|
||||
## Updating an issue
|
||||
|
||||
- Put current status and remaining acceptance criteria at the top. Preserve the original report as history.
|
||||
- Record expected/actual behavior, reproduction, environment, source commit, run and artifact evidence. State whether the cause is established.
|
||||
- Separate implementation evidence, passing scoped tests, owner visual approval and final release approval. They are distinct requirements.
|
||||
- Resolve all original acceptance criteria before closing, or document an explicit approved disposition. A later passing run alone does not explain an intermittent failure.
|
||||
- Keep external-provider exclusions visible. Controlled provider fixtures do not prove a real Auth0 tenant, Google account or QuickSight dashboard.
|
||||
- Update issue links after a fix or new finding. Repository plans, session notes, release notes and test/visual ledgers support the issues; they are not competing live backlogs.
|
||||
|
||||
## Release and design constraints
|
||||
|
||||
Preserve the original Next design at `e42ad2b2bcdc76311e13002a22dd70e3ec437192`. Material Dashboard is the behavior and metadata compatibility reference. Prioritize primary-browser functional depth and disclose secondary-browser failures.
|
||||
|
||||
[RC9](releases/1.0.0-RC.9.md) is the published prerelease for application testing. Its immutable-tag acceptance run has known Firefox/tablet failures; consult the release notes and #985 for the exact evidence. The browser fixes in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) are [unreleased](releases/unreleased.md).
|
||||
|
||||
Every release and changelog entry must link its central QQQ issues and distinguish product changes, test/infrastructure corrections, verification and unresolved work. A published RC's tag and artifacts remain immutable; subsequent fixes require a separately authorized version and release. Update tickets and Project 12 with the actual delivery version and release link after publication.
|
||||
|
||||
Final 1.0 remains held for the owner's requested application soak and renewed, explicit final approval ([#712](https://github.com/QRun-IO/qqq/issues/712)). Passing CI, an issue marked Done or elapsed time does not release that hold. This project does not authorize releasing QQQ 4.1.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Plan: API playground contrast (#724)
|
||||
|
||||
## Reproduction and scope
|
||||
|
||||
Fresh, fingerprinted desktop captures preserve the original Next composition. RapiDoc's Expand all text in both the original design and RC6 renders RGB(29, 78, 216) on RGB(42, 43, 44), a contrast ratio of 2.12:1 at 16px normal weight. This inherited defect fails the 4.5:1 requirement. Browser regression checks also found light-mode contact-link contrast of 2.36:1 and authentication-status contrast of 3.00:1.
|
||||
|
||||
## Implementation and verification
|
||||
|
||||
- [x] Retain failing browser contrast checks inside RapiDoc's shadow DOM.
|
||||
- [x] Apply existing dashboard primary-text and error tokens at the component boundary. Pair dark accent backgrounds with inverse button text. Preserve layout and dependencies.
|
||||
- [x] Verify normal and hovered controls in light/dark Chromium desktop and mobile, plus live Preferences theme switching.
|
||||
- [x] Run types, lint, production export, all 1,901 unit tests and existing bundle budgets.
|
||||
- [x] Directly inspect four fresh screenshots after color transitions settle.
|
||||
- [x] Independent review approved with no remaining findings; prepare signed isolated commit.
|
||||
|
||||
Evidence: `test-results/visual-review/api-playground-contrast/` in the original checkout. Final focused report: four passed, zero failures, skips, flaky outcomes or report errors. These are targeted checks, not a complete acceptance gate. Initial toggle verification incorrectly asserted an HTML attribute: React 19 sets RapiDoc's non-reflected Lit property. A subsequent audit sampled active color transitions. Both runs are retained; the corrected test awaits the property, Lit render and actual shadow-root animations without fixed sleeps.
|
||||
|
||||
## Remaining limits
|
||||
|
||||
This proves the fixture's visible playground content, hover states and theme switching, not all expanded endpoint/authentication states or arbitrary custom palettes. The existing narrow-screen inner horizontal clipping remains open; this color correction does not redesign the playground.
|
||||
|
||||
This branch is based on RC6 source `7ddf45f` and excludes the separate TIME fix `276b65b`. Neither follow-up is part of the immutable RC6 tag. Final 1.0 remains on hold.
|
||||
@@ -0,0 +1,31 @@
|
||||
# PLAN: Next UI 1.0 query filter parity
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the supported Material Dashboard query-filter workflows in QRun-IO/qqq#715 and #718, and prove them with real-backend acceptance rows.
|
||||
|
||||
## Approach
|
||||
|
||||
Build on the preserved wave 2 worktree. Keep one `QQueryFilter` as the source of truth for basic and advanced modes, with the existing `useRecordQuery` state and saved-view serialization. Share the existing metadata-driven operator and value controls between quick-filter chips and the advanced builder. Use the v1 metadata settings from backend PR #787 for weekday options and help.
|
||||
|
||||
## Files affected
|
||||
|
||||
- `src/components/query/RecordQuery.tsx`, `RecordQueryToolbar.tsx`: mode switch, quick filters, sort, clear confirmation, advanced preview.
|
||||
- `src/components/query/QuickFilterBar.tsx`: basic-mode chips, field picker, operator/value editor and accessible clear/remove actions.
|
||||
- `src/components/query/FilterBuilder.tsx` and its extracted helpers: complete advanced operator and value behavior from #718.
|
||||
- `src/lib/utils/quick-filter-utils.ts`, `filter-utils.ts`, `saved-view-utils.ts`, `src/lib/hooks/use-record-query.ts`: metadata defaults, criteria edits, mode reconciliation, persistence and view round-trips.
|
||||
- `tests/acceptance/matrix/query.json` and `tests/acceptance/specs/query/`: real-backend evidence for every applicable #715/#718 behavior.
|
||||
|
||||
## Steps
|
||||
|
||||
1. [x] Add meaningful unit tests for quick-filter defaults, complex-filter rejection, edits, weekday operators, date expressions and saved-view round-trips; finish any missing utility behavior.
|
||||
2. [x] Wire instance filter settings and add the basic/advanced switch with a reason tooltip when basic cannot represent the current filter.
|
||||
3. [x] Build and test basic quick-filter chips, add-field menu, operator/value editing, clear/remove behavior and advanced preview.
|
||||
4. [x] Add sort picker, clear-all confirmation that preserves sort, and per-row validation; cover keyboard and touch behavior.
|
||||
5. [ ] Add real-backend acceptance rows and tests, then run typecheck, lint, unit, static-export budget and the five-project browser gate after integration.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Render field labels from metadata and preserve readable join and virtual-field behavior.
|
||||
- Keep the filters and saved views interoperable with Material Dashboard JSON.
|
||||
- Preserve unrelated wave 2 worktrees. Do not release QQQ 4.1.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Script completion compatibility
|
||||
|
||||
Goal: close the autocomplete gap in the parity ledger while retaining the original Next textarea editor and its keyboard accessibility. Authorized by the active Next UI 1.0 completion goal.
|
||||
|
||||
- Preserve syntax overlay, split panes, per-file language, undo, read-only behavior and Tab escape.
|
||||
- Opt script editors into a caret-adjacent suggestion list: QQQ API/logger methods, current-file identifiers, and highlighter language keywords. No new dependency or backend contract.
|
||||
- Typing a prefix or Ctrl+Space opens suggestions; arrows select, Enter/Tab insert, Escape dismisses; pointer selection retains textarea focus. Composition input must not be intercepted.
|
||||
- Replace the current token at the caret, preserve surrounding text, and avoid duplicating an existing call parenthesis.
|
||||
- Verify unit regressions, real-backend script edit/test/save/undo behavior, focus/keyboard and visual states. Update guides and ledger only to the extent proven.
|
||||
|
||||
Verified: baseline Chromium 95847c2 passed 556 tests; backend PR #913 merged. Autocomplete passes 1,840 unit tests, types, affected lint, production export and bundle budgets; 15 Chromium developer/script workflows and 10 focused cases across all five profiles. Both review findings fixed after failing regressions. A native textarea probe explained WebKit undo grouping; completion now has a loaded-revision undo/redo test without changing native history behavior. Light/dark screenshots inspected; whole-family visual review remains open.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Sidebar icon label assertion (#728)
|
||||
|
||||
## Evidence
|
||||
|
||||
RC6 candidate WebKit failed NAV-002 because raw link text included decorative legacy icon codepoints. The same assertion was reproduced against the public RC6 JAR after explicitly awaiting the fixture's legacy glyph. This is a test assertion defect: accessible link names should match metadata labels, while decorative SVG text remains hidden from assistive technology.
|
||||
|
||||
## Correction
|
||||
|
||||
Await the legacy glyph, then assert exact link count and each accessible label in backend order. Check that rendered legacy SVGs remain `aria-hidden`. Retain nested-app, parent expansion and child label assertions. No product code or release artifact changes.
|
||||
|
||||
## Verification
|
||||
|
||||
Retain red and focused Chromium/WebKit/mobile reports under `test-results/visual-review/sidebar-icon-labels/` in the original checkout. The focused check exercises the actual public RC6 JAR with backend `e0d57dd`. Targeted ESLint and diff checks are required; independent review precedes integration. Original CI failure remains recorded, not erased by a later focused pass.
|
||||
|
||||
Focused result: three passed, zero failures, skips, flaky outcomes or report errors. Targeted ESLint and diff checks passed.
|
||||
|
||||
Independent review approved with no findings. This change verifies navigation semantics; glyph appearance remains covered by the separate legacy-icon workflows.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Plan: TIME block seconds compatibility (#728)
|
||||
|
||||
Preserve Next layout while making standalone and process TIME inputs accept and submit seconds, matching the shared form editor and QQQ TIME values.
|
||||
|
||||
1. Reproduce native step validation failure with standalone and process tests; retain a regular form control.
|
||||
2. Apply the existing one-second input step to both plain block paths.
|
||||
3. Verify edited/seeded values through actual Chromium workflows, including backend process readback; run affected tests and relevant quality gates.
|
||||
4. Keep this follow-up separate from the unchanged RC6 candidate while its hosted CI runs.
|
||||
|
||||
Verification: two focused component regressions failed before the fix (regular form control passed); three real-backend Chromium scenarios failed at native step assertions. After the two-line correction, all 66 affected component tests and eight desktop/mobile backend workflows passed, including independent SQL checks. Full unit suite, type/lint, production build and budgets passed. This is isolated follow-up work, not part of RC6 candidate `7ddf45f`.
|
||||
@@ -15,7 +15,7 @@ import type { QInstance, QTableMetaData, QRecord, QQueryFilter } from '@/types'
|
||||
| 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. |
|
||||
| `QAuthenticationMetaData` | `metadata.ts` | Describes the auth provider type (`AUTH_0`, `OAUTH2`, `TABLE_BASED`, `FULLY_ANONYMOUS`, `MOCK`), the provider-specific values (client ID, base URL, audience) needed to boot the correct auth flow, and the pre-sign-in `branding` (`QLoginBranding`) the login page shows. |
|
||||
| `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. |
|
||||
|
||||
@@ -1,93 +1,208 @@
|
||||
# Browser matrix — real-backend acceptance (QRun-IO/qqq#649)
|
||||
# Browser matrix — real-backend acceptance (QRun-IO/qqq#649, #708)
|
||||
|
||||
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
|
||||
|
||||
## September 30 — post-RC4 report and menu checkpoint
|
||||
|
||||
Local production export at `a8e4d3b`:583 Chromium tests and all391 required feature rows passed, zero failures/skips/flaky results, three approved external-service exclusions. This includes the uniform table backgrounds, record overview spacing and hidePreview compatibility fixes. Retained full report/gate: `report-preview-full-chromium/` in local visual-review evidence.
|
||||
|
||||
The subsequent column-menu height correction passes28 targeted Chromium/WebKit cases. The valid red run failed both320px-window cases and passed both720px cases; final checks cover bounds, keyboard/focus, Copy/sort, standalone query actions and variants. Hosted predecessor36739388053 WebKit remains failed578/1: Copy full query values was outside the viewport before variant switching. Its exact scenario passed locally before correction; hosted resolution is unproven. Other predecessor jobs remain pending at this checkpoint. These scoped results do not establish complete browser compatibility.
|
||||
|
||||
|
||||
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.
|
||||
five configured Playwright projects below. CI runs Chromium separately from phone/tablet
|
||||
so the primary workflow gate can finish independently. Prioritize Chromium
|
||||
workflow depth for RC1 and final 1.0; the complete final browser claim still requires all five projects.
|
||||
|
||||
## Verification order
|
||||
|
||||
First complete workflows in desktop Chromium against the real backend: normal defaults,
|
||||
required and invalid inputs, dependent controls, edit/cancel, save/reopen, permission
|
||||
boundaries, error recovery, and database or downloaded-file results. Expand browser and
|
||||
viewport coverage after those behaviors work. Keep existing secondary results visible;
|
||||
a passing row or test count does not establish that every path within a feature works.
|
||||
|
||||
For example, WID-073 checks report column totals and the exported value distribution
|
||||
against SQL for the active filter, then verifies cancellation leaves saved data unchanged.
|
||||
Opening the statistics dialog alone is insufficient evidence of that workflow.
|
||||
|
||||
## RC3 published checkpoint — 2026-09-29
|
||||
|
||||
Published source `f1a6a3a` / `v1.0.0-RC.3` passed **570 Chromium tests and all 391 required rows** in the [candidate run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36652168977), with zero failures, skips or flaky results. The tested PR merge has the same Git tree as the tag; backend commit was `cba758507dcc54cf282a6638ab50a7d3a1904c7c`.
|
||||
|
||||
The candidate run is complete: mobile **545 passed**; Firefox **569 passed, one failed** (WID-005: Widget Gallery heading timeout); WebKit **568 passed, two failed** (NAV-056: application-request access-control errors; QRY-030: density-menu timeout); tablet **543 passed, two failed** (QRY-030: density-menu timeout; REC-046: WebKit internal navigation error). No skips or flaky results. Reports and traces are retained; causes remain unproven. The overall candidate run failed. The [tag run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36654410984) passed **570 Chromium tests and all 391 required rows**. Tag WebKit passed **568 and failed two** (NAV-016 application-request access-control errors; QRY-030 density-menu timeout); tag Firefox passed **568 and failed two** (QRY-007 error toast not found; WID-073 Render Report action timeout). Tag mobile passed **545 tests**; tablet passed **543 and failed two** (NAV-026 widget-request access-control errors; QRY-030 density-menu timeout). The tag run is complete and failed overall, with zero skips or flaky results. Reports and traces are retained; no complete cross-browser pass is claimed.
|
||||
|
||||
The public Maven JAR passed signature/checksum checks, normal dependency resolution in a separate consumer, and seven affected Chromium workflows with independent database readback. Both image architectures are published; the actual arm64 image passed app-route and all 21 referenced JavaScript asset checks. See [RC3 release notes](../releases/1.0.0-RC.3.md) for immutable artifact checksums and limitations. Final 1.0 remains on hold.
|
||||
|
||||
## RC2 published checkpoint — 2026-09-29
|
||||
|
||||
Published source `0905cfb` / `v1.0.0-RC.2` passed **563 Chromium tests and all 390 required rows** in both its PR and tag runs. The [completed tag run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36642612560) also passed all **538 mobile tests**. WebKit passed 561 and failed QRY-030 (density menu) and RPT-012 (internal navigation error); Firefox passed 561 and failed PRC-061 (bulk-load default selection) and INT-011 (failed-save feedback); tablet passed 537 and failed QRY-030. No tests skipped or passed through retries. The overall run failed, and failure causes remain under investigation.
|
||||
|
||||
The earlier PR run had a different set of secondary-browser failures. Both sets remain recorded in the [RC2 release notes](../releases/1.0.0-RC.2.md); a passing later scenario does not establish why an earlier failure occurred. The published Maven JAR was independently downloaded, signature/checksum verified, resolved by a fresh consumer and exercised in five Chromium workflows. Both container architectures are published; the arm64 image passed runtime HTML/JavaScript checks. RC2 is immutable and final 1.0 remains on hold.
|
||||
|
||||
The preparation checkpoints below are historical results and do not supersede this published-artifact evidence.
|
||||
|
||||
## Post-RC1 checkpoint — 2026-09-29
|
||||
|
||||
The report capability candidate `2221999` passed **562 Chromium tests**, all 390 required rows, zero failures/skips/flaky tests in [CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36633453712). WebKit passed 561 with one QRY-030 failure: the density option did not appear after a trigger tap. Its trace is retained and the cause remains unproven. Firefox passed 560 with two failures: NAV-014 reported an owned embedded-page request failure during navigation; SEC-003 timed out awaiting its initial record row, before the create-restriction assertions. The final screenshot shows the row, and the trace shows a roughly 15-second delay before the query was sent; the cause remains unproven. Mobile passed all 537 tests; tablet passed 536 with one QRY-030 density-menu timeout matching the desktop WebKit symptom. The run is complete and failed overall. These results precede the child-export correction.
|
||||
|
||||
The subsequent `542ac47` local full Chromium run passed 562 and failed WID-046: the generic parent CSV export was hidden while child dropdowns awaited selection, despite the parent's independent CSV payload. The correction preserves the selection restriction for specialized child export and restores generic payload export. Its new regression was observed failing before the fix; 29 focused tests, all 1,857 unit tests, and the production build pass. The corrected export then passed **563 Chromium tests and all 390 required rows**, with zero failures/skips/flaky tests. A clean local JAR matches the tested export byte-for-byte. Exact-commit CI and published-artifact verification remain pending; this local result does not establish artifact availability.
|
||||
|
||||
The preceding d937507 [CI run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36626338487) passed **558 tests each in Chromium and WebKit**, all 390 required rows. Firefox passed **556 with two failures**: NAV-014 reported `NS_ERROR_FAILURE` for the owned embedded page during breadcrumb navigation; QRY-007 timed out hovering an application-theme error toast after it detached. Logs and traces are retained for diagnosis. All five focused breadcrumb/error-theme scenarios subsequently passed locally in Firefox against RC2 without source or test changes. That recheck does not establish the cause or resolution of the CI failures. Mobile passed 533; tablet passed 532 and failed REC-019 with a WebKit internal navigation error while opening a field-lab record. Its log and trace are retained. These results do not establish a complete cross-browser pass or erase the RC1 results below.
|
||||
|
||||
## RC1 checkpoint — 2026-09-29
|
||||
|
||||
Published candidate `e901df9` / `v1.0.0-RC.1`: Chromium **547 passed**, no failed/skipped/flaky tests, all 389 required rows passed. WebKit **546 passed, 1 failed** in NAV-034 legacy table-process redirect navigation, with an internal WebKit resource-loading error; trace retained for investigation. The completed Firefox run had 546 passes and one WID-021 widget visibility timeout; phone/tablet had 1,043 passes and one tablet SEC-002 internal WebKit navigation error. The separate tag run also had failures; see the release notes for both runs. [CI run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36601699322). This does not establish a complete cross-browser pass.
|
||||
|
||||
The actual Maven Central JAR additionally passed Sleep Interactive defaults/validation/completion and report-context filtered statistics/CSV checks. The published arm64 container passed a runtime/HTML smoke check; its manifest also includes amd64. See [RC1 release evidence](../releases/1.0.0-RC.1.md).
|
||||
|
||||
```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
|
||||
# primary workflow gate, fresh static export
|
||||
QQQ_ACCEPTANCE_BROWSERS=chromium node scripts/acceptance.mjs
|
||||
# subsequent compatibility gate, all five projects, fresh static export
|
||||
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet node scripts/acceptance.mjs
|
||||
# the same with explicit ports (use distinct ports for concurrent runs)
|
||||
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile \
|
||||
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet \
|
||||
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
|
||||
# the touch projects only, reusing out/ (a filtered run is a partial gate)
|
||||
QQQ_ACCEPTANCE_BROWSERS=mobile,tablet node scripts/acceptance.mjs --skip-build
|
||||
```
|
||||
|
||||
| Project | Playwright device | Engine (Playwright 1.58.2) | Runs |
|
||||
| Project | Playwright device | Engine (Playwright 1.64.0-alpha-2026-10-01) | 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` |
|
||||
| chromium | Desktop Chrome | Chrome for Testing 155 | every spec |
|
||||
| firefox | Desktop Firefox | Firefox 156 | every spec |
|
||||
| webkit | Desktop Safari | WebKit 26.6 | every spec |
|
||||
| mobile | Pixel 7 (412 × 839, touch) | Chromium | tests tagged `@mobile` |
|
||||
| tablet | iPad (gen 7) (810 × 1080, touch) | WebKit | tests tagged `@mobile` or `@tablet` |
|
||||
|
||||
## Phone scope
|
||||
## Phone and tablet scope (QRun-IO/qqq#708)
|
||||
|
||||
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.
|
||||
Below 768 px the UI uses a different layout by design:
|
||||
|
||||
The phone-relevant rows and their `@mobile` tests:
|
||||
- lists are card lists;
|
||||
- filters open in a bottom sheet;
|
||||
- record sections are an accordion;
|
||||
- record actions are in an action sheet;
|
||||
- navigation is a modal drawer.
|
||||
|
||||
| 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 tablet in portrait (810 px) gets the desktop layout (grid, sidebar, tabs) with a touch
|
||||
pointer. Specs that assert the desktop layout (grid columns and cells, column resizing,
|
||||
grid keyboard navigation) do not describe phone behavior, and skipping them is not
|
||||
allowed: skips fail the gate. The touch projects therefore run only tagged tests:
|
||||
|
||||
- `mobile` runs tests whose title carries `@mobile`;
|
||||
- `tablet` runs tests tagged `@mobile` or `@tablet` (`grep` in
|
||||
`tests/acceptance/playwright.config.ts`).
|
||||
|
||||
Tagged tests also run on the desktop projects. They are either layout-neutral (helpers
|
||||
`listCell`, `columnCells`, `listRows`, `navigation`, `recordList`, `openRecord` and
|
||||
`recordAction` pick the grid or the cards, the tabs or the accordion) or pinned to a
|
||||
412 px touch viewport, so every phone scenario is also checked in Firefox and WebKit at
|
||||
phone width.
|
||||
|
||||
### Coverage rule, enforced by the gate
|
||||
|
||||
When a touch project runs, `scripts/acceptance-gate.mjs` requires every required row to
|
||||
have a passing test in `mobile` and in `tablet`. The only exception is a row that records
|
||||
why it is not a touch scenario (`"desktopOnly": "<reason>"`, which currently no row uses).
|
||||
The result is in `summary.phone`: `covered`, `desktopOnly`, `uncovered`. The desktop-layout
|
||||
variants that stay untagged (the grid-keyboard INT-001, the grid-loading INT-009, the
|
||||
desktop REC-004 tab test) have phone counterparts for the same rows.
|
||||
|
||||
### Touch targets and layout checks
|
||||
|
||||
On a coarse pointer every control is at least 44 × 44 CSS px (WCAG 2.5.5). This is a
|
||||
`@media (pointer: coarse)` rule in `src/styles/globals.css`, plus `pointer-coarse:`
|
||||
classes in components. It covers:
|
||||
|
||||
- buttons, inputs, selects, tabs, menu items and options;
|
||||
- a label wrapping a checkbox, radio or switch;
|
||||
- breadcrumbs, sidebar entries, record links and widget block links.
|
||||
|
||||
Links in running text and data values keep their inline size. Mouse layouts are unchanged
|
||||
at every width, because Playwright emulates `pointer: coarse` only when `hasTouch` is set.
|
||||
|
||||
`tests/acceptance/support/touch.ts` provides the checks:
|
||||
|
||||
- `expectNoHorizontalScroll`: the page never scrolls sideways; wide data scrolls inside
|
||||
its own container;
|
||||
- `expectTouchTargets` / `expectTouchReady`: no visible control below 44 px, applied only
|
||||
under a coarse pointer.
|
||||
|
||||
The phone and tablet specs call these on the shell, lists, filter sheet, column
|
||||
configuration, bulk bar, record view and forms, dialogs and sheets, every process screen
|
||||
type, dashboards and reports.
|
||||
|
||||
Phone behaviors with their own rows and tests:
|
||||
|
||||
| Phone behavior | Rows |
|
||||
|----------------|------|
|
||||
| Navigation drawer: tap, backdrop, keyboard focus trap, Escape, focus return | NAV-026, NAV-037 |
|
||||
| Shell on phones and tablets: header, breadcrumbs, sidebar, skip link, not-found | NAV-038, NAV-014 |
|
||||
| Card list: grid columns in order, formatted values, selections, loading | QRY-008, INT-009 |
|
||||
| Filter sheet: modal dialog, focus in and out, Escape, scrolls inside | QRY-009 |
|
||||
| Column configuration by touch | QRY-024 |
|
||||
| Bulk actions from a card selection | QRY-036 |
|
||||
| Saved views and export menus on a phone | QRY-055 |
|
||||
| Record header and phone action sheet | REC-058, INT-003 |
|
||||
| Forms with every editor type on phones and tablets | REC-059, INT-008 |
|
||||
| Tooltips and help by tap (fields, widget labels, widget help) | REC-036, REC-039, WID-043, WID-044 |
|
||||
| Dashboards: one column below 1024 px, wide tables scroll in their card, dropdown/date/reload/export by tap | WID-022, WID-040, WID-019, WID-045–WID-048 |
|
||||
| Process screens of every type touch-ready | PRC-001–PRC-047 |
|
||||
| Sign-in, logout, session expiry; accessibility scan at phone width | SEC-020–SEC-023, INT-014, INT-005 |
|
||||
| TABLE_BASED password sign-in, failure, expiry and logout | SEC-034–SEC-037 (QRun-IO/qqq#700) |
|
||||
| Login page branding before sign-in | NAV-033 (QRun-IO/qqq#703) |
|
||||
| Search dialog with record search | NAV-030 (QRun-IO/qqq#701) |
|
||||
| Keyboard use on a phone (card, action sheet, edit, save) | INT-001 |
|
||||
|
||||
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:
|
||||
Recorded 2026-09-26 on macOS (Darwin 27) for the Next UI 1.0 integration (QRun-IO/qqq#713):
|
||||
the sample from the qqq `feature/next-1.0` backend (`a55c71a2d`, with the v1 supplemental
|
||||
metadata, developer-mode and table-variant process routes of #714) and the Next static export
|
||||
of `feature/next-1.0` (`e3bea6e`: the batch 1-3 streams, Material parity #714 and phone and
|
||||
tablet coverage #708), built from a clean `.next`. One run of the full gate with the five
|
||||
projects:
|
||||
|
||||
Tests passed / run, per spec area and project (the mobile project runs the `@mobile` tests only):
|
||||
Tests passed / run, per spec area and project (the touch projects run 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** |
|
||||
| Area | chromium | firefox | webkit | mobile | tablet |
|
||||
|---|---|---|---|---|---|
|
||||
| navigation | 59/59 | 59/59 | 59/59 | 56/56 | 56/56 |
|
||||
| performance | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 |
|
||||
| processes | 61/61 | 61/61 | 61/61 | 54/54 | 54/54 |
|
||||
| query | 68/68 | 68/68 | 68/68 | 68/68 | 68/68 |
|
||||
| records | 90/90 | 90/90 | 90/90 | 84/84 | 84/84 |
|
||||
| security | 73/73 | 73/73 | 73/73 | 67/67 | 67/67 |
|
||||
| widgets | 93/93 | 93/93 | 93/93 | 91/91 | 91/91 |
|
||||
| **total** | **449/449** | **449/449** | **449/449** | **425/425** | **425/425** |
|
||||
|
||||
`—`: 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`):
|
||||
Gate (`test-results/acceptance/gate.json`, 2,197 tests, 44.3 min, retries 0, commit `e3bea6e`):
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": { "passed": 289, "failed": 0, "missing": 0, "excluded": 3 },
|
||||
"summary": { "passed": 347, "failed": 0, "missing": 0, "excluded": 3,
|
||||
"phone": { "covered": 347, "desktopOnly": 0, "uncovered": 0 } },
|
||||
"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 }
|
||||
"chromium": { "passed": 449, "failed": 0, "skipped": 0, "flaky": 0 },
|
||||
"firefox": { "passed": 449, "failed": 0, "skipped": 0, "flaky": 0 },
|
||||
"webkit": { "passed": 449, "failed": 0, "skipped": 0, "flaky": 0 },
|
||||
"mobile": { "passed": 425, "failed": 0, "skipped": 0, "flaky": 0 },
|
||||
"tablet": { "passed": 425, "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.
|
||||
Every required row passes in every desktop project and in both touch projects; no row is
|
||||
`desktopOnly`. PRC-039, WID-033 and SEC-030 are `required: false` pending approval and are
|
||||
reported as excluded.
|
||||
|
||||
## Differences found between engines
|
||||
|
||||
@@ -122,4 +237,67 @@ Fixed:
|
||||
open, closes on Escape, and returns focus to its trigger, also when a dialog opened from
|
||||
one of its items closes.
|
||||
|
||||
Open: none.
|
||||
The fixes above describe earlier checkpoints. Open failures from the RC1 runs are recorded in the RC1 checkpoint and release notes; they have not been waived.
|
||||
|
||||
|
||||
## RC4 candidate density touch correction
|
||||
|
||||
The recurring QRY-030 density-menu failure was reproduced in Linux WebKit using
|
||||
Playwright 1.58.2's official Noble image. Instrumented events showed that touch
|
||||
pointer-down opened the menu, then synthetic mouse focus returned to its trigger;
|
||||
Radix treated that focus as outside the non-modal menu and dismissed it. macOS
|
||||
WebKit did not reproduce this event sequence.
|
||||
|
||||
The RC4 candidate source applies the existing Selection/Views/Export trigger guard to
|
||||
Density. It ignores outside interactions only when they target that same trigger;
|
||||
other controls still dismiss the menu. No layout, dependency or timeout changed.
|
||||
|
||||
Both the existing density-selection test and the new touch/keyboard/outside-dismissal
|
||||
regression passed with the selection-menu workflow: **nine checks across Linux
|
||||
WebKit, tablet WebKit and Chromium, zero failures/skips/flaky results**. The new
|
||||
regression failed before the fix. An initial added-test run used an incorrect
|
||||
`textbox` locator for the searchbox; it was interrupted, retained, corrected and
|
||||
rerun. The full unit suite passed 1,884 tests; production export, types, affected
|
||||
lint and independent review also passed. The combined candidate at `f2cdf03` passed **577 Chromium tests and all 391
|
||||
required rows**, with zero failures/skips/flaky results and three approved exclusions.
|
||||
The preceding numeric-wheel correction separately passed its 576-test Chromium gate.
|
||||
|
||||
Both corrections were integrated before RC4 publication at
|
||||
`f2cdf03`, superseding its earlier preparation checkpoint `1dbe1d9`. Published
|
||||
RC3 does not contain them. Historical RC3/precursor failures above remain
|
||||
valid evidence for those revisions; this focused result does not certify the whole
|
||||
browser matrix.
|
||||
|
||||
|
||||
## Completed precursor CI before RC4 integration
|
||||
|
||||
Run `36658766595` at `1283bac07e81741bfadc34bf7fe1c7805604d16f` is terminal.
|
||||
Chromium passed 574 tests and all 391 required rows. Mobile passed 547; tablet
|
||||
passed 544 and failed three: NAV-015 internal WebKit navigation, NAV-056 API
|
||||
access-control errors, and QRY-030 density touch. Firefox passed 573 and failed
|
||||
WID-024 waiting for the record heading. WebKit passed 573 and failed QRY-030.
|
||||
All profiles had zero skips or flaky outcomes; general CI and image checks passed.
|
||||
These results predate the report-editor, numeric-wheel and density corrections.
|
||||
The Linux density regression is corrected and locally verified as described above;
|
||||
other secondary failures remain open pending diagnosis or new candidate evidence.
|
||||
Hosted checks for the integrated RC4 candidate and tag are complete; see the RC4 published checkpoint below.
|
||||
|
||||
|
||||
## RC4 published checkpoint — 2026-09-30
|
||||
|
||||
Published source `3c409f9` / `v1.0.0-RC.4` passed **577 Chromium tests and all 391 required rows** in both [candidate](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36664932061) and [tag](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36714593674) acceptance. Both used backend `e0d57dd31b979b17215370546ef4e26cd8a38d21`; the candidate merge tree matches the tagged source.
|
||||
|
||||
Both acceptance runs completed and failed overall because of secondary-browser failures. All profiles had zero skips or flaky outcomes; failures were retained, not waived or silently retried.
|
||||
|
||||
| Browser | Candidate result | Tag result |
|
||||
| --- | --- | --- |
|
||||
| Chromium | 577 passed | 577 passed |
|
||||
| Mobile | 547 passed, 1 failed: REC-054 script-suggestion listbox bounding-box timeout in dark mode | 548 passed |
|
||||
| Firefox | 576 passed, 1 failed: WID-026 record-heading timeout | 576 passed, 1 failed: WID-019 dashboard-heading timeout |
|
||||
| WebKit | 576 passed, 1 failed: REC-046 internal navigation error on a read-only user's copy route | 576 passed, 1 failed: REL-005 internal navigation error returning to the pet list |
|
||||
| Tablet | 547 passed, 1 failed: NAV-026 widget-request access-control errors during drawer navigation | 547 passed, 1 failed: SEC-002 internal navigation error opening a denied ledger create route |
|
||||
|
||||
Density scenarios passed in every profile of both runs. The other failures remain open; a later pass does not erase the earlier failure or prove full browser compatibility.
|
||||
|
||||
|
||||
The actual public Maven JAR passed signature/checksum verification, fresh-consumer dependency resolution and 17 affected real-backend Chromium workflows. The public ARM64 container served the application and all 21 referenced JavaScript assets. These checks establish artifact delivery and the stated workflow coverage, not full compatibility or visual approval. See [RC4 release notes](../releases/1.0.0-RC.4.md) for checksums and remaining requirements. Final 1.0 remains on hold for owner testing and renewed approval.
|
||||
@@ -0,0 +1,5 @@
|
||||
# ESB integration acceptance
|
||||
|
||||
The ten required [ESB matrix scenarios](../../tests/acceptance/matrix/esb.json) pass in Chromium, Firefox and WebKit: 30 cases with no failures, skips or retries. They exercise the stock sample’s embedded Artemis broker, table-event delivery into `syncPerson`, service/table/process views, permission denial, subscription browsing, trigger pause/resume/restart and confirmed dead-letter replay. A further 38 Chromium navigation, permission and table Developer-view cases, plus 12 security-variant and search cases, pass without skips or retries. The main and security fixtures use separate broker ports. These are focused ESB results; general Next browser/parity and final release acceptance remain tracked separately.
|
||||
|
||||
The backend integration fixes are [QQQ PR #980](https://github.com/QRun-IO/qqq/pull/980), based on `5bf02d54`: normal sample ESB registration, API documentation route isolation and nullable subscription/dead-letter pause state. Frontend verification passes 105 focused ESB tests, all 1,951 unit tests with coverage thresholds, type checking, the production static export and bundle budgets. The [acceptance README](../../tests/acceptance/README.md) gives the commands and fixture boundaries; [Next #10](https://github.com/QRun-IO/qqq-frontend-next/issues/10) and [QQQ #739](https://github.com/QRun-IO/qqq/issues/739) track delivery. RabbitMQ and broker management behavior are covered by the backend conformance suite; these browser tests use Artemis without an HTTP management endpoint, so unavailable broker actions remain hidden.
|
||||
+404
-291
@@ -1,331 +1,444 @@
|
||||
# Next UI feature acceptance matrix
|
||||
|
||||
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
|
||||
|
||||
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.
|
||||
Rows: 394. Required rows passing in the recorded runs: 391.
|
||||
|
||||
Recorded runs: `test-results/acceptance/gate.json` (2026-09-25T20:52:12.358Z).
|
||||
Recorded runs: `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-results/visual-review/rc8-corrected-full-chromium/gate.json` (2026-10-01T01:03:05.420Z), `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-results/visual-review/rc8-corrected-ci-chromium/gate.json` (2026-10-01T01:23:18.171Z).
|
||||
|
||||
Corrected RC8 source cd3d473: 602 local and 602 hosted Chromium cases passed, with zero failed/skipped/flaky cases or report errors. Hosted run 36798799502. Three approved external-service exclusions remain. Earlier failed runs are retained and described in docs/releases/1.0.0-RC.8.md; this matrix records the corrected primary runs only. Other browser profiles and whole-product compatibility/visual approval remain separate.
|
||||
|
||||
The additional ESB integration scenarios and their focused three-browser results are recorded in [ESB acceptance](esb.md). The historical full-suite counts above predate those ten new matrix rows.
|
||||
|
||||
## 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) |
|
||||
| NAV-001 | Direct link to a table list renders seeded rows | success | | prime-test-database.sql | pass (chromium) | |
|
||||
| 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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | |
|
||||
| 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) | [#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) | [#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 by label (matched locally, alongside record search when the backend offers it: NAV-030) | 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; search dialog contains Tab and Shift+Tab with empty or populated results; Clear returns to the input; Escape and backdrop dismissal restore keyboard and pointer openers | no search request fails (regression: every keystroke produced a 404 from a missing /search endpoint); with no searchable tables no request is made at all (NAV-032) | stock sample | pass (chromium) | [#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 and a backdrop tap close it; drawer controls are 44 px touch targets | | stock sample, 412x839 touch viewport | pass (chromium) | |
|
||||
| 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) | [#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) | [#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) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
|
||||
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/search over each table's searchFields) finds, after the matching pages, by record label with the table label; results open by keyboard and the search page survives refresh | '/' dialog: 'COCO' finds the pet Coco (label and table label), case-insensitively; ArrowDown and Enter open /app/pet/{id}; the request searches the readable searchable tables (person, pet, savedView) with a per-table limit of 5; header box: 'Sample' lists the matching people (database order, limit 5) after pages; Enter opens /app/search?q=Sample whose Records region lists every match (limit 25) before and after refresh; a link opens the record | a one-character term is not sent (unit), and a recent record already found is not listed twice (unit) | stock sample + NavigationFixtures (search fields on person, pet, savedView) | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) |
|
||||
| NAV-031 | Record search respects table read permissions and record security locks, in the UI and the API | noPets: 'ey' lists the matching people only; the UI never asks for pets and the metadata advertises no pet search fields; admin: the backend returns the pets matching 'EY' for tableNames [pet]; alice finds her saved view 'Alice People View' with the saved view table label, in the UI and from the API; bob, with whom the view is shared read-only, finds it from the API (the lock admits shared readers) | noPets: POST /qqq/v1/search with tableNames [pet] returns no results; an unscoped search returns no pets; casey (neither owner nor shared reader): the locked saved view is not found in the UI or from the API | stock sample + NavigationFixtures + QueryFixtures (alice's saved view shared read-only with bob); noPets persona; sharing demo users | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) |
|
||||
| NAV-032 | Without searchable tables (the backend declares no searchFields) global search stays local and never calls a search endpoint | the variant's v1 metadata lists no table with searchFields; header box, '/' dialog and search page keep the local labels and matches, with no Records group or region | no request to /qqq/v1/search or /search is made while typing, opening the dialog or loading the search page | security variant (stock sample + SecurityFixtures) | pass (chromium) | [#701](https://github.com/QRun-IO/qqq/issues/701) [#653](https://github.com/QRun-IO/qqq/issues/653) |
|
||||
| NAV-033 | Login branding before sign-in: logo, app name, accent color, favicon and title from GET /qqq/v1/metaData/authentication (no session), never the banners | unauthenticated authentication metadata carries exactly appName, logo, icon and accentColor; after logout and a fresh load the login card shows the loaded logo and QQQ Sample; primary color variables and the Sign in button use the accent; favicon /kr-icon.png; title Sign in \| QQQ Sample | no banner text in the unauthenticated response or on the login page | sample branding (appName, logo /samples-logo.png, icon /kr-icon.png) + NavigationFixtures accentColor #1d4ed8 and three banners | pass (chromium) | [#703](https://github.com/QRun-IO/qqq/issues/703) [#539](https://github.com/QRun-IO/qqq/issues/539) |
|
||||
| NAV-034 | Material table-scoped URLs: /app/{table}/{process} opens the table process (or an instance process); /app/{table}/{id}/{process} runs it for that record and returns to it | /app/person/person.bulkEdit?recordsParam=recordIds&recordIds=1,2 replaces itself with the run (init sends 1,2; Back returns to the table); /app/person/prcQuickTask opens the instance process with returnTo the table; /app/person/1/clonePeople runs for record 1 (init and SQL clone) and Return lands on the record; /app/person/2/bulkEdit resolves the table process by suffix; Cancel returns to the record | /app/person/1/noSuchProcess shows the in-shell not-found state and starts no process; report URLs (/app/{table}/{report}) are not covered: the backend frontend report metadata (QFrontendReportMetaData) has no tableName, so no report can match (#732) | sample person.bulkEdit, clonePeople (table processes), prcQuickTask (ProcessesFixtures, instance process) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) [#732](https://github.com/QRun-IO/qqq/issues/732) |
|
||||
| NAV-035 | Process runs launched from a record view or a filtered query return there (Return and Cancel honor returnTo) | record Actions > Clone People runs for the record and Return lands on the record; Bulk Edit from /app/person?filter=isEmployed: returnTo is the launching path and query; after the edit (SQL) Return restores the same filter and the filtered rows | the unmatched record (Morgan) stays out of the returned grid | sample person, clonePeople, person.bulkEdit | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| NAV-036 | Material app settings (MaterialDashboardAppMetaData): showAppLabelOnHomeScreen and includeTableCountsOnHomeScreen turned off hide the home-screen title and the table counts | v1 metaData carries supplementalAppMetaData.materialDashboard for the app; The app home has no visible title (a screen-reader-only h1 remains) and the table entry shows no count; No count request is made for the table | Apps without the settings keep their title and counts (Material defaults) | NavigationFixtures.java navQuietHome app (navQuietItem table) with MaterialDashboardAppMetaData(false, false) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| NAV-037 | Mobile navigation drawer is a keyboard-usable modal dialog: focus moves in on open, Tab stays inside, and Escape, the close button or a chosen link close it and return focus to the menu button | Enter on the menu button opens the Navigation dialog with focus inside; Tab and Shift+Tab visit every drawer control and never reach the page behind; Escape, the close button and choosing Carrier close it; focus returns to the menu button; Carrier opens | Tab never leaves the open drawer (QRun-IO/qqq#708: focus stayed on the menu button; Firefox could not reach the drawer) | stock sample (Miscellaneous app), 412x839 touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| NAV-038 | Shell on phones and tablets: skip link, header controls, breadcrumbs, navigation entries, user menu, app homes and not-found links are 44 px touch targets, and the page never scrolls sideways | skip link is the first Tab stop and 44 px tall when shown; header, breadcrumbs, sidebar or drawer entries and the user menu meet the 44 px target; no sideways page scroll with a four-level breadcrumb trail | under a mouse (pointer: fine) the desktop sizes are unchanged | stock sample + NavigationFixtures (navDeepItem three apps deep); mobile (Pixel 7) and tablet (iPad, desktop layout with touch) projects | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| NAV-055 | Material Dashboard CSS and test hooks remain available on navigation, banners, app homes, forms, record views and query controls | Material data-qqq-id and class selectors identify corresponding Next UI controls; application CSS keyed to those selectors changes the rendered style; record and query actions retain the expected behavior and button variants | | stock sample with NavigationFixtures and RecordsFixtures; application CSS applied in css-hooks.spec.ts | pass (chromium) | [QRun-IO/qqq-frontend-next#731](https://github.com/QRun-IO/qqq-frontend-next/issues/731) |
|
||||
| NAV-056 | The Material Dashboard theme from versioned QQQ metadata changes Next UI tokens, application CSS and the branded header | v1 metaData publishes the selected Material theme settings; the rendered dashboard applies its primary token, branded header colors and tagline, and custom CSS; Every theme scenario resets instance metadata before navigation; Preferences exposes accessible Light and Dark choices; keyboard changes, real reload persistence, reset and light/dark dialog accessibility are verified; Application theme override is explained in Preferences without erasing the saved browser appearance choice; Pivot validation meets text contrast in default light, default dark and a configured orange error theme; API playground visible text and hovered controls meet contrast in light/dark themes; Preferences changes update the rendered palette without reload | | AcceptanceSampleServer /acceptance/theme; reset removes the theme before each test | pass (chromium) | [QRun-IO/qqq-frontend-next#719](https://github.com/QRun-IO/qqq-frontend-next/issues/719) |
|
||||
|
||||
## Performance budgets on the production export: large tables, heavy dashboards and long processes (QRun-IO/qqq#710). Bundle budgets are checked separately by scripts/perf-budget.mjs.
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|---|---|---|---|---|---|---|
|
||||
| PRF-001 | large table: a 10,000-row, 40-column table opens with every column and the exact first page within budget | all 40 columns have a sort header; the first 25 rows carry the exact computed values; the total reads 10,000; time from navigation to the first page is within PERFORMANCE_BUDGET.firstPageMs | only one page of rows is in the DOM (25 rows, 1,000 cells), not the table | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
|
||||
| PRF-002 | large table: 250-row pages of 40 columns render, page and reach the last page within budget | page size 250 shows ids 10000..9751 with 10,000 cells within PERFORMANCE_BUDGET.pageMs; next page shows 9750..9501 and last page 250..1 (page 40), each within PERFORMANCE_BUDGET.pageMs; the backend is asked for skip/limit 250 | the DOM never holds more than one page (250 rows) | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
|
||||
| PRF-003 | large table: sorting and filtering 10,000 rows return exact results within budget | sorting by Name ascending shows Row 00001..Row 00025 within PERFORMANCE_BUDGET.queryMs; Count 04 equals 0 narrows to the 40 matching rows (the SQL answer) within PERFORMANCE_BUDGET.queryMs | | tests/acceptance/fixture/PerformanceFixtures.java prfWide | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
|
||||
| PRF-004 | heavy dashboard: 24 widgets (statistics, bar, line and pie charts, 100-row tables) load once each within budget | every widget finishes loading within PERFORMANCE_BUDGET.dashboardMs of navigation; statistics show their exact counts, chart data tables their exact points, tables their 100 rows | each widget's data is requested exactly once | tests/acceptance/fixture/PerformanceFixtures.java prfDashboard | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
|
||||
| PRF-005 | long process: a 10-second job over 10,000 rows reports progress, polls at a bounded rate and shows its result promptly | progress messages advance while the job runs; the result shows Row Count 10000 and the SQL total; the result appears within PERFORMANCE_BUDGET.processResultMs of the job finishing | status is polled no more than once per POLL_INITIAL_MILLIS (1.5 s) and never concurrently | tests/acceptance/fixture/PerformanceFixtures.java prfLongRun | pass (chromium) | [#710](https://github.com/QRun-IO/qqq/issues/710) |
|
||||
|
||||
## 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-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) | [#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) | |
|
||||
| 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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
|
||||
| PRC-012 | VIEW_FORM labels and backend values | Introduction and Status values | | prcComponents | pass (chromium) | [#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) | [#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) | |
|
||||
| PRC-015 | Step help content for process screens | help text shown | | prcComponents helpContent (PROCESS_SCREEN) | pass (chromium) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | user-facing message; internal detail toggle; expanded details meet contrast requirements in light, dark and application themes; simpleThrow "I always throw."; Close returns to the app | | prcFailures, prcComponents, sample simpleThrow | pass (chromium) | |
|
||||
| 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) | [#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) | |
|
||||
| PRC-024 | Linear stepper and step position | all step labels; current step marked; Step i of n | | prcComponents | pass (chromium) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| PRC-035 | Named WIDGET fetched with the process UUID and values | widget text built from process values | | prcWidgets prcHtmlWidget | pass (chromium) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| PRC-040 | Reload mid-process starts a fresh run | new process UUID, first screen, empty inputs; earlier effects kept (SQL) | | prcComponents | pass (chromium) | |
|
||||
| PRC-041 | Expired session mid-process returns to sign-in | 401 redirects to login | nothing saved | prcComponents + expired persona | pass (chromium) | [#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) | |
|
||||
| PRC-043 | Default process values from the link (defaultProcessValues) | sleepMillis preset drives an async sleep to screen1; labName prefilled; normal navigation shows a required sleep duration with a 1000ms default and completes to screen1 | clearing sleep duration blocks submission on screen0 | sample sleepInteractive, prcComponents | pass (chromium) | |
|
||||
| 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) | [#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) | |
|
||||
| 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) | [#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) | |
|
||||
| PRC-048 | Process-field possible values load and search through the v1 API | the process combobox loads its options from the v1 process route; the chosen value reaches the process and persists; a v1 search returns the exact match | an unknown process field is 404 | prcComponents | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| PRC-049 | Processes run on the v1 process routes (upload, step, back step, records, metadata) | a bulk load uploads its file, maps, reviews, steps back (isStepBack=true) and inserts through /qqq/v1/processes/... and /qqq/v1/metaData/process/...; the inserted row is in the database; this session can cancel its own run | an unsafe upload filename is 400 before anything is stored; an unknown run is refused; (another session's run is refused 403 by records and cancel - backend unit tests) | person.bulkInsert, prcComponents | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) [#543](https://github.com/QRun-IO/qqq/issues/543) |
|
||||
| PRC-050 | Processes the instance adds to every query and record screen (Material processNamesToAddToAllQueryAndViewScreens) | v1 /metaData names the process in supplementalInstanceMetaData.materialDashboard (the only supplemental entry v1 publishes; the unversioned route's javalin server configuration stays off v1); casey receives it with no table and in no app (API); the person query Actions menu lists it last, after a divider, and runs it on the selected rows with tableName=person (SQL); the record actions menu of qryBin (no processes of its own) runs it on that record (SQL); the phone record action sheet runs it on that record (SQL) | a user whose metadata omits it (alice): not offered on query or record screens and its run page is not found (no SQL rows); noProcesses persona: absent from the metadata, not offered on query or record screens, and the backend refuses it (403, no SQL rows); a user whose metadata omits the process gets an empty v1 processNamesToAddToAllQueryAndViewScreens list (no hidden process names); the v1 and unversioned init routes refuse the process for a user without permission | ProcessesFixtures.java prcTagRecords (no table, in no app) named by MaterialDashboardInstanceMetaData; a metadata customizer lists it only for the sample user casey, so other areas keep their menus; person, qryBin | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| PRC-051 | Dependent possible values on a process screen: the choices follow the screen's other values | every search posts the screen values as the values map of the v1 JSON body; choices narrow to the chosen Category and change when it changes; the chosen id is recorded (SQL) and its label shows on the result screen | Category empty: the unset variable makes no matches for a form, shown as No options found; the backend refuses an out-of-filter id lookup and returns only the filtered choices | prcSpecimenPick specimenId (prcSpecimen source filtered on category = ${input.category}) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| PRC-058 | Saved bulk load profile menu (Material SavedBulkLoadProfiles): new profile, update after the confirm, rename, save as a copy, delete, unsaved-change count with its list, Reset All Changes, New Bulk Load Profile, Reset to Empty or Suggested Mapping | a new profile is named in the Save New Bulk Load Profile dialog and Enter saves it (SQL row owned by the user); the heading names it; a change shows 1 Unsaved Change with the list of changes; Save asks Update Existing Bulk Load Profile and Enter confirms; the same row holds the new mapping (SQL); Rename keeps the row id under the new name; Save As stores a separate copy, which is then in use; a saved profile's changes are listed (header row, file column) and Reset All Changes restores its mapping; New Bulk Load Profile leaves the profile for an empty mapping; Reset to Suggested Mapping and Empty Mapping replace the mapping, and the suggested one is what the review screen shows | Enter in the delete dialog does not delete (SQL count unchanged); only the Delete button does, and the screen starts a new, empty mapping | enricher person.bulkInsert; SavedBulkLoadProfileMetaDataProvider on owned saved_bulk_load_profile tables | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
|
||||
| PRC-059 | Saved bulk load profile ownership: yours vs shared with you, and only the owner may save, rename or delete a profile | the query process returns both profiles with their owners; the menu lists alice's under Your Saved Bulk Load Profiles and bob's under Bulk Load Profiles Shared with you; on bob's profile Save, Rename and Delete are disabled with the not-owner reason, the changes list says why, and no Save link is offered; Save As is enabled; alice saves her own copy (SQL row owned by sample:alice) | bob's row is unchanged (label, owner and mapping, SQL) and no store or delete request named it; the backend has no ownership lock on saved bulk load profiles, so the UI is the only guard, as in Material | sample users alice and bob; bob's profile stored through storeSavedBulkLoadProfile and shared READ_ONLY with alice in shared_saved_bulk_load_profile | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
|
||||
| PRC-060 | Bulk load file mapping form fidelity: field help, column tooltips, repeated-header warning, header-row toggle re-mapping by header name, scroll to the first error, bulk-edit Key Fields / Fields To Update and the unmapped key field error | help for the header row, layout and table key fields shows under each and describes its control; a mapped column's tooltip names its fields; a repeated header is flagged and not offered as a column; without a header row columns are letters; a mapping to the repeated header is cleared with a warning when headers come back; off and on again the step posts header names (First Name, Last Name, Email) and the first Email column is inserted (SQL); an unmappable submit brings the first field error into view; bulk edit shows Key Fields (Select table key fields to continue.) and Fields To Update (Clear if empty only there); choosing Id names it as an unmapped key field until a column is chosen; the matched records update (SQL) | no step request is sent while a field has no column | enricher person.bulkInsert and person.bulkEditWithFile; ProcessesFixtures.primeBulkLoadHelp help content keyed process:person.bulkInsert;field:hasHeaderRow\|layout and process:person.bulkEditWithFile;field:tableKeyFields | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
|
||||
| PRC-061 | Typed bulk load default values, searchable value mapping and the grouped, searchable Add Fields menu (Material QHierarchyAutoComplete) | the Person default value is chosen by searching person possible values and shows its label; Birth Date takes a date; Is Employed takes Yes or No; the Add Fields menu groups Pet and Pet Note, searches, disables mapped fields with a tooltip and adds the WIDE note field twice (Pet Note: Note (1) and (2)); species values are mapped by searching the species possible values; the pets, their person, species and birth date and both notes each are inserted (SQL) | a default value left empty is refused with A value is required. before any step request | enricher pet.bulkInsert (pet with its notes association, WIDE layout) and person.bulkInsert | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
|
||||
| PRC-062 | Composite INPUT_FIELD metadata editors share process validation and multipart submission | Choose a labeled possible value, edit currency and multiline code, update a widget filter without changing columns, upload BLOB and FILE_UPLOAD data and verify backend-read content; Desktop wheel scrolling over a plain numeric block preserves its value; intentional keyboard edits and touch input changes are returned by the backend process review | Required uploads block submission; code-editor Enter does not submit the process | prcBlockEditors | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
|
||||
| PRC-063 | INPUT_FIELD autofocus and trimmed Enter values on subsequent process screens | Plain and metadata editors receive configured focus, placeholders and previous process values; Enter saves trimmed text verified in SQL; ordinary result screens receive heading focus | First blur does not show premature validation; required whitespace cannot advance either input screen | prcInputFocus | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
|
||||
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | Unchanged seeded timestamps preserve seconds and instant across plain/shared blocks and regular form fields, including the repeated autumn DST hour; Edited timestamps crossing daylight saving submit UTC values verified from backend SQL; TIME inputs in plain/shared blocks and forms retain seconds for unchanged and edited submissions, verified through SQL readback. | Zoned values must not disappear from datetime-local controls or lose their timezone on submission | prcInputDateTimes with browser timezone America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
|
||||
| PRC-065 | Configured Google Drive shared-folder selection and process submission | Select a shared-drive folder and submit ID, name, token and other required input through the real process; independently verify database outcome | Reject a file and retry; cancel a replacement selection clears the destination and token; missing required note cannot advance | prcDrive with owned Google SDK script responses and real configured backend CSP | pass (chromium) | [#704](https://github.com/QRun-IO/qqq/issues/704) |
|
||||
| PRC-066 | INPUT_FIELD case behaviors preserve caret and submitted process values | Backend uppercase/lowercase metadata transforms a middle-of-value edit without moving the caret; Enter and Submit persist exact values verified through independent SQL; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Submit persists the exact transformed text through independent SQL; Unicode case expansion preserves continued typing in STRING and TEXT fields; submitted values are verified through independent SQL; Explicitly configured PASSWORD case behaviors preserve masking and show/hide values; Enter and Submit persist Unicode-expanded values verified through independent SQL | Typing alone does not submit or persist the process; Multiline Enter does not submit or navigate away | prcInputCase / prcTextCase | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
|
||||
|
||||
## 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) | |
|
||||
| QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | Sort matches SQL; Pin right is reflected in the grid; Filter opens the field editor; Hide removes the column | | QueryFixtures.java qryItem | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
|
||||
| QRY-091 | Column menu copies page or full query values in backend sort order | Page copy contains the first ten ids; Full copy contains all eleven ids | | sample carrier (11 rows) | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
|
||||
| QRY-092 | Column chooser searches, groups, counts and marks changed layouts | Search narrows fields; Groups collapse and expand; Hiding a field marks the columns dirty; Keyboard opening focuses the popup and Tab reaches search; Explicit close and Escape restore Columns focus; outside click keeps the clicked control focused | Unknown search shows an empty message | QueryFixtures.java qryItem | pass (chromium) | [#716](https://github.com/QRun-IO/qqq/issues/716) |
|
||||
| QRY-093 | Last saved view and unsaved edits survive reopening and reload | Dirty filter survives reload; Table route opens the last saved view; Reset restores the backend view; Displayed filter updates the URL before a delayed page navigation completes; immediate reload preserves it, along with unrelated parameters, hash and history length | | sample Alice People View | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
|
||||
| QRY-094 | Counted quick views render and open their saved-view route | Count reflects the backend filter; Quick-view button opens the filtered view | | sample Alice People View marked as a quick view in H2 | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
|
||||
| QRY-095 | A new query defaults to fifty rows per page | Fifty visible rows; Pagination reports fifty of the full count | | QueryFixtures.java qryManyRow (1,234 rows) | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
|
||||
| QRY-096 | A saved view reconciles removed fields and warns the user | Obsolete criteria and sort are removed before querying; Warning names the removed field; Heading names the active saved view | | sample Alice People View with retiredField references in H2 | pass (chromium) | [#717](https://github.com/QRun-IO/qqq/issues/717) |
|
||||
| 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; Original Next uniform row backgrounds in light and dark mode, with opaque pinned cells and selection highlighting | Null boolean renders empty, not No; Hidden and heavy (TEXT) fields are not columns | QueryFixtures.java qryItem (8 rows, every field type) | pass (chromium) | [#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) | [#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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| QRY-007 | A failed query reports the backend error with a retry | Backend error text shown; Retry reloads after the failure clears; Query error text and the front error notification meet text contrast in light, dark and configured application themes; Retry repeats the backend request; hover remains readable; Dismiss hides the error; resetting the invalid filter restores SQL-verified records | No rows from the failed request are shown | QueryFixtures.java qryItem | pass (chromium) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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; Touch opens and toggles the density menu without synthetic trigger focus dismissing it; Enter, Escape and outside search focus retain their normal behavior; Pinned selection targets remain exposed and respond at both edges for every density | Subset dialog rejects invalid N | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#693](https://github.com/QRun-IO/qqq/issues/693) |
|
||||
| QRY-065 | Export streams from the v1 export route | the export menu posts {format, filename, fieldNames, filter} to /qqq/v1/table/{table}/export and downloads every row; the v1 route streams the same rows as CSV | a table without the export capability is refused (403) by the v1 route | qryItem, qryLedger | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| QRY-066 | Variant options come from the v1 variants route | the variant picker lists the stores from GET /qqq/v1/table/{table}/variants; the v1 route lists every option with its type and id; a table without variants lists none | an unknown table is refused (403) | qryStock (qryStore options) | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | v1 table metadata carries supplementalMetaData.materialDashboard.gotoFieldNames (API); Go To... on the query toolbar and the record view header offers the primary key, then each key, labelled by field labels; primary key, single-field and two-field keys each open the one matching record (EQUALS query, id from SQL); no match shows "Record not found."; a partial key matching two bins shows "More than 1 record was found..." (counts from SQL); Enter in an input submits its option; Escape closes and returns focus to the Go To button; a table with GET but no QUERY capability opens Go To automatically and it cannot be dismissed | a table without gotoFieldNames has no Go To button on its query or record screen; no navigation on no match or several matches | QueryFixtures.java qryBin (gotoFieldNames [[code], [aisle, shelf]]), qryLocker (GET without QUERY), qryItem (no Go To) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order (Material bySection order) | Headers read Id, Name, Code, Price, Quantity; Rows equal SQL select ordered by id desc | The metadata field-map order (price first) is not used | QueryFixtures.java qryOrdered (qry_item rows; fields declared price, quantity, code, id, name; sections identity [id, name, code], details [price], stock [quantity]) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | Page 5 at 250 per page reads Showing 1,001–1,234 of 1,234; The page shows exactly the last 234 ids by SQL | No unformatted range such as 1001–1234 | QueryFixtures.java qryManyRow (1,234 rows) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | r sends a new query request and shows a value changed through the API (SQL confirms the change); f opens the Advanced Filters panel and a second f keeps it open; n opens the create form | keys typed in quick search are text (no filter panel, no navigation); viewer persona: n does nothing (f still works); the backend refuses the insert (403, SQL unchanged) | sample person | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and return to the query | Bulk Delete of the South store's full query result counts its one record; Every init and step request carries tableVariant {type: qryStore, id: 2}; Return comes back to the variant query, which is empty for South while North keeps its two rows | Without the variant the backend could not read the variant table's records | QueryFixtures.java qryStock (memory backend keyed by qryStore variants) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | First Name quick filter finds the SQL-matching records; Type-specific defaults gate Boolean until chosen, default possible values to any-of and date-time to after; A multi-value chip summarizes extra values; A saved Basic view restores its criterion and SQL rows | No application-specific field names are embedded in the UI; Cancel discards an un-applied quick-filter draft | sample person and QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
|
||||
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | A field offered by metadata appears as a chip after selection and reload; Joined and virtual fields query SQL-backed records | Field choice is scoped to this table | sample person and QueryFixtures.java qryItem/qryCaseItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
|
||||
| QRY-082 | Basic mode cannot simplify repeated or nested advanced conditions and explains why | Repeated Name conditions disable Basic with a focusable reason tooltip; URL criteria become chips; OR criteria open Advanced with a reason | Clicking Basic does not discard either condition | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
|
||||
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | Name and Quantity conditions filter SQL rows; deleting Name in the inline editor preserves Quantity | Removing one condition does not reset the whole query | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
|
||||
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | Name ascending order matches SQL before and after confirmed clear; Condition status explains missing and complete values on keyboard focus | Cancel leaves conditions in place | QueryFixtures.java qryItem | pass (chromium) | [#715](https://github.com/QRun-IO/qqq/issues/715) |
|
||||
| QRY-085 | Weekday criteria use the metadata-enabled date function and ISO weekday values | Selecting a weekday sends WeekdayOfDate and returns the SQL-matching rows; Date-time weekday sends WeekdayOfDateTime with configured UTC arguments and returns matching rows | The weekday list uses names rather than bare numbers | QueryFixtures.java qryItem with dated rows and Material weekday settings; AcceptanceSampleServer.java H2 date functions | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
|
||||
| QRY-086 | Relative date presets expose their evaluated date and filter records | Seven days ago renders a live date and returns the recent SQL-backed rows; A date-time preset retains its relative expression, evaluates on focus and returns recent rows | A relative value remains an expression instead of fixed text | QueryFixtures.java qryItem with relative dates | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
|
||||
| QRY-087 | Bulk pasted values become a multi-value filter | Two pasted record names return the matching SQL-backed rows | The dialog previews each value before adding it | QueryFixtures.java qryItem | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
|
||||
| QRY-088 | Instance query help accompanies the bulk paste dialog | The dialog displays the configured help text | Help text comes from instance metadata | QueryFixtures.java instance-level QUERY_SCREEN help | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
|
||||
| QRY-089 | String filter input honors field case behavior from v1 metadata | Uppercase typing is normalized and queries the lowercase stored code | The normalized value remains visible in the input | QueryFixtures.java qryCaseItem code with TO_LOWER_CASE | pass (chromium) | [#718](https://github.com/QRun-IO/qqq/issues/718) |
|
||||
| QRY-008 | Phone card list: readable records, touch-sized selection, query selections and paging | One card per record with the SQL ids and names, values wrap instead of being cut off; Tapping the 44 px label around a card checkbox selects it without opening the record; A full-query selection checks every card; paging and rows per page work on cards; Tapping a card opens its record | No horizontal page scroll and no control below 44 x 44 px | sample (person, pet, petNote, carrier, petSpecies) carrier (11 rows), 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| QRY-009 | Phone filter sheet fits the viewport, scrolls inside and manages focus | Filter opens a modal sheet that takes focus; Many conditions scroll inside the sheet, which stays inside the viewport; Cards behind the sheet show the filtered records (SQL); Escape or Close closes the sheet and returns focus to Filter, which counts the conditions | No horizontal page scroll and no control below 44 x 44 px in the sheet | QueryFixtures.java qryItem, 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| QRY-024 | Column configuration is reachable and usable by touch on phones and tablets | The panel opens inside the viewport with touch-sized controls; Hiding a column removes it from the cards (phone) or the grid (tablet) and persists; Escape closes the panel and returns focus to its button | The panel never runs off the left edge of a phone | QueryFixtures.java qryItem | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| QRY-036 | Bulk actions launch from a phone card selection | Checked cards show the selection banner; The Actions menu fits the phone; Bulk Edit launches with exactly the checked ids | No horizontal page scroll while the menu is open | QueryFixtures.java qryItem, 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| QRY-055 | Saved views and export menus fit the phone and are touch-sized | The saved views menu lists your view inside the viewport; Escape closes it; The export menu fits the phone and downloads a CSV | No horizontal page scroll and no control below 44 x 44 px in either menu | sample sharing demo (Alice People View), 412 px touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| 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) | [#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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
|
||||
## 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) |
|
||||
| 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) | |
|
||||
| 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) | [#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) | [#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; Overview field rows retain original Next typography and spacing in light and dark themes; a section tab shows only that section; list view shows every section; Arrow/Home/End keys move tab focus; Enter/Space activates a named panel with one tab stop; phone accordion supports keyboard activation | | fieldLab record created in the test | pass (chromium) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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; Full Copy loads the descendant draft on selection; returning to Base Copy preserves the base form; 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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
|
||||
| REC-049 | pages of a table outside the app tree use the table label in the document title and breadcrumbs | create page title is "Create Scheduled Report \| Scheduled Report \| QQQ Sample"; record view title is the record label, then Scheduled Report | the table name (scheduledReport) is not shown in the breadcrumbs | scheduledReport (memory, not in the app tree) for savedReport 1 | pass (chromium) | [#707](https://github.com/QRun-IO/qqq/issues/707) |
|
||||
| REC-051 | Table-field possible values search and resolve through the v1 API | owner search sends a v1 POST with the search term and lists the exact match; ids and labels resolve to the exact option; the chosen owner persists | a source table the persona cannot read is refused (403) by the v1 route; an unknown field is 404 | recordLab | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| REC-050 | Record create, view, edit and delete run on the v1 record routes | the create/edit/delete flow sends POST, GET, PATCH and DELETE to /qqq/v1/table/... and persists each step; record-v1 associations are written and read back with includeAssociations | a missing required field is a 400 with the backend message and nothing is written; a read-only persona is refused (403) insert, update and delete on v1 and the row is unchanged | person, fieldLab, qryHousehold | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| REC-056 | Record view hash links: #audit, #/launchProcess={process} and #{section} anchors | #audit opens the audit dialog; closing it clears the hash and a reload does not reopen it; #/launchProcess=person.bulkEdit replaces the URL with the run for the record (init recordIds=2); Cancel returns to the record; #employmentInfo selects the Employment Info tab and scrolls it into view; a later hash change selects Dates | | sample person, person.bulkEdit | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| REC-057 | Create presets from links: #/createChild= opens a child create dialog over the record; /create#/defaultValues=/disabledFields= prefills and locks | the backend-format #/createChild=pet link on person 3 opens Add Pet with Person preset and disabled; Create closes the dialog, clears the hash, submits personId=3 and SQL shows the pet linked to person 3; /app/pet/create#/defaultValues={name, personId}/disabledFields={personId} prefills both, locks Person and saves them (SQL); /app/pet/create#defaultValues={speciesId} (no slash) prefills Species without locking | locked fields are disabled; unlocked preset fields stay editable | sample person -> pet (personId), petSpecies | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the form's current values | every search posts the form values as the values map of the v1 JSON body; create: choices narrow to the chosen Category and change when it changes; edit: the stored Item label shows, a new Category changes the choices; the chosen id persists (SQL) and the record view shows its label | Category empty: the unset variable makes no matches for a form, shown as No options found; an item outside the filter is not offered, even when searched by name; the backend refuses an out-of-filter id lookup and returns only the filtered choices | recPvPick itemId (recPvItem source filtered on categoryId = ${input.categoryId}) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | developer page for a table keeps its metadata sections and shows the API Docs & Playground section; with no application APIs it shows "This table is not available in any APIs." and no API or Version selectors; OpenAPI download and new-tab buttons remain fully visible before interaction at desktop, phone and 320px widths in light/dark modes; pointer/touch downloads yield the real specification and keyboard navigation opens its URL; specification labels fit wider application font metrics at desktop and phone widths without content clipping | a viewer persona sees the same empty state and cannot fetch any table spec | recordLab has no application APIs; person is exposed through the owned Acceptance API | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script creation | versions list with CURRENT on the current revision and per-file code for the selected version; an older version offers Edit and Activate; save a new version with a commit message (SQL: new script_revision row, its file and script.current_script_revision_id; persists after reload); test run output and log lines equal the backend testScript response; a failing run shows the error the backend returns; logs list the script_log rows of the selected version with their lines; docs show the script type help text and sample code; create a script for an empty associated script field; local QQQ helper and keyword suggestions support keyboard and pointer/touch insertion, native undo/redo, both-theme accessibility, and SQL-verified revision persistence | a viewer cannot save or test a revision (no Edit or Test offered; the backend refuses storeScriptRevision and testScript); a viewer cannot create a script (no Create Script offered; the backend refuses the create route); a table without associated scripts shows none | scriptLab (RecordsFixtures: Alpha with script 101, revisions 101-102, logs 101-102; Beta without a script) on the scripts tables from WidgetsFixtures | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | e, c and n open edit, copy and create with the right values; a opens the audit history; d opens the delete confirmation, Escape keeps the row (SQL), confirming deletes it (SQL); ? lists Create a New Record, Edit/Copy/Delete/Audit the current Record with their keys | no shortcut fires while the audit or keyboard help dialog is open; keys typed in the Jump to input are text; viewer persona: n, e, c, d do nothing (a still works); the backend refuses insert and delete (403, SQL unchanged) | sample person | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| REC-058 | Record view on phones and tablets: the title stays readable, the header controls wrap onto their own row, and the phone action sheet fits, scrolls and keeps focus | The title stays on one line and never overlaps the view toggle, Audit or record actions; neither the page nor its main region scrolls sideways; The back link, value links and header controls are touch-sized (44 x 44 px) under a coarse pointer; The phone Actions sheet fits the screen, takes focus, keeps Tab and Shift+Tab inside, closes by Escape or a backdrop tap with focus back on its trigger, and runs Edit | A read-only user gets no phone Actions trigger (asserted in REC-046) | recordLab 1 (Lab: Alpha); phone (412 px touch), tablet (810 px touch) and desktop viewports | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| REC-059 | Record forms on phones and tablets: every editor type fits without sideways scrolling, controls are touch-sized, possible-value lists open on screen and the phone action bar stays reachable | Create Field Lab: no sideways scroll, every control at least 44 x 44 px by touch; on a phone Save stays on screen; boolean, date, time, text and rich text values persist; Edit Record Lab: the owner list opens inside the screen with touch-sized options; the picked owner persists | | fieldLab create (every field type), recordLab 1 edit (possible values, code editor, password reveal, file uploads) | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
|
||||
| REC-061 | An editable dynamicForm widget on a record form validates its fields and persists them in the declared JSON record field | the region field starts from stored JSON and appears in the record edit form; changing the region persists the new JSON in inputValues and survives reload | clearing the required region blocks the write; the transient region field is absent from the table multipart payload | accWidgetHost 1 with accHostRecordVariables and hidden inputValues field | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
|
||||
| REC-062 | Inline enum possible values filter by label prefix without a lookup request; CHIP adornments style options and chosen IDs persist | v1 metadata exposes the inline enum and the edit form shows the held label; a label-prefix search filters options and the CHIP adornment colors and icons them; choosing High stores HIGH in SQL and shows High after reload | a substring that is not a prefix offers no options; the picker makes no possible-values request | recordLab priority field with an inline enum and CHIP adornment | pass (chromium) | [QRun-IO/qqq-frontend-next#721](https://github.com/QRun-IO/qqq-frontend-next/issues/721) |
|
||||
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | v1 table and field metadata expose the Material supplemental adjuster settings; the table onLoad route supplies a note before render; the name onChange route changes a field label and clears its value; the field rule clears status | a backend lock message disables Save for a preset name | FormAdjusterFixtures.java adjusterLab table with a table onLoad adjuster, name onChange adjuster and clear-target field rule | pass (chromium) | [QRun-IO/qqq-frontend-next#720](https://github.com/QRun-IO/qqq-frontend-next/issues/720) |
|
||||
| REC-064 | Record sections render qualified fields from readable exposed joins | record GET sends a selected LEFT join with qryItemJoinPerson; the joined person's first name appears in the Details section | unreadable join paths are removed from sections and GET descriptors by the same permission filter as query pages | QueryFixtures.java qryItem joins person and shows person.firstName in Details | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
|
||||
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | Selecting South Store shows S-KIWI on the record view and edit/copy forms; Every South record GET includes tableVariant {type: qryStore, id: 2}; Switching to North Store shows N-APPLE for the same record ID with variant id 1 | The duplicate ID must not show North Store's N-APPLE value | QueryFixtures.java qryStock (same record ID in North and South variants) | pass (chromium) | [QRun-IO/qqq-frontend-next#722](https://github.com/QRun-IO/qqq-frontend-next/issues/722) |
|
||||
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | Unrelated edit omits unchanged timestamp; Copy sends the original zoned instant and preserves source/copy SQL timestamps | Copy must not select the first occurrence of a repeated hour or truncate fractional seconds | fieldLab; browser America/New_York; repeated DST hour with subsecond precision | pass (chromium) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
|
||||
|
||||
## 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-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) | [#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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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; paths that normalize to another origin (/.//host, /app/..//host, /app/../\host) land on /app | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) [#696](https://github.com/QRun-IO/qqq/issues/696) |
|
||||
| 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) | [#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 | | a state-matched access_denied shows the provider description once; a state mismatch shows a generic error and 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) | [#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) | [#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) | [#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) | [#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) |
|
||||
| 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) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
|
||||
| SEC-032 | Unsupported authentication type (a type this UI does not know, as a newer backend could declare) is reported, never silently treated as another type | | login shows "Unsupported authentication type: ACCEPTANCE_UNKNOWN" and no data or session is requested | SecurityAcceptanceServer -Dqqq.security.auth=UNSUPPORTED (v1 authentication metadata names type ACCEPTANCE_UNKNOWN; TABLE_BASED is supported since QRun-IO/qqq#700, see SEC-034..SEC-037) | pass (chromium) | [#669](https://github.com/QRun-IO/qqq/issues/669) [#700](https://github.com/QRun-IO/qqq/issues/700) |
|
||||
| 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) | [#674](https://github.com/QRun-IO/qqq/issues/674) [#675](https://github.com/QRun-IO/qqq/issues/675) |
|
||||
| SEC-034 | TABLE_BASED sign-in: a deep link asks for username and password under the app branding, POST /qqq/v1/manageSession with Authorization: Basic creates a stored session, the session user is shown and a reload resumes it | login form shows the logo and app name before any session exists and reads no data; credentials travel once as Basic credentials (password with colons), never in the body or browser storage; lands on the requested page with rows; user menu shows Tess Table (table-based) / tess.table; one session row for the user, matching the sessionUUID cookie; reload keeps it | without the session cookie the backend refuses a read (401) | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
|
||||
| SEC-035 | TABLE_BASED sign-in failure: a wrong password or unknown user is refused with one message, stores no session, and the form recovers | correct credentials after a refusal sign in to the requested page | empty fields are named and nothing is sent; wrong password and unknown user both show "Incorrect username or password."; no session row is stored and no data is read; the password field is cleared | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
|
||||
| SEC-036 | TABLE_BASED session expiry: a session idle past the inactivity timeout gets a 401, the user signs in again and returns to the record they opened | expired mid-use returns to the clicked record after signing in again | the expired session is refused by the backend ("Session is expired.") | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) + /acceptance/expire-table-sessions | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
|
||||
| SEC-037 | TABLE_BASED logout: POST /qqq/v1/logout deletes the stored session and clears both cookies, protected pages stay on the form, and the next user signs in as themselves | a different user signs in on the same browser and is shown as themselves | the ended session cannot be replayed with either cookie (401); after logout a protected page (full load) stays on the form and reads no data | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED + SecurityFixtures.defineTableBased/primeTableBased (users tess.table, ravi.rows) | pass (chromium) | [#700](https://github.com/QRun-IO/qqq/issues/700) |
|
||||
| SEC-038 | Dashboard documents carry a strict Content-Security-Policy (script-src self plus the hash of each inline script, object-src none, base-uri/form-action self, frame-ancestors none), X-Frame-Options DENY, Referrer-Policy and Permissions-Policy | login, token, app, list, record, edit and 404 documents send the policy and headers; the script-src hashes equal the hashes of the inline scripts in each served document; pages hydrate and navigate under the policy with no violation | another web site framing the dashboard gets no content; an injected inline script and inline event handler do not run and are reported as violations | tests/acceptance/fixture/AcceptanceSampleServer.java (javalin mode); with QQQ_ACCEPTANCE_MODE=standalone the container image server (standalone/qqq-server.mjs) | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) [#734](https://github.com/QRun-IO/qqq/issues/734) |
|
||||
| SEC-039 | The policy allows the configured identity provider origin (OAUTH2 discovery, AUTH_0 browser token exchange) and no other connection target | OAUTH2: connect-src is self plus the provider origin; sign-in completes with no violation; AUTH_0: connect-src is self plus the Auth0 domain; the browser token exchange completes with no violation | a fetch to any other origin is refused and reported (connect-src) | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2\|AUTH_0 | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) |
|
||||
| SEC-040 | Application override hook and metadata-derived origins: the application adds its own origins (QApplicationJavalinServer.withNextDashboardSecurityHeadersCustomizer); QuickSight and custom component bundle origins come from widget metadata | the QuickSight embed, block image and audio from the loopback service and the custom component bundle load with no violation; the policy lists exactly the configured origins | a frame from an origin the application did not configure is refused and reported (frame-src) | tests/acceptance/fixture/WidgetsFixtures.java loopback service | pass (chromium) | [#695](https://github.com/QRun-IO/qqq/issues/695) [#734](https://github.com/QRun-IO/qqq/issues/734) |
|
||||
| SEC-041 | The login page explains known sign-in error codes and never displays text taken from the link | direct links with access_denied and other known codes show their safe message; other well-formed codes show the code; Try again signs in through the provider | free text in ?error= is replaced by a generic message | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
|
||||
| SEC-042 | Rendered HTML (field values, widgets, process steps, help, banners) cannot add forms, form controls, style sheets or page-covering positioning | formatting in a stored HTML value is kept; the stored value is unchanged in the database | a stored form with a password input, a style element and a position:fixed overlay render as plain content | tests/acceptance/fixture/RecordsFixtures.java fieldLab.htmlValue | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
|
||||
| SEC-043 | Per-user browser data (recently viewed records, stored identity) is bound to the signed-in identity | the same user keeps their recently viewed records across reloads | a different user signing in on the same browser without a logout does not see the previous user's recent records | tests/acceptance/fixture/AcceptanceSampleServer.java personas and sample users | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
|
||||
| SEC-044 | Session cookies (sessionUUID, sessionId) are SameSite=Lax for the whole site, and Secure when the request arrived over HTTPS | manageSession and API responses set the cookies with Path=/ and SameSite=Lax; the browser stores them as Lax | over plain HTTP the cookies are not Secure (sign-in keeps working); behind X-Forwarded-Proto https they are | tests/acceptance/fixture/AcceptanceSampleServer.java | pass (chromium) | [#696](https://github.com/QRun-IO/qqq/issues/696) |
|
||||
| SEC-048 | Session cookies (sessionUUID, sessionId) are HttpOnly: script in the page cannot read them, and resuming a session from the cookie never hands its token to script | after sign-in (MOCK, TABLE_BASED, OAUTH2) the browser stores both cookies HttpOnly, naming the current session; a reload resumes the session through POST /qqq/v1/manageSession with an empty body; the response carries the session values and no uuid | document.cookie never shows sessionUUID or sessionId; an in-page POST /qqq/v1/manageSession {} (what injected script could send) answers without the session token | tests/acceptance/fixture/AcceptanceSampleServer.java (MOCK); SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED and OAUTH2 with tests/acceptance/support/fake-oidc.ts | pass (chromium) | [#733](https://github.com/QRun-IO/qqq/issues/733) |
|
||||
| SEC-049 | With HttpOnly session cookies the backend ends and replaces sessions: logout expires both cookies, an idle-expired or revoked session sends the user to sign in again, and a new password or OAuth2 sign-in points both cookies at the new session | TABLE_BASED: a first visit shows the form without a session request; an idle-expired session is refused, the form returns without a resume attempt, and signing in again returns to the record; OAUTH2: a session revoked on the server sends the user through the provider and back, with new HttpOnly cookies; logout: the server deletes the session and expires both cookies; a protected page stays signed out without a session request | a logout that never reached the server leaves the earlier user's cookies; the next user's sign-in replaces both, and a reload keeps the new user | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED and OAUTH2 with tests/acceptance/support/fake-oidc.ts | pass (chromium) | [#733](https://github.com/QRun-IO/qqq/issues/733) |
|
||||
| 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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | |
|
||||
| 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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | |
|
||||
| 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) | [#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-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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| WID-009 | lineChart widget: line with a point per label and description | points carry exact values | | sample SampleWidgetsDashboard/SampleLineChartWidget | pass (chromium) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-029 | pivotTableSetup widget shows, edits and clears the saved report's pivot definition | rows/values labels from the source table; modal OK updates only the host draft; host Save persists rows, columns and aggregate values with SQL readback and reopen; keyboard row reorder and value move preserve saved configuration; turning the pivot off clears the stored definition on host Save | report without pivot shows message; missing row fields and aggregate functions keep the editor open; Cancel discards added fields without changing the database; used group/value fields are excluded from choices; changing a numeric value to text clears an incompatible aggregate | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned pivot saved report | pass (chromium) | [#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) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
|
||||
| WID-070 | filterAndColumnsSetup edits a saved report's filter, sort, columns and variable values through the host form | Edit Filters and Columns opens the report setup dialog; OK writes queryFilterJson and columnsJson to the host form, and Save persists a changed sort to the backend; assigning a filter variable persists FilterVariableExpression in the backend JSON and shows ${VARIABLE} in the editor; import a saved view, reset changes to that view or reset to a new default draft without updating the saved view | Cancel discards the draft; OK without host Save leaves the backend row unchanged; preview and Open in new window block a query while a filter variable lacks a value; resetting and cancelling leave both the report and all saved views unchanged | prime-sharing savedReport 1 (Pet Species Report, no pivot table); saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
|
||||
| WID-071 | a WIDGET-adorned process form field renders filter setup and writes its own value | the widget request names the adorned field, the editor updates its filter, and the next process screen receives the JSON | other widget output fields are not written to the adorned field's host form | ProcessesFixtures.java prcFieldWidget with a queryFilterJson WIDGET adornment | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
|
||||
| WID-072 | API-versioned report setup loads table metadata and previews through the application API | the report editor loads person metadata and its preview rows and count through the real API-aware v1 routes | an unsupported API version returns 404 | ApiVersionFixtures.java acceptanceApi 2026.Q3; savedReport 102; saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
|
||||
| WID-073 | report setup editor uses the full server-backed grid for paging, sorting and column sizing | grid preview hides selection, changing page size changes the real backend query, header sorting changes the real backend query, and keyboard column resize persists in saved columns JSON; create a report from a query, switch to Audit, save and reopen, verify stored columns and download the CSV; filtered column statistics totals and exported value counts match SQL; closing statistics and cancelling the editor leave saved data unchanged; a table without COUNT still shows real preview rows and page copy matches SQL; variant-backed report previews choose, switch and remember the backend variant for query/count, copy and statistics on ordinary and application-API routes; complete nested criteria mark preview column headers; opening the indicator edits the existing condition and real preview rows match SQL; configured omitted joins disappear from new preview column/filter choices while saved joined values, editable criteria and column positions survive; changed results and reload match SQL; editor Cancel, Basic/Advanced, OK, host Save and reopen preserve joined criteria, sorts, order and width; hidePreview hides only the read-only summary; the editor retains real query rows, sorting and Refresh; Cancel, OK, host Save and reopen preserve the intended sort with the flag enabled and disabled; column menus fit 320px and 720px windows; keyboard End/Escape restores focus and pointer Copy/full-query and sorting remain reachable | selection pseudo-column and incompatible previous-table fields are excluded from saved reports; full-column copy refuses results over the configured limit; an empty preview offers no column copy action; cancellation preserves the saved report; no query before selecting a required variant; an old variant copy response cannot overwrite a later copy; variant changes do not persist report edits; incomplete criteria do not mark columns; opening an indicator adds no condition; read-only preview edits and editor Cancel leave stored report JSON unchanged | savedReport 1 targeting petSpecies without a pivot; saved-reports-sharing.spec.ts | pass (chromium) | [#722](https://github.com/QRun-IO/qqq/issues/722) |
|
||||
| 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) | [#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) | [#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) |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | [#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; Unmapped legacy icon names render local glyphs in light/dark desktop/mobile modes; metadata navigation and main tiles preserve color, sizing, existing Lucide mappings and same-origin font delivery | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks and accLegacyIcons | pass (chromium) | [#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) | [#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) | [#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) | |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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; Standard report inputs retain compact widths and an inline Generate Report action without a process header | empty required input blocks submit | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonInputReport | pass (chromium) | [#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) | [#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) | [#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) | [#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) | [#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) | [#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; Add new on the saved report opens the Add Scheduled Report dialog over it with Saved Report preset and locked (Material #/createChild=); Create submits savedReportId=1, closes the dialog and reloads the child list | invalid cron expression rejected with message | scheduledReport (memory) for savedReport 1 | pass (chromium) | [#673](https://github.com/QRun-IO/qqq/issues/673) [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| 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) | [#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) | [#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) | [#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) | [#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) | [#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) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
|
||||
| WID-064 | cronUI schedule editor on record forms: Basic days/hours/minutes, Advanced expression, live description, validation, time zone | Basic mode builds weekdays, hours and minutes; the live description equals the cronDescription QQQ stores; Basic mode builds selected dates, Clear resets the schedule, and the Advanced hint names the part under the keyboard caret; Advanced mode edits the stored expression; the live description equals the backend widget description; expression and time zone persist (SQL); an expression Basic mode cannot show opens Advanced with Basic disabled and the reason | an out-of-range expression shows the error live and marks the input invalid; saving without a schedule shows the required error in the editor, focuses it, and saves nothing | scheduledReport (memory) for savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostCron with a time-zone enum possible-value source | pass (chromium) | [#702](https://github.com/QRun-IO/qqq/issues/702) |
|
||||
| WID-065 | record view mounts only the active layout, so each section widget mounts and loads once | desktop tabs: every section widget is in the DOM once and requested once; no accordion is mounted; phone accordion: only open sections mount; each widget is requested once, on opening | the desktop tab layout is not mounted at phone width | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost | pass (chromium) | [#706](https://github.com/QRun-IO/qqq/issues/706) |
|
||||
| WID-066 | Widget metadata and widget data come from the v1 API | v1 /metaData lists permitted widgets with hasPermission and the reports map; the dashboard renders its widgets from POST /qqq/v1/widget/{name} and makes no legacy metadata or widget request | a denied widget is absent from v1 metadata, never requested, and refused (403) by POST /qqq/v1/widget without rendering | widgetPermissions (accHealthy, accDenied), accPersonReport | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| WID-067 | a widget whose payload is a single leaf block (Material block widget) renders that block | The backend payload is the TEXT block itself (blockTypeName TEXT, no blocks list); The widget shows the block's text as a TEXT block | No empty COMPOSITE container is rendered in its place | WidgetsFixtures.java accLeafBlock (COMPOSITE widget whose renderer returns a TextBlockData) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| WID-068 | table widget Material extras: typed cells (numbers with separators, html, htmlAndTooltip, image, composite), hidden helper columns, fr widths, sub-rows, sticky header, column-header help, paging (10 by default, numbered pages, jump-to-page, entries-per-page), export built from columns and rows, and a multi-table widget with an export button and footer per table | Default cells show numbers with thousands separators; html cells render sanitized markup; an htmlAndTooltip cell shows the row's tooltip HTML on hover, focus or tap; an image cell shows the row's image, label and total; a composite cell renders its blocks; Columns of type hidden (tooltip, image helpers) are not shown; fr widths share the width left by fixed widths (2fr is twice 1fr; a 180px column keeps 180px); A row with sub-rows expands and collapses them (nested rows are shaded; a sub-row expands its own sub-rows); The columnHeader=name help slot shows its HTML help on the Name header; Without rowsPerPage 10 of 40 rows show, with numbered page buttons; the entries-per-page select (5-25) changes the page size; more than six pages become a jump-to-page input; the header stays at the top while the fixed-height body scrolls; Export without csvData downloads the Material CSV of every column (hidden ones too), quoted, with link text and without icon glyph text; Each table of a multi-table widget has its own label, export button (csvData or columns and rows) and footer; the widget itself has no export button | Icon glyph text (open_in_new) never reaches the CSV; Collapsed sub-rows are not rendered; A sub-table's footer is not shown under another sub-table | WidgetTableChartFixtures.java widgetTableCharts/accTableCells, accTablePaging, accMultiTableExtras | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
|
||||
| WID-069 | chart widget Material extras: theme color names, legend toggles, single-series legends (horizontal bars, line badges), full pie with percent tooltip, stacked backgroundColor, stacked tooltip and right integer axis, rotating category labels, small line y ticks and grid, bar chart As of line | Pie slices named info and success fill with the Material theme colors; the pie is full (no inner radius); Hovering a slice shows its label, value and percent of the total; Clicking a legend entry hides that slice or series and clicking again shows it; Stacked datasets fill from their singular backgroundColor (a theme name or a CSS color); the tooltip names only the hovered dataset; the y axis sits on the right with whole-number ticks; long category labels turn; A single-series horizontal bar chart has a legend; a line chart names its series in badges above the chart; A small line chart shows y-axis values and a horizontal grid; A barChart widget ends with an As of line naming today's date | A hidden series draws no bars or points; No y-axis tick of the stacked chart is fractional | WidgetTableChartFixtures.java widgetTableCharts/accPieNamed, accStackedExtras, accLineBadges, accHorizontalLegend, accSmallLineTicks, accBarAsOf | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
|
||||
| RPT-019 | render report input step shows only its fields; a report without variables adds no no-fields message | Report Format, Email To and Email Subject are shown; the values widget renders nothing | no "No fields" text on the input step | savedReport 1 (Pet Species Report, no variables) | pass (chromium) | [#707](https://github.com/QRun-IO/qqq/issues/707) |
|
||||
| RPT-020 | Report files download from the v1 download and report routes | a process report links its file at /qqq/v1/download/... and the file has every row; a report without a process streams from /qqq/v1/reports/{name}?format=csv with every row | an unregistered server file or storage reference is refused (403); a restricted report is 403, an unknown one 404, a missing format 400 | accPersonReport, accStreamedReport, accRestrictedReport | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
|
||||
| WID-074 | Standalone composite scalar controls and metadata editors | Seeded text, number, date, boolean and native file controls; currency prefix, named possible values and multiline code editor; Choice width and center-click behavior with system and monospace fonts, including a 320px viewport; Backend uppercase/lowercase metadata preserves middle-of-value edits and caret position in standalone blocks; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Desktop wheel scrolling preserves plain and shared numeric block values; intentional ArrowUp edits still work and text inputs retain focus; Native standalone TIME controls increment by one second and retain second-level values. | Code-editor Enter leaves the widget on the same page; Multiline Enter does not submit or navigate away | widgetInputEditors / accTypedInputs | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
|
||||
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | UTC timestamp displays the correct local date, time and seconds | Do not slice UTC digits into a local-time field | accTypedInputs in America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,554 @@
|
||||
# Next UI visual review
|
||||
|
||||
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
|
||||
|
||||
Status: in progress, September 30, 2026. This review is a release requirement. Captured screenshots are not approvals or evidence that a surface has passed review.
|
||||
|
||||
The visual reference is the working Next UI before the September 26 parity integrations, commit `e42ad2b2bcdc76311e13002a22dd70e3ec437192`. Material Dashboard remains the reference for supported workflows and metadata contracts. The user's September 27–28 correction requires preserving the earlier Next design while completing those workflows.
|
||||
|
||||
## Latest checkpoint — September 30, phone process states
|
||||
|
||||
Compared original Next `e42ad2b2bcdc76311e13002a22dd70e3ec437192` with the packaged RC8 candidate `f0f0eae98e4b6c883475956a7c59e060d2e5f0a5`, using the same fresh fixture classes and backend. All 24 phone images were inspected: light/dark, original/current, bulk-load mapping at four scroll positions, process progress and expanded internal error. All requested scroll positions were reached, with no setup errors, page errors or document horizontal overflow. Evidence: `rc8-phone-process-review/` manifests and review.
|
||||
|
||||
Progress and expanded-error composition retain the original arrangement. Bulk-load field cards and footer retain their arrangement, but saved-profile/reset controls, metadata help and larger touch targets make the upper section taller. Grouped Add Fields and the red removal icon also differ from the original. These are recorded differences, not owner approval. Header breadcrumb crowding remains open. This checkpoint covers these phone states only; whole-product visual and interaction approval remain open.
|
||||
|
||||
## Earlier checkpoint — September 30, reachable column menus
|
||||
|
||||
VIS-031: column menus were not constrained to available viewport height. A browser probe on the report preview measured a 352px menu starting at y=-147 in a 320px window in both Chromium and WebKit. The shared menu now uses Radix's available-height limit, an 8px collision margin and vertical scrolling, matching existing query menus. Its labels, ordering, typography and normal-height presentation are preserved.
|
||||
|
||||
The corrected regression first failed at 320px and passed at 720px in both engines. All 28 final Chromium/WebKit checks pass, including bounded placement, End/Escape and focus return, pointer Copy, sorting, standalone column actions and report variant workflows. Four final menu screenshots were directly inspected. Seven grid component tests, production export, types/lint/license and bundle budgets pass. Evidence: `column-menu-{probe.json,corrected-red-report.json,green-report.json,green-artifacts,unit.log,build.log,budget.log}` under local visual-review evidence. The first regression incorrectly expected a statistics action absent from Person metadata; that test assumption was corrected before the valid red run. No product change addressed that test-only error.
|
||||
|
||||
Hosted WebKit run36739388053 failed WID-073 before copying: the trace repeatedly reports Copy full query values outside the viewport. Its report and trace are retained in `row-appearance-ci-webkit/`. The exact workflow passed locally before the change, so this reproduced short-window defect does not by itself prove that hosted failure resolved. A fresh hosted result is still required.
|
||||
|
||||
The prior combined table, record-spacing and report hidePreview source `a8e4d3b` separately passed **583 Chromium tests/all 391 required rows**, zero failures/skips/flaky results and three approved external-service exclusions. That full run predates this menu correction; evidence is `report-preview-full-chromium/`. RC4 remains the published version; final 1.0 and whole-product visual approval remain held.
|
||||
|
||||
## Earlier checkpoint — September 30, forms, processes and dark widgets
|
||||
|
||||
At post-RC4 source `9fb613d`, inspected all 88 images from 64 desktop entries for 16 form/process states in both themes. These include Create, validation, Edit, Copy, Field Lab, process components/help/validation/review, route details/validation/confirmation, cancellation and widget results. All entries have export fingerprints, no setup/page errors and no document overflow. Layouts retain the original Next composition. The broken image in Lab briefing is the deliberate script/onerror sanitization fixture, not a newly broken image feature. A focused axe scan of the default process stepper reports no color-contrast violations or incomplete checks in either theme; its colors were not changed. Evidence: `post-rc4-form-process-review/`. This does not cover all process interactions, bulk-load/progress/error variants or responsive states.
|
||||
|
||||
Also inspected 22 fresh dark desktop images (eight entries) covering the full scrolled Widget Gallery, Blocks, Parents, and Tables and Charts pages. Every requested scroll reached its target; no setup/page errors or document overflow occurred. The existing Next card composition remains. Changes include functional maps, typed table cells, chart colors/legends, supplied footer metadata and native dark audio controls. No new visual regression was found in this inventory. This closes the current dark desktop lower-page gap for these four pages only. Evidence: `post-rc4-widget-review/` (11 directly inspected paired sheets). Other themes, responsive layouts, interactive widget states and whole-product approval remain open.
|
||||
|
||||
## September 30 — report summary preview flag compatibility
|
||||
|
||||
Source comparison found that Material's `hidePreview` hides the read-only summary preview but leaves the editing query available (`FilterAndColumnsSetupWidget.tsx:483-526`). Next incorrectly applied the flag inside the editor too. The bounded correction retains the existing Next editor and scopes this flag to the summary. It is not in published RC4.
|
||||
|
||||
A real-backend regression failed for the hidden-summary case while the ordinary case passed. After correction, eight Chromium report scenarios pass with zero failures/skips/flaky results, covering hidden/shown summaries, editor sorting/Refresh, Cancel, OK, host Save/reopen, query permissions, filter indicators and saved omitted joins. All 1,884 units, production export, types, changed-file lint and license checks pass. Four fresh editor images were directly inspected (both themes, flag enabled/disabled); dialog and preview bounds are identical, with no page errors or document overflow. Evidence: `report-preview-{red,green}-report.json`, `report-preview-{unit,build}.log`, and `report-preview-fixed-review/`. Broader embedded-query equivalence is still open.
|
||||
|
||||
## Earlier checkpoint — September 30, record detail review
|
||||
|
||||
Captured and inspected six desktop record states in light and dark themes against the original Next: small record, Person, selected section with keyboard focus, the field/adornment lab (including its lower scroll position), empty Audit and Delete confirmation. The 24 manifest entries contain 28 images, with no setup/page errors or document overflow. Evidence: `post-rc4-record-review/` under the local visual-review directory. Card placement and dialog composition are retained; added audit sort controls, code expansion and widget chrome remain visible. These captures do not approve populated audits, every field interaction or responsive record states.
|
||||
|
||||
Found VIS-030: overview field values inherit 16px/24px typography after the metadata grid migration, increasing plain rows from the original 42px to 46px. Browser measurements on both exports establish the difference (`spacing-probe.json`). Restored the original overview line height while retaining metadata widths and the selected-section layout. Both new Chromium cases failed at 24px versus 20px before the change; all nine affected record workflows pass after it. Fresh measurement matches the original five Person field heights exactly (42px for plain values, 43px for the boolean badge). All 1,884 unit tests, production export/types and changed-file lint pass. Eight fresh after-fix entries (ten images including the lab scroll states) were inspected in both themes with no setup/page errors or document overflow; evidence is `record-spacing-fixed-review/`. The initial browser launch used an inactive default backend port and is retained separately from the valid regression run. The correction is not in published RC4.
|
||||
|
||||
## Earlier checkpoint — September 30, query rows and filter review
|
||||
|
||||
Compared 14 desktop query/filter states in both themes between the original Next export and the published RC4 JAR: 56 entries, no unexpected setup/page errors or document overflow. The retained baseline export fingerprint matches the earlier review. Inspected table, Basic/Advanced, populated/nested filter, quick editor, columns, column-menu, relative-date, density, selection, Views and Export captures. Three added controls have no equivalent original state and are marked accordingly. Evidence: `rc4-query-review/` under the local visual-review directory. This is a desktop subset, not whole-query or whole-product approval.
|
||||
|
||||
Found VIS-029: unconditional alternating row shading added by parity commit `4c7918f` changes the original Next table, especially in dark mode. Restored the original uniform default while retaining selected-row highlighting, opaque pinned cells, theme overrides and the `data-row-parity` customization hook. The correction is on the post-RC4 branch and is not in published RC4. Both new rendered-color tests failed against unchanged RC4, then passed on the corrected export. All 1,884 unit tests, production export/types, changed-file lint and 13 existing Chromium theme/density/pinning workflows passed.
|
||||
|
||||
The first browser attempt had a stopped server; a subsequent temporary symlink was not discoverable by the backend classpath scanner. Those setup failures are retained separately. The new pinned-cell test initially matched nested transparent text spans as well as table cells; correcting its selector produced two passing light/dark cases without further product changes. Corrected-build screenshots from that selector-failure run were inspected and show uniform rows with opaque pinned cells; their origin remains explicit. Fresh corrected-export captures are now complete and inspected for the small table, Person table, populated filters and selected rows in both themes (eight entries). Their manifests contain no setup or page errors. Evidence: `row-appearance-fixed-review/` under the local visual-review directory. Broader visual review remains open.
|
||||
|
||||
## Earlier checkpoint — September 29, report omitted-join preservation
|
||||
|
||||
Inspected the corrected report editor's Filters and sort and Columns tabs in desktop light and dark mode. The existing dialog, tabs, typed filter controls, sort controls, column picker, preview grid and footer remain in use. Saved joined fields remain labelled and editable even when omitted from new choices; the preview still shows their values. This inspection covers the affected state and does not approve the whole report family or the broader original-Next comparison.
|
||||
|
||||
Four captures and geometry/runtime results are retained in the local visual-review directory: `report-editor-omission-{light,dark}.png`, `report-editor-omission-columns-{light,dark}.png`, and `report-editor-omission-visual-result.json`. At 1440×1000 the dialog is 1376×936 with 32px margins in both themes, no document overflow and no page errors. The Columns tab keeps its existing internal scrolling. The isolated visual server used a separate sample database and was stopped after capture; owner previews were untouched.
|
||||
|
||||
The destructive open-and-OK regression was observed failing before the correction. Current verification: 1,884 unit tests, production export, types, affected lint and three real-backend Chromium report workflows pass; independent review found no further defects. The full current Chromium gate passed 575 tests and all 391 required rows, with zero failures, skips or flaky results. RC3 does not include this follow-up.
|
||||
|
||||
## Earlier checkpoint — September 29, script suggestions
|
||||
|
||||
Added caret-adjacent suggestions inside the existing script editor: QQQ API/logger helpers, language keywords and file identifiers. The parent dialog, textarea, split panes and syntax overlay are retained. Inspected desktop light/dark captures under `test-results/acceptance/script-completion-evidence`; list bounds and both-theme axe checks pass across all five profiles. This focused review does not approve the whole developer-tools family.
|
||||
|
||||
The new interaction passes 1,840 unit tests, types, affected lint, license, static export and bundle budgets; 15 Chromium developer/script workflows and 10 focused cases across all five profiles. Native WebKit groups consecutive edits differently from Chromium: the initial cross-browser assertion failed after a fill plus completion, and a plain-textarea probe reproduced it. Retained failures; no production undo workaround or retry was added. The replacement check starts from a loaded revision and verifies native undo and redo, then separately tests typed-prefix completion and SQL-backed save/reload. Source/caret validation and linear prefix scanning correct both independent review findings.
|
||||
|
||||
Baseline full Chromium at 95847c2 is now terminal: 556 passed, all 390 required rows, no failures/skips/flaky tests. Backend PR #913 is merged. The broad original-Next visual review and final release gates remain open.
|
||||
|
||||
## Earlier checkpoint — September 29, report filter indicators and API cache correction
|
||||
|
||||
Report previews now feed complete nested criteria into the shared grid’s existing filtered-column indicator. Clicking it opens the current filter without adding a condition. The read-only preview keeps edits local. Inspected the real-backend desktop light editor and read-only captures in `report-filter-indicator-final-artifacts`; the existing grid indicator and layouts are reused. The captured editor is scrolled to its active condition. This is a focused inspection, not whole-family approval.
|
||||
|
||||
The previous full local Chromium export (303ba61) passed **554 tests**,390 required rows,zero failed/skipped/flaky. The subsequent indicator change passes **1,828 unit tests**,types,affected lint,license,static production export,bundle budget and **30 real-backend Chromium report workflows**. The first focused browser run mistakenly served the previous static export after a standalone build; its missing-indicator failures are retained separately and are not evidence about the corrected artifact. The proper static export was then rebuilt and verified.
|
||||
|
||||
Remote eeab664 CI is terminal: Chromium549pass; Firefox WID-070 widget-loading timeout; WebKit NAV-023 internal navigation error; touch WID-072 application-API500,NAV-034 redirect timeout,REL-005 internal navigation error. Logs and traces are retained. The API500 was reproduced as a concurrent static metadata-cache race in QQQ; backend62461e163 fixes atomic initialization and endpoint-test fixture isolation, with110module tests and coverage passing. PR913 remains pending new CI/merge. All40 focused rechecks of the failed workflows pass across Firefox,WebKit,phone and tablet on the corrected export/backend. The original failures remain recorded; a focused recheck does not establish that all navigation and loading failures are resolved. Full final-source/browser-matrix verification and the original Next visual review remain open.
|
||||
|
||||
## Earlier checkpoint — September 29, report backend variants
|
||||
|
||||
Report previews now reuse the existing Next variant picker for tables that require a store/tenant selection. No query runs before selection; switching starts a fresh preview page and scopes query/count, copy and statistics. The picker works nested inside the report editor. Light/dark desktop captures show it above the parent dialog and the selected variant above the preview, with no horizontal overflow or page errors. No changes were needed to the shared picker's layout or stacking. Captures: `report-variant-picker-{light,dark}.png`, `report-variant-{light,dark}.png` and `report-variant-visual-result.json` in the local visual-review directory. This does not approve the full report family.
|
||||
|
||||
Depth verification passes 35 real-backend Chromium scenarios, all 1,826 units, production build, types, affected lint, license and bundle budget. Both ordinary and application-API previews are covered. A review-found delayed-copy race was reproduced then corrected: a response from the previous variant cannot overwrite a later clipboard copy. Independent frontend/backend reviews have no remaining findings in this scope.
|
||||
|
||||
The application-API browser run exposed a backend defect: its query/count executors ignored the received variant. QQQ PR #913 applies the ordinary endpoints' session handling. Two HTTP regressions failed then passed; all 109 API middleware tests and coverage pass. Backend build succeeds under its configured static-analysis gates, with warnings. The 35 browser checks used that corrected local middleware JAR; PR CI/merge is pending. Full embedded-query parity, original-Next visual review and release acceptance remain open.
|
||||
|
||||
## Earlier checkpoint — September 29, report saved-view resets
|
||||
|
||||
Report setup now exposes Reset Changes and Reset to New View beside the selected saved view. The first restores the imported view; the second starts a default draft. Both discard unapplied Basic quick-filter values, and neither updates the saved view or report. The stale quick-filter regression failed for both reset actions before the correction, then passed. Independent review confirmed the correction with no remaining findings in this scope.
|
||||
|
||||
The rebuilt export passes all 25 report/sharing Chromium scenarios (zero skips/flaky tests), 1,823 unit tests, types, affected lint, license headers and the bundle budget (1,035.8 KB / 1,050 KB). New depth cases verify copy limits, missing COUNT capability and empty preview behavior against the real backend, with persisted report and saved-view comparisons. This is targeted verification, not complete embedded-query equivalence.
|
||||
|
||||
Inspected desktop light/dark captures show the reset links beside the view selector, with readable controls, no dialog horizontal overflow and no page errors. Existing dialog, filter and preview composition is preserved. Evidence: `report-reset-{light,dark}.png`, `report-reset-visual-result.json`, `report-reset-chromium.log` and `report-reset-final-units.log` in the local visual-review directory. The broader original-Next comparison remains open; no complete screen family is approved.
|
||||
|
||||
## Earlier checkpoint — September 29, process details and RC1
|
||||
|
||||
Fresh Chromium desktop comparisons cover process widgets, results, collapsed internal errors and expanded error details in light and dark modes: 16 original/current captures, with no setup/runtime errors or document overflow. The inspected widget, result and expanded-error pairs preserve the original Next card and action composition. Reload controls remain compact; desktop widget content is slightly shorter than the reference. These checks do not approve the complete process family.
|
||||
|
||||
The expanded-detail review led to **VIS-028**, a reproduced accessibility defect in the application-theme configuration: its orange error color yielded 2.8:1 contrast for 12px text. Default light/dark passed. Process details now use the same derived dark/light error-text shades as the query error panel. Text size, position and disclosure behavior are unchanged. The three-theme regression first failed on the application theme, then passed; nine real-backend Chromium error/retry/completion cases and all 1,806 units pass. Build, types and affected lint pass. Evidence: `process-detail-contrast-{red,green,build,unit}.log` and `rc1-process-visual-{light,dark}.log` in the local visual-review directory.
|
||||
|
||||
RC1 preparation separately passed the full 544-case Chromium gate (before this one-line contrast correction), clean JAR verification, dependency audit and focused checks. RC1 was subsequently published from `e901df9` on September 29, with Maven Central and GHCR artifact verification recorded in the release notes. See the [RC1 notes](../releases/1.0.0-RC.1.md). The original Next visual review and all remaining Material behavior parity gaps remain final 1.0 work.
|
||||
|
||||
## Earlier checkpoint — September 29, appearance preferences
|
||||
|
||||
The user-facing Preferences dialog now exposes Light and Dark choices using native radio controls within its existing layout. The choice applies immediately and persists under the existing `qqq-dark-mode` browser/origin key; a fresh profile remains light. An application theme still requires light mode, now with a visible explanation and disabled choices. The saved preference is retained under that override; Reset to Defaults clears it to light along with table and record defaults. No system-following or account synchronization is claimed.
|
||||
|
||||
The initial two integration tests failed because the controls were absent. The completed change passes **1,803 unit tests** and **15 real-backend browser cases across all five profiles** (NAV-056 appearance and NAV-038 shell touch/focus). The browser audit initially caught transitional colors while switching palettes; it now waits for actual CSS animation completion before checking the whole visible dialog against WCAG A/AA rules. A subsequent tablet diagnostic caught a metadata request interrupted by the test's immediate reload; the scenario now waits for the application theme to load before reloading. Failure logs/traces are retained under `appearance-transition-*` and `appearance-reload-*`. Build, types, changed-file lint, license and bundle budget pass (1,034.5 KB / 1,050 KB).
|
||||
|
||||
Refreshed original/current Preferences captures in both themes at desktop, tablet and phone sizes, including the dialog's own scroll region. Inspected current light desktop/phone top and dark phone bottom: controls remain within the viewport, the footer remains available, and lower layout options are reachable by scrolling. The screenshot tool was corrected to target the Preferences scroll area rather than the phone navigation drawer. Current export fingerprint: `d87518fddea70bd830c3fb10ea3ebaae41410feca77f6b6df2daf56a24370c1a`. The gallery still has 106 light / 87 dark states per viewport; this refresh adds no new state IDs. No whole screen family is approved. Full remote acceptance on the previous pushed head is still running; this targeted result is not release acceptance.
|
||||
|
||||
## Earlier checkpoint — September 29, widget width and acceptance setup
|
||||
|
||||
The remote phone failure reproduced with a wider native font: the standalone choice stayed 224px while its neighboring scalar input measured 230px. The original width assertion is retained. The metadata-editor minimum is now `min(15rem, 100%)`: 240px where available, capped to 230px at the tested 320px phone viewport. A browser-only trial preceded the one-line source correction; the final export reproduces the trial without injected styles. System, Arial and monospace measurements and 320/393/810/1440px viewports show no document overflow. Plain scalar controls are unchanged.
|
||||
|
||||
The Firefox dashboard trace showed its page-opening hook ran before the owned backend session and diagnostics existed. A pre-navigation session assertion failed locally. The hook now initializes those fixtures first and verifies the owned session cookie; metadata and widget reads start after the reset. This corrects a definite fixture precondition. The original 17-second render delay is not claimed independently explained; full CI must verify that it does not recur.
|
||||
|
||||
All **105 targeted real-backend cases** pass across Chromium, Firefox, WebKit, phone and tablet: the complete sample-dashboard group and standalone input cases, including system/monospace center clicks and a 320px viewport. All **1,801 unit tests**, production export, types, lint, license and bundle checks pass. The original width and dashboard-heading assertions remain. Logs use `widget-ci-*`, `widget-width-*` and `widget-setup-red-browser.log`; the width red trace is preserved separately.
|
||||
|
||||
Refreshed two widget states in both themes at three sizes: 24 original/current entries. Inspected the current desktop editors and light/dark phone open-choice menu. Current captures match the final export with no setup/runtime error or document overflow. The original has no choice menu; its new state is explicitly marked non-equivalent. Recounting all six canonical manifests gives **106 light and 87 dark states per viewport**; this corrects the prior checkpoint's dark count after form captures were added. No whole family is approved.
|
||||
|
||||
## Earlier checkpoint — September 29, error readability and refreshed form review
|
||||
|
||||
**VIS-027 corrected:** the query error panel failed at 4.24:1 in default light mode, Retry hover at 1.8:1 in dark mode, and the configured orange error theme at 2.52:1. Error text now uses the existing theme-derived red-800 in light mode and red-400 in dark mode shades, with red-300 for dark Retry hover. The visible Sonner error notification separately failed at 4.34:1; its light surface now uses red-800. The configured orange pivot validation also failed at 4.25:1 and now uses red-800 in light mode. Panel, dialog and notification geometry, placement and timing are unchanged.
|
||||
|
||||
The broader browser run exposed lazy-fixture leakage: default report-theme tests did not request the backend reset and inherited the preceding configured theme. Its 34 passes / 16 failures are retained as diagnostic evidence. The report spec now requests the reset before every test; a deliberate configured-orange pivot case retains that contrast regression. Query and pivot checks assert the actual root theme. The final run passes **55 real-backend cases across all five profiles**: error panel/notification contrast, Retry hover and request, dismissal, SQL-verified recovery, report-link and pivot contrast, and theme metadata. All **1,801 unit tests**, production export, types, changed-file lint, license and bundle checks pass (1,034.1 KB / 1,050 KB). This is targeted acceptance, not final release acceptance. Logs use `query-error-{complete,pivot,final}-*`; confirmed earlier failures remain in their logs and `query-error-theme-red-artifacts`.
|
||||
|
||||
Refreshed original/current create, edit, copy, validation and field-lab forms in both themes at all three sizes:60 entries / 120 images, including scroll sequences. Inspected desktop edit, desktop field-lab middle/bottom, phone field-lab top and edit bottom, dark phone validation, tablet create, and dark desktop field-lab bottom pairs. Their form-body section spacing, field placement and footer positions match the original; the Person currency prefix is metadata-driven. This does not approve uninspected field interactions, modal forms or the whole forms family. Original/current error and Retry-hover captures were also refreshed. Final current-export refresh covers eight states per theme/size (48 entries): five forms, two query errors and pivot validation; no setup/runtime errors or document overflow, all with matching build fingerprints.
|
||||
|
||||
Inventory is **105 light / 84 dark states per viewport**. Other captures may carry older export fingerprints and must be checked before approval. No whole family is approved. Current preview on port 18769 serves the final contrast build; original on port 18768 remains the original Next design.
|
||||
|
||||
Full remote CI 36580709453 at 30db309 is now **terminal failure**: WebKit passed; phone WID-074 measured choice width 224px versus native scalar 226px, and Firefox WID-014 timed out waiting for the dashboard heading in beforeEach. The later failure snapshot contains that heading. Downloaded logs/traces are in `ci-36580709453-*`; neither failure is waived. The Firefox trace shows backend/diagnostics fixtures were never initialized before the failed page-opening hook. A phone font-loading probe did not reproduce the width failure: system-font scalar 201px and choice 224px stayed unchanged while the downloaded font was held/released. Investigate platform font sizing and hook setup next; do not change geometry or relax assertions without evidence. No merge or publication.
|
||||
|
||||
## Earlier checkpoint — September 29, query evidence and column-popup focus
|
||||
|
||||
**VIS-026 corrected:** opening Columns with Enter left focus on its toolbar trigger; activating the popup's Close button with Enter then left focus on the page body. The nonmodal popup now receives focus without scrolling or automatically opening a phone search keyboard. Explicit close returns to Columns; Escape keeps its existing focus return, and outside clicks retain focus on the clicked control. Embedded report column editors do not take focus when mounted. The popup layout and field controls are unchanged.
|
||||
|
||||
The component regression failed before the fix. A real-browser probe reproduced both focus failures; the corrected Playwright regression then failed at inactive popup focus. Its first attempt used a Testing Library matcher by mistake and is not counted as product evidence. After the fix, 48 affected component tests pass. The broader five-profile backend run passed 28/30; both failures assumed WebKit tabs to a button before search. A plain native-control probe showed Chromium/Firefox tab to Close while this WebKit runner tabs directly to search. The test now verifies those observed native paths while retaining popup focus, search, keyboard close, Escape and outside-click assertions. The final focus check passes all five profiles. Production export, types, lint, license and bundle checks pass. Logs use `column-focus-*`; the confirmed pre-fix artifacts are retained separately.
|
||||
|
||||
**Review evidence correction:** some older current captures still showed column widths that had already been fixed. The capture tool now stores a timestamp and SHA-256 fingerprint of the exported files in each new entry. The gallery displays them, flags untracked/older builds, and avoids resetting a newer scenario selection when an earlier fetch finishes. Six theme/viewport image-pair checks and both warning checks pass (`gallery-provenance-check.log`). Captures outside the refreshed query family still require freshness verification; they must not be treated as current approval evidence.
|
||||
|
||||
All 27 query states now have refreshed original/current entries in light/dark at desktop/tablet/phone sizes: 324 entries. Current entries have no setup/runtime errors or document overflow and match the final export fingerprint. Three added states cover column-popup keyboard focus, search and no matches. The original has no search control; those baseline captures explicitly state they are not equivalent interaction states. Inspected fresh desktop column configuration/menu/empty/error/loading states, desktop keyboard focus, tablet filtered columns and dark phone no-match presentation. Inventory is now 104 light and 83 dark states per viewport. No full family is approved.
|
||||
|
||||
**VIS-027 remains open:** the light query error panel fails a browser contrast check at 4.24:1 for its heading, message and Retry text (required 4.5:1). The dark panel passes. `query-error-contrast-probe.log` contains the measured foreground/background colors and affected nodes. Correct the text contrast while preserving configured status colors and the original panel layout, then verify both default and application themes.
|
||||
|
||||
Remote full CI36580709453 at30db309: WebKit passed; Chromium/phone/tablet and Firefox remain running. Keep local corrections unpushed until that run finishes. Full visual review, functional gaps and final release gates remain open.
|
||||
|
||||
## Earlier checkpoint — September 29, compact filter controls restored
|
||||
|
||||
**VIS-002 corrected for the reproduced desktop height regression:** the populated filter table started at y579, versus y535 in the original Next capture. The unsaved-view status forced a separate full-width row (24px), and the desktop filter header used 44px controls plus padding (20px above the original header height). A browser-only trial isolated both causes before the source change.
|
||||
|
||||
The status can now share available toolbar space at tablet/desktop widths; phone wrapping remains. Filter mode and sort controls use a compact 32px minimum on devices without coarse pointers and retain 44px minimums when any coarse pointer is available. The desktop panel header is41px, down from61px; the resulting panel and table positions match the original populated-filter capture. Basic/Advanced, sorting, ownership, save/reset actions and the single editable condition list remain intact. Tablet/phone measurements retain44px targets and no document overflow.
|
||||
|
||||
Verification: production export/typecheck, standalone typecheck, changed-file lint, license and bundle checks pass (1034KB/1050KB). All34 affected component tests and50 real-backend cases across Chromium/Firefox/WebKit/phone/tablet pass (QRY-080/083/084/051/093). These are targeted checks, not a whole-suite acceptance result. `filter-layout-red.log` preserves the pre-fix61px header failure; `filter-layout-trial.log` proves the browser-only correction; `filter-layout-green.log` checks the actual final export without injected styles. Other logs use `filter-layout-{unit,build,browser,types,license,budget}`.
|
||||
|
||||
Refreshed six current query states in both themes at three sizes:36 entries, no setup/runtime errors or document horizontal overflow. Inspected desktop light/dark populated filters, tablet populated filters and dark phone sheet. Original captures remain unchanged. Inventory stays101light/80dark states per viewport; no whole family is approved. Current preview includes this build. The full remote run36580709453 at30db309 is still running; general and image CI passed. Keep local corrections unpushed until the full run finishes. Full visual review and release gates remain open.
|
||||
|
||||
## Earlier checkpoint — September 29, copied-record timestamp persistence
|
||||
|
||||
The follow-up found ordinary record edits already protect unchanged timestamps by omitting them from PATCH. Base Copy and both child-row editor hosts submit complete values, however, and were discarding fractional seconds. The real-backend copy regression in America/New_York also reproduced a one-hour shift during the repeated autumn hour: `06:30:07.123456Z` became `05:30:07Z`. Copies and child edits now pass original record values through the existing wire converter; edited values retain normal local-to-UTC conversion.
|
||||
|
||||
Verification: **1,799 unit tests and 30 real-backend cases across five profiles pass** (REC-065/018/013/060 and both PRC-064 cases). REC-065 checks unchanged PATCH omission, the copy POST instant and source/copy SQL precision. Separate component regressions prove association and process child-row preservation; these are not claimed as new child-specific real-backend coverage. Production export, types, changed-file lint, license, whitespace and bundle checks pass (1033.9KB/1050KB). Logs use `record-datetime-*`; the confirmed pre-fix trace is retained in `record-datetime-red-artifacts`. The first browser attempt stopped at the existing unreadable-password copy guard; supplying a new fixture password allowed the timestamp defect itself to reproduce.
|
||||
|
||||
No layout or style changed in this correction, so existing visual captures were not repeated. Current preview includes the corrected export. Visual inventory remains101light/80dark states per size; no whole family approved. Full CI36580709453 at30db309 remains running across all browser jobs; general CI36580709451 and image36580709514 passed. Keep the new correction local until that full run finishes. Final visual review, complete release verification and publication remain open.
|
||||
|
||||
## Earlier checkpoint — September 29, date-time input review
|
||||
|
||||
**VIS-025 corrected:** zoned process values were assigned directly to datetime-local controls, which displayed blank fields. Plain standalone blocks sliced UTC digits into a local-time control and discarded seconds. Shared standalone editors displayed local values but sent unzoned text through their action callback. New component regressions and a fixed America/New_York browser context reproduced the failures.
|
||||
|
||||
Process fields and standalone blocks now reuse the existing date-time conversion helpers, retaining their native controls and seconds. Process payload conversion occurs after contributors. The converter can retain an original instant when its displayed wall time is unchanged, preserving the second occurrence of a repeated DST hour and hidden subsecond precision. Edited local times become UTC instants. Material's unadorned TEXT/HTML block mapping was inspected and confirmed to use ordinary text controls; no speculative editor redesign was made.
|
||||
|
||||
Verification: **1,796 unit tests and 30 real-backend cases across all five profiles pass** (PRC-064 unchanged/edited, WID-075, PRC-063/062 and REC-018). Backend SQL proves unchanged spring and autumn timestamps retain their instants; edits across the spring transition use the correct offset. A unit regression proves unchanged subsecond precision survives. Production export, standalone types, changed-file lint, license, whitespace and bundle checks pass (1033.9KB/1050KB). Logs: `block-datetime-{red-unit,callback-red-unit,precision-red-unit,red-browser,green-unit,unit,build,browser,types,lint,license,budget}.log`; pre-fix browser traces remain in `block-datetime-red-artifacts`.
|
||||
|
||||
Paired process timestamp and standalone editor captures were added/refreshed in light/dark at three sizes:24entries, no setup/runtime errors or document overflow. Inspected desktop process pairs, phone process inputs and dark phone standalone controls. The original process blocks show raw text and its regular form timestamps are blank; current controls show dates, seconds and the existing shared editor timezone hint. That hint adds height relative to the original block renderer and remains part of the broader composition review. Captures use UTC; separate acceptance uses America/New_York. Inventory101light/80dark states per size; no whole family approved.
|
||||
|
||||
Full CI36573705464 at121468b is now terminal: Firefox and WebKit passed, Chromium/touch failed dependency download before tests. This satisfies the hold on pushing local commits. Final full verification on the new source and the complete release audit remain required; no merge or publication.
|
||||
|
||||
## Earlier checkpoint — September 29, input configuration review
|
||||
|
||||
**VIS-024 corrected:** advancing a process moved focus from a configured autofocus input to the screen heading. A real-browser regression reproduced the inactive Plain Code field on screen two. The process now preserves focus already inside a marked autofocus editor; ordinary result screens still receive heading focus. Plain and shared metadata editors both pass the transition check.
|
||||
|
||||
Enter also inspected trimmed text while submitting the untrimmed process form. PRC-044 reproduced surrounding spaces in the actual SQL record. Plain/shared text blocks now normalize their own value before validation and submission. Required whitespace cannot advance; standalone shared blocks validate the normalized value before invoking their callback. Typed numeric/date/checkbox values and multiline, file and picker keyboard behavior retain their existing paths.
|
||||
|
||||
Verification: **1,791 unit tests and 25 real-backend cases across all five profiles pass** (PRC-037/044/062/063 and WID-074). Production export, types, changed-file lint, license, whitespace and bundle checks pass; total JavaScript is 1033.6KB gzip against 1050KB. PRC-063 checks placeholders, retained process values, first blur without premature validation, required whitespace, both autofocus paths, result heading focus and stored SQL text. Pre-fix traces remain in `block-enter-red-artifacts` and `block-focus-red-artifacts`; logs use `block-{enter,focus}-red-*` and `block-input-config-*`.
|
||||
|
||||
Added paired plain/formatted input focus captures in both themes at three sizes:24entries, no runtime/setup errors or document overflow. Inspected desktop plain pairs, phone formatted pairs and dark tablet formatted focus. These process cards preserve the original geometry; current required markers and focus rings are visible. Inventory is100light/79dark states per size. This closes the reproduced focus/Enter defects, not the full widget/process configuration audit or any whole-family visual approval.
|
||||
|
||||
Both previews use the updated fixtures; the original export is unchanged and the current export includes these fixes. Full remote run36573705464 at121468b: WebKit passed, Firefox running, Chromium/touch failed dependency download before tests. The final full release gate remains open; no merge or publication.
|
||||
|
||||
## Earlier checkpoint — September 29, record section keyboard review
|
||||
|
||||
**VIS-023 corrected:** record tabs had tab roles but no arrow navigation, single tab stop or tab/panel associations. The real-backend regression failed when Right Arrow left focus on Overview. Tabs now use manual activation: Left/Right/Home/End move focus; Enter/Space opens the section through the existing URL callback. This avoids mounting section widgets merely while moving focus. Tab enters the named panel, with a visible focus indicator. Phone accordion behavior is preserved. The approach follows the [WAI tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
|
||||
|
||||
Verification:1786unit tests, production export/typecheck, final types/lint/license/whitespace and bundle1033.5KB/1050KB pass. The related five-profile run passed32cases and found one ambiguous phone test selector (outer accordion and nested section share a region name). The test now follows the trigger’s actual aria-controls and retains name/value assertions; all five keyboard rechecks pass. Existing section/list/deep-link/joined-field checks passed. Logs: `record-tabs-{red-browser,green-browser,keyboard-green,unit,build,types,lint,final-lint,budget}.log`. The pre-fix trace is retained in `record-tabs-red-artifacts`.
|
||||
|
||||
Added record-section-focus and refreshed record-small pairs in light/dark at desktop/tablet/phone:24entries, no runtime/setup errors or page overflow. Inspected desktop original/current record card, dark tablet focus and phone accordion focus. Current layout retains the recovered original record composition; focus styling adds no layout spacing. Inventory is98light states/size and77dark. These are reviewed states, not whole-family approval. Configured/denied/error/loading variants and remaining form/widget review continue.
|
||||
|
||||
Local changes remain unpushed while fullCI36573705464 at121468b is active: Firefox/WebKit running; Chromium/touch failed during Corepack network setup before tests. General CI and image build passed. No merge or publication.
|
||||
|
||||
## Earlier checkpoint — September 29, compact report inputs restored
|
||||
|
||||
**VIS-020 corrected for standard report input screens:** restored the compact card, 384px ordinary field maximum, and left-aligned Generate Report action below the inputs. Removed the added visible Input/Step header and divided footer. Cancel remains a secondary inline action. The accessible form name, shared dynamic form, validation, typed values, possible values and process cancellation remain. Configured grid widths and rich editors retain their layout; longer or custom report processes retain their step navigation and components.
|
||||
|
||||
Before the change, two unit regressions failed because Generate Report was absent, and the real-browser layout regression failed because the extra Step header was visible. Final verification: **1786unit tests**, production export/typecheck, changed-file lint, license, whitespace and bundle checks pass. **30 real-backend cases across all five profiles pass**, covering report layout/required validation/database-matched output and shared mixed/filtered/required/composite process forms. Logs: `report-compact-{red-unit,red-browser,green-unit,full-unit,build,green-browser,lint,budget}.log`; pre-fix browser artifacts are retained in `report-compact-red-artifacts`.
|
||||
|
||||
Paired input, validation and result captures were refreshed in both themes at desktop, tablet and phone:36entries, no runtime/setup errors or document overflow. Inspected desktop input, phone input pairs, dark phone validation pairs, and tablet result. The normal card geometry now matches the original; inline Cancel and shared validation focus/message treatment remain visible differences. No whole report family is approved. Canonical inventory remains97light/75dark states per size. Current preview18769 includes this correction.
|
||||
|
||||
Final diff review kept rich/file/code/widget editors on their existing grid rules, including configured file widths. The final export,1786units and changed-file lint pass; RPT-006 passes again in all five profiles. Final logs are `report-compact-final-{build,unit,browser,lint,budget}.log`. Both current dependency audits report zero known advisories (316production dependencies;1038in the full audit); this does not replace the remaining application security checks.
|
||||
|
||||
Remote full acceptance36573705464 at121468b remains active: Firefox/WebKit running; Chromium/mobile/tablet failed before tests in Corepack dependency setup with network ETIMEDOUT/ENETUNREACH. Its log is retained as `ci-121468b-chromium-mobile-tablet.log`. General CI36573705788 and image36573705430 pass. Do not push the local report correction while the full run is live; final full verification and release remain open.
|
||||
|
||||
## Earlier checkpoint — September 29, saved-view reload correction
|
||||
|
||||
Full run36564701143 is terminal: Chromium/mobile/tablet and WebKit pass; Firefox failed QRY-093. The trace showed new filtered data before asynchronous page navigation updated the URL. Query URL changes now replace the current history entry synchronously through Next-integrated native history, preserving other parameters and the hash. No query layout was intentionally changed.
|
||||
|
||||
A deterministic Firefox regression failed before the fix with the old filter still in the URL. The corrected export passes25 saved-view, delayed-navigation, back/forward, quick-view and column-menu cases across five profiles, plus20 pagination, sorting, quick-search and return-navigation cases. The current tree passes1784unit tests, production export/typecheck, final standalone types, changed-file lint, license and whitespace checks; bundle1033KB gzip remains within1050KB. Logs: `query-url-{red-unit,red-browser,green-browser,related-browser,build,unit,types,lint}.log`. The final full run with this correction remains pending; these are focused checks.
|
||||
|
||||
The current preview includes the correction. Desktop report-input pairs were re-inspected against original source: original compact card has384px maximum-width inputs and an inline Generate Report action; current shared process screen adds header and footer dividers, step count and wider fields. VIS-020 remains open. Preserve the shared dynamic form/process lifecycle while restoring the report presentation. Full visual review and all family approvals remain open.
|
||||
|
||||
## Earlier checkpoint — September 29, composite metadata inputs
|
||||
|
||||
The block INPUT_FIELD integration now reuses Next's field editors for choices, formatting and supported adornments, retaining the process form and multipart upload path. Plain scalar input layout remains the reference. The richer code/filter/upload editors add genuine capabilities absent from the original block renderer; their additional height and composition are still under visual review. This does not approve the widget or process family.
|
||||
|
||||
**VIS-021, corrected in source:** the dispatcher initially treated the backend's default `%s` format as custom formatting. This changed ordinary scalar controls into the shared form layout. A fixture-based browser failure and unit regression exposed it; neutral formatting now preserves the original scalar path. Hidden and read-only metadata are respected in both hosts.
|
||||
|
||||
**VIS-022, corrected in source:** a standalone choice editor collapsed to approximately101px, placing its Clear button at the center of the control. A native hit test confirmed that a center click cleared the value instead of opening the choices. The original scalar input is approximately200px wide; standalone metadata editors now use a14rem minimum, also accommodating Firefox's approximately217px native input. The browser assertion compares their widths and retains the center click.
|
||||
|
||||
A typed-input fixture existed but was not registered, so the first new capture showed Page not found. It now has a dedicated `widgetInputEditors` app. The capture script treats unexpected not-found screens as failed evidence. Paired captures also cover `prcBlockEditors`, whose original renderer lacks the rich editors, and the unchanged `prcWidgets` route. These comparisons distinguish new capability from restored composition. Final verification and refreshed captures are recorded below when complete.
|
||||
|
||||
The remote full run36564701143 has WebKit success, Firefox520passes/1failure(QRY-093), and Chromium/mobile/tablet still running. The Firefox trace shows a concrete reload race: B-filter data appeared before asynchronous router replacement updated the address bar, so reload used the previous Avery filter. Fix remains open; no assertion has been weakened. Artifact11033840508 and its log are retained locally.
|
||||
|
||||
Final input-editor checkpoint: **1,783 unit tests pass**, production export/typecheck, changed-file lint, license and whitespace checks pass, and the bundle remains within1050KB gzip. **35 real-backend cases pass across five profiles**, comprising30 widget/process checks and five unchanged QRY-093 rechecks. Those rechecks do not resolve the proven CI URL race. Final logs are `block-editors-width-{build,acceptance,visual-light,visual-dark}.log`, `block-editors-final-{unit,types,lint,license,budget}.log`. The earlier width run's failures and traces are retained, including the aborted local run after repeated diagnosed failures; no remote job was canceled.
|
||||
|
||||
Canonical inventory:97light states/size(194original/current entries),75dark(150entries). All36 entries for the three compared scenarios have no setup/runtime errors or page overflow. Inspected final light desktop/phone standalone controls, light phone process lower controls, dark phone standalone controls, dark tablet process lower controls, and existing Widget Lab. Plain fields retain their compact treatment; actual adorned editors are larger than the original scalar controls. Complete configuration/autofocus and visual composition review remain open in the parity ledger. The two new acceptance rows are PRC-062 and WID-074; the generated whole-suite matrix still requires final release reconciliation.
|
||||
|
||||
## Earlier checkpoint — September 29, report interaction review
|
||||
|
||||
**Release not ready; full visual review incomplete.** Recovery head `06cf1b0` is now pushed to PR #14. General CI `36564701134` and image build `36564701149` pass; full acceptance `36564701143` remains running. The preceding full run ended with tablet/WebKit Export failures and Firefox saved-report opening delay. Local report review changes are verified separately and will stay local while the new full run is active.
|
||||
|
||||
Expanded the review with report inputs, required validation, completed output, filter variables, pivot editing/validation, schedule creation/day selection, sharing, and keyboard tab focus. The capture script now measures dialog-body scroll positions. Query/report dark coverage is paired at all three sizes; unavailable original editors remain explicitly non-equivalent. Other families still have dark/theme/interaction gaps. No whole family is approved.
|
||||
|
||||
**VIS-018 (medium), corrected:** the report filter/column tabs declared tab semantics but had no arrow-key behavior or labeled panel. Added automatic activation with Left/Right/Home/End, a single tab stop, a linked panel and visible keyboard focus using the existing script-tab pattern. Filter and column drafts persist across switches; hidden Columns metadata remains respected. Unit regression failed before the correction. The first browser run exposed Firefox's native tab stop on a scrolling panel at 1280×720; a native probe proved that an explicit `tabIndex=-1` moves Tab into the column search field. The assertion was preserved.
|
||||
|
||||
**VIS-019 (medium), corrected:** pivot validation text on its tinted background measured 4.12:1 in light mode and 4.21:1 in dark mode. Axe independently rejected both. The alert now uses darker light-mode red text and lighter dark-mode red text, preserving its border, tint, layout and validation behavior. Final native measurements are 5.48:1 in both themes, and all ten light/dark pivot contrast cases pass across the five browser profiles.
|
||||
|
||||
**VIS-020 (review open):** report inputs now use the shared process screen. The original compact input card and Generate Report button have gained an Input/Step header, wider desktop field, and Cancel/Submit footer. The full process lifecycle and dynamic fields must remain, but the additional height and action placement are not visually approved. Compare `report-inputs` and `report-input-validation` on desktop/phone before deciding the final presentation. Scheduling and sharing retain their original dialog layout in the inspected pairs; scheduling adds the metadata-driven Variable Values section. Its lower controls are reachable by scrolling.
|
||||
|
||||
Inspected pairs: populated desktop/phone filters; dark phone quick editor, columns and export; dark tablet relative-date menu; desktop/phone report inputs; phone schedule scrolls and sharing; desktop pivot; phone pivot validation including its lower controls; dark phone variables/pivot validation/sharing; dark tablet schedule Days popover. This list names reviewed evidence, not whole-family approval. Reports still need configured variants, denied/error/loading states, populated schedule/share interactions, complete keyboard closure, and review of the added editor composition. The functional embedded-query equivalence row remains Partial.
|
||||
|
||||
Evidence: `query-report-dark-completion.log`, `report-interactions-{initial,light,dark}.log`, `report-tabs-{red,green,focus-probe}.log`, `pivot-contrast-{light,dark,red}.log`; final verification and corrected screenshots follow this checkpoint. Guides now document report-tab keyboard behavior and the Firefox scroll-container distinction.
|
||||
|
||||
Final checkpoint verification: **1,773 unit tests and 45 real-backend report cases pass across five profiles**. Export/build typecheck, final standalone TypeScript check, changed-file lint, license headers, whitespace and bundle budget pass (1032.1 KB gzip / 1050 KB). Logs: `report-review-final-{unit,build,acceptance,budget}.log`, `report-tabs-focus-green.log`, `pivot-contrast-{light,dark}-green.log`. Corrected tab-focus and pivot-validation captures were refreshed in both themes at all three sizes and inspected on phone. Canonical inventory is now **95 light states and 73 dark states per size**, with 190/146 original/current entries. Report captures have no runtime/setup errors or document horizontal overflow. This remains partial release evidence; full visual review and the complete final release gate are open.
|
||||
|
||||
## Earlier checkpoint — September 29, inline profile naming restored
|
||||
|
||||
**Release not ready; full visual review incomplete.** PR #14 remains open at `4748b6c`; local recovery is verified separately. Full real-backend run `36557085746` now has Firefox519passes/1failure (saved-report record opening) and WebKit519passes/1failure (Export). Chromium/mobile/tablet remains running. WebKit's Export failure is corrected locally; Firefox is under diagnosis. Nothing is merged or published. No whole visual family is signed off.
|
||||
|
||||
The light inventory contains **85 scenarios at each of three sizes**. Dark coverage remains partial (47 desktop, 45 tablet, 47 phone scenarios, with some unpaired entries). Unavailable original interactions are explicitly marked. Captures are evidence to inspect, not approval.
|
||||
|
||||
New, Save As and Rename now use the original Next inline naming layout inside the profile section. Compact field/button styling, Enter behavior and focus return are restored while ownership, backend errors, copying and overwrite/delete confirmations remain. Paired light/dark captures at all three sizes show the recovered editor; added reset controls still increase the surrounding section height. This is not full process-family approval.
|
||||
|
||||
Current verification: **1,771 unit tests and 40 bulk-load real-backend cases across five profiles pass**. The earlier menu/Preferences checkpoint passed50/50 affected cases. Production export, final typecheck/lint, license and bundle budget pass (1031.9 KB gzip / 1050 KB). The PRC046 lifecycle test now targets the portaled menu correctly and verifies duplicate-name rejection. Parity recount is728Done,22DifferentUX,5Partial,2Missing,38N/A across795existing rows; the previous summary omitted one supplemental metadata row.
|
||||
|
||||
## Evidence and method
|
||||
|
||||
- Current source: `feature/next-1.0`; restoration, report editor and guide commits are integrated. The live current preview includes the verified recovery changes described below. Both comparison exports were refreshed on September 29.
|
||||
- Both builds run the same current sample JAR and acceptance fixture sources, with fresh admin browser contexts and the same Inter font bytes. Historical-reference type checking is skipped only in its isolated build; the current export completes its normal type check.
|
||||
- Local comparison gallery: <http://127.0.0.1:18770>. Earlier UI: <http://127.0.0.1:18768/app>. Current UI: <http://127.0.0.1:18769/app>.
|
||||
- Evidence is retained under `../qqq-frontend-next/test-results/visual-review/`: source/build provenance, capture scripts, manifests, screenshot sequences, and server/build logs. It is ignored generated output, not a published artifact.
|
||||
- Review dimensions: hierarchy, typography and spacing, color and icon treatment, control grouping, responsive layout, and interaction states. Confirm findings against source before changing defaults; preserve explicitly configured metadata behavior.
|
||||
|
||||
## Coverage
|
||||
|
||||
Current inventory: **106 light and 87 dark states per viewport**, each with original/current entries. The latest checkpoint refreshes standalone widget inputs and the open choice menu against the final export. Other states include older captures and need current-build verification before approval. The table below describes the initial inventory and its remaining review scope; later checkpoints document additional coverage, not whole-family approvals.
|
||||
|
||||
The initial pass captured 38 scenarios per build at desktop (1440×1000), tablet (810×1080), and phone (393×851): 228 build/state combinations and 447 screenshots, including long-page scroll sequences. Every entry viewport has received a paired visual scan. Detailed review of all scroll sequences, dark/configured themes, keyboard focus, and additional interaction states remains. No whole family is signed off yet. The baseline report-filter editor is unavailable because that functionality did not exist there; its capture is not an equivalent editor state. The desktop baseline delete capture was corrected during the populated-filter follow-up.
|
||||
|
||||
| Family | Captured desktop scenarios | Remaining review scope |
|
||||
|---|---|---|
|
||||
| Shell and navigation | Dashboard, app home, nested navigation, not found | Search, command menu, notifications, user menu, help, login/session, phone navigation, branding/theme variants |
|
||||
| Query | Small and wide table, filters, advanced filters, columns, views, export | Populated criteria and groups, quick-filter editing, column menus, density, cards, selection/bulk controls, saved-view states, pagination, empty/error/loading |
|
||||
| Records | Compact record, person, record lab, audit, delete | Every tab/list/card variant, associations, metadata menus, configured sidebar, collapse, sharing, warnings/errors |
|
||||
| Forms | Create, edit, copy, field lab | Each field editor, validation, disabled/read-only, dirty state, full copy, file/association editors, modal forms |
|
||||
| Processes | Wizard, component lab, widget step, bulk load | Subsequent steps, selection, validation review, progress, results, errors, cancellation, upload mapping |
|
||||
| Reports | Run inputs, saved report, edit, filter/column editor | Filter variables, columns, pivot editor, scheduling, sharing, results/download, validation |
|
||||
| Widgets | Sample dashboard, gallery, blocks, parents, controls, states, tables/charts, record widgets/editors | Map all 38 dispatcher labels to evidence; inspect every scroll capture, dropdown, tooltip, menu, loading/error/empty state |
|
||||
| Developer tools | Table and record developer pages | Script editor/viewer, code tabs, dialogs, ESB metadata/tools, responsive layouts |
|
||||
|
||||
## Findings
|
||||
|
||||
| ID | Severity | Observed difference | Evidence | Status |
|
||||
|---|---|---|---|---|
|
||||
| VIS-001 | High | Wide query tables now use very narrow numeric/date/boolean defaults. Header labels are reduced to fragments, dates wrap across lines, and monetary values clip. The reference keeps readable columns and scrolls horizontally. | `desktop/query-person`, `desktop/query-advanced`; `DataGrid.tsx` and `query-columns.ts` | Restored 150px defaults; configured/user widths preserved. Desktop dates/currency verified after rebuild. Long heading truncation remains under review. |
|
||||
| VIS-002 | Medium | Switching an empty query to Advanced adds a long unsaved-view status and links to the toolbar, moving Export and view controls to another row. The filter panel also grows substantially with separate mode and sort controls. | `desktop/query-advanced` | Corrected for the reproduced desktop regression: status shares available toolbar space and compact mode/sort controls restore original panel/table positions; 44px touch targets remain. See latest checkpoint. Whole query-family review remains open. |
|
||||
| VIS-003 | Medium | A record with process actions now has separate Copy and Delete buttons plus an Actions menu. The reference groups secondary actions in the menu. | `desktop/record-person`, `tablet/record-person`; `RecordActions.tsx` | Corrected and verified in desktop/tablet/phone record captures. Enabled menu actions suppress duplicate direct buttons, including nested menus. NAV-055 passes all five profiles. |
|
||||
| VIS-004 | Medium | Record tabs crowd the identity header and field groups have more vertical spacing. The earlier header has a clear gap above tabs. | `desktop/record-person`, `desktop/record-small`; `RecordViewHeader.tsx` display-contents wrapper and field layout | Normal wrapper and 16px field gap restored; desktop/tablet/phone record captures reviewed. |
|
||||
| VIS-005 | Medium | Widget header icons changed from colored outline glyphs to filled square tiles; the sample pie chart changed from a doughnut to a solid pie. | `desktop/widgets-sample`; `WidgetIconTile` and `QqqChartWidget` | Header glyph colors and doughnut restored; image paths, main-icon tiles, explicit colors, slice links, tooltips and legend toggles retained. Fresh desktop/phone captures reviewed; 30/30 affected five-profile widget cases pass. |
|
||||
| VIS-006 | High | Phone query pages always expose mode, sort, and quick-filter controls beneath the toolbar, pushing the first Person card from approximately y=474 to y=708. Opening the Filter sheet also adds unsaved-view links behind it. | `phone/query-person`, `phone/query-small`, `phone/query-filters`; `RecordQuery.tsx` unconditional phone controls | Corrected: shared controls now live in the phone Filter sheet; opening preserves mode. Fresh phone captures confirm first card returns to approximately y=474. Affected filtering/focus/record acceptance passes 83/83 across five profiles. |
|
||||
| VIS-007 | Medium | Phone app-page titles are reduced to a few characters after adding a home breadcrumb and a separate command-palette control. The earlier header preserves substantially more of the page name. | `phone/app-home`, `phone/widgets-sample`, `phone/not-found`; `Header.tsx`, `Breadcrumbs.tsx` | Corrected: compact mobile gaps, current crumb constrained to available width, and scrolling after metadata arrives or the trail resizes. Fresh phone app/widget captures reviewed; all header controls retained, navigation/focus/touch checks pass in five profiles. |
|
||||
| VIS-008 | Medium | Process widget buttons changed from compact primary buttons to full-width slate bars, with extra margins pushing the footer below the first phone viewport. | `process-widgets` on all sizes; process migration to shared `QqqComposite` inherited `ButtonBlock` defaults | Partially corrected: hosted process buttons use compact primary styling and disable during work; metadata colors/formats and shared action handling preserved. Desktop/phone captures reviewed; five-profile PRC-035–037 pass. Process text/label/header spacing still exceeds the earlier Next layout and remains under review. |
|
||||
|
||||
Existing defects also found in the reference: Views menus run off the right edge on phone/tablet; the phone Export menu opens partly off the left edge; records with many tabs have overlapping tablet labels; sample Big Number context overflows on phone. These are production-quality issues even though they predate the parity changes. They require focused interaction checks and fixes, not preservation as design intent.
|
||||
|
||||
Views and Export overflow is now corrected using the existing Radix dropdown pattern: collision-aware placement, available-height scrolling, arrow-key selection and Escape focus return. Existing item actions, native disabled buttons and permissions are preserved. Fresh desktop/phone captures reviewed; 45/45 saved-view/export real-server cases pass across all five profiles, including full viewport intersection assertions. Tablet tab labels and Big Number context are corrected in the responsive checkpoint below.
|
||||
|
||||
Dashboard, app-home content, wizard entry, basic process components, centered Person create/edit/copy forms, blocks/parent widgets, and developer-page structure retain the earlier overall layout in the captured states. Form headings and currency adornments differ. Reports and record-widget forms gained previously missing filter, pivot, dynamic-form, and variable sections; their additional length is functional work that must survive restoration. Typed widget table cells now render values instead of raw markup/object strings. These observations cover entry states only and do not approve the full workflows.
|
||||
|
||||
## Historical release checkpoint — September 28
|
||||
|
||||
- Next UI 1.0.0 remains unreleased. PR #14 remains open at `93b7b5a`; full real-backend run `36507768282` is in progress. The local query-menu follow-up described below is verified separately.
|
||||
- Remote source/typecheck/lint/unit/coverage, mocked E2E, Storybook, bundle, CircleCI and both architecture image builds pass. Full real-backend run `36501934828` completed with failures in every job. Chromium/mobile/tablet reported 143 failures and 1343 passes; Firefox reported 52 failures and 460 passes. This is not a green release gate.
|
||||
- A missing middleware API dependency in the separate security fixture caused many failures. Both fixture launch paths now share classpath resolution. The local security/no-search/process-field-widget rerun passes **124/124 in Firefox and WebKit**.
|
||||
- Report preview/editor verification passes **15/15 across all five profiles**. Read-only previews support temporary paging, sorting and resizing without writing to the saved report. Column widths from metadata are retained.
|
||||
- Current full unit checkpoint: **1765/1765**. Production export/typecheck, license headers and bundle budget pass (1032 KB gzip / 1050 KB). The remaining CI regression set passed 140 cases across all five profiles; its five script author/date selector failures were corrected and rerun successfully (5/5). This does not replace the final full gate.
|
||||
- Functional ledger remains **734 Done, 15 Done with different UX, 5 Partial, 2 Missing, 38 N/A** (794 rows). Remaining groups include base-path hosting, Google Drive picker, embedded query/report controls, complex block input adornments, unmapped icon names, and script autocomplete.
|
||||
- Developer, agent and CSS-hook guides are integrated; the developer/agent report-preview descriptions now reflect the paged grid. Final browser workflow and visual approval remain open.
|
||||
|
||||
## Release sequence
|
||||
|
||||
1. Complete the screen/state inventory and paired desktop, tablet, and phone evidence, plus theme and accessibility states.
|
||||
2. Review every capture and record regressions with source causes. Correct shared defaults and verify the affected families together.
|
||||
3. Reconcile the developer/agent guides and functional parity ledger with the chosen Next interactions.
|
||||
4. Run the final five-profile real-server acceptance, source, accessibility, security, performance, and packaging gates on the resulting source.
|
||||
5. Merge and publish Next UI 1.0.0 only after the complete release evidence is green. QQQ 4.1 remains unreleased.
|
||||
|
||||
September 28 follow-up: phone filter controls and restored widget styling are in the current preview on port 18769. The initial capture remains archived for comparison; manifests contain refreshed captures only for the scenarios explicitly recaptured. Remaining priority regressions are VIS-002 (dirty-view toolbar), VIS-007 (phone header identity), VIS-008 (process widget button defaults), plus responsive defects and detailed visual states.
|
||||
|
||||
Later September 28 checkpoint: VIS-007 and process button defaults are corrected in the refreshed current preview. Full unit suite: 174 files / 1763 tests pass. Production webpack export/typecheck, source lint (0 errors, 5 existing warnings), license and bundle budget pass. NAV-026/037/038 and PRC-035/036/037 pass 30/30 across the five profiles. Header metadata-arrival and process disabled-action regressions have red-to-green unit evidence. Process spacing, VIS-002, responsive defects, and detailed states remain open; these focused checks do not replace the final full release gate.
|
||||
|
||||
Query-menu checkpoint: full unit suite 174 files / 1765 tests passes. Static export and standalone webpack builds/typecheck, lint (0 errors, 5 existing warnings), license and bundle checks pass. Mocked E2E passes 102 tests with two existing skips; its advanced-filter helper now explicitly selects Advanced after opening Filter, matching the restored mode-preserving behavior. This corrects the 13 stale-helper failures in CI run 36501112971 at pushed head `75044c1`. Real-backend CI run 36501113002 on that head was still running at this checkpoint. Final all-surface visual review and release verification remain incomplete.
|
||||
|
||||
Mocked-suite follow-up: the two shortcut-suppression checks were skipping when the lazy-loaded query input had not appeared yet. They now wait for the input, verify the typed character, and assert the global dialog stays closed. Full mocked E2E passes **104/104, no skips** (`query-menus-mocked-e2e-final.log`). Menu checkpoint `5cb2f5a` is pushed; real-backend run 36501934828 and CI run 36501934819 are in progress. The previous real-backend run was cancelled by the new push, so it is not completion evidence. The test-only follow-up stays local until that run finishes or another necessary source checkpoint is ready.
|
||||
|
||||
Spacing review checkpoint: redundant desktop filter heading and nested padding removed; mode/sort controls share the header, while the phone sheet retains its accessible title. Process-hosted labels, text and non-card widget headers use compact spacing. Existing explicit metadata styling and coarse-pointer targets remain. Preview refreshed on port18769; screenshot evidence and `spacing-*.log` files are in the root evidence directory. The populated query still has both a preview and editable conditions; further hierarchy/mode-state review remains open. Tablet tab labels and phone Big Number context defects were reproduced with DOM measurements, and browser-only CSS trials confirmed their causes. Source corrections and regression assertions are the next checkpoint.
|
||||
|
||||
Responsive checkpoint: record tabs now use content-based flex sizing, preventing labels from drawing outside their buttons; the tab bar retains horizontal scrolling for larger sets of sections. Composite rows with space-between alignment wrap when needed, and Big Number widths stay within their available container. Explicit style overrides remain effective. `responsive-red.log` proves the new bounds assertions fail for clipped phone number context and tablet tab labels before the correction; `responsive-green.log` passes all18 applicable cases across5profiles (WID023/WID040/REC004). Production export/typecheck, affected ESLint and diffcheck pass. Fresh desktop/tablet/phone record, sample-widget, block and parent captures are stored in the manifests. Desktop sample-widget arrangement is preserved; phone context now wraps within the card. Both image architecture builds are green remotely; full real-backend CI36501934828 remains in progress.
|
||||
|
||||
Review inventory now has41scenarios at3sizes; the historical report-filter and quick-filter editors are unavailable and are explicitly recorded as non-equivalent captures. Populated condition and nested-group states were inspected at all3sizes, plus current quick-filter editing. Phone block/parent scroll viewports1 were compared with the reference and retain their content layout. Remaining work still includes all other scroll sequences, additional interaction states, themes/focus, mode/preview hierarchy, long table headings, remaining functional gaps, and final release gates.
|
||||
|
||||
|
||||
## Report preview and CI diagnosis checkpoint — September 28
|
||||
|
||||
The report view now shares the editor's paged, sortable, resizable grid. Preview state stays local; saved column widths are preserved. The report tests pass **15/15 across all five profiles**, including database before/after comparison proving that preview interactions do not change the saved report. The latest phone report capture shows contained pagination with an unbroken page indicator. Selected Basic/Advanced controls use solid theme primary/foreground tokens; measured dark-theme contrast is **6.70:1**. These changes do not close the remaining embedded-report control gaps.
|
||||
|
||||
The remote full Firefox/WebKit jobs failed. A shared security-fixture classpath correction resolves missing middleware API classes: **124/124** security, no-search, and process-field-widget checks now pass locally across those two browsers. Other failures include stale control-location assertions, fixture catalog changes, and a real column-filter defect that adds a condition while leaving its editor hidden in Basic mode. The latter now opens Advanced directly; the rebuilt export/typecheck passes and affected browser verification is running. General Filter opening still preserves mode. Final whole-suite release verification and the full visual review remain open.
|
||||
|
||||
|
||||
Visual state follow-up: paired form-validation and empty-query captures were added and inspected on desktop, tablet and phone, increasing the inventory to 43 scenarios. Empty-query composition and validation-field layout retain the earlier Next design. Form heading copy differs (Creating New Person versus Create Person), and configured currency adornments remain visible. Phone Field Lab scroll captures 1–6 were compared: field spacing, rich text, password/file controls and later metadata sections retain the original layout. These captures do not approve picker/editor interactions or all form states.
|
||||
|
||||
CI diagnosis follow-up: the column Filter action now opens Advanced so its newly added condition is visible; general Filter and keyboard opening preserve mode. Acceptance assertions follow the restored section headings, secondary action grouping, 150px default widths and statistics menus while retaining metadata-width and permission checks. Script revision tests now select required API metadata and assert the API line separately from author/date. The phone menu test uses actual taps: Firefox with touch emulation emitted no pointerdown for a synthetic mouse click, while four diagnostic touch taps opened the menu successfully.
|
||||
|
||||
Checkpoint verification: `recovery-five-profile.log` records 140 passes and five failures at the outdated author/date selector; `script-api-author-green.log` passes those five corrected cases. `recovery-full-unit.log` has 1765 passes. Final TypeScript and changed-file lint pass; source lint has zero errors and five existing warnings. License headers, diff check and bundle budget pass. Current preview port 18769 was refreshed with this export and responds 200.
|
||||
|
||||
Final old-run diagnosis: the Chromium/mobile/tablet log contains the same corrected failures plus two mobile export assertions receiving an intersection ratio of 0.9999999403953552 instead of exactly 1, and one WebKit internal navigation error. Viewport checks now measure all four edges with a half-CSS-pixel rounding tolerance; the remaining three-profile security/export rerun passed **184/184**, including the previously failed session-switch case. Together with the Firefox/WebKit run, the security classpath fix is verified on all five profiles. Validation error text was measured at 4.58:1 in light mode and 5.01:1 in dark mode on the sample form background.
|
||||
|
||||
Dark validation follow-up: phone and tablet comparisons retain the baseline field hierarchy, but both builds had nearly invisible native date-picker icons because no browser color-scheme was selected. The stylesheet now assigns light/dark color-scheme with the existing theme selectors; rendered verification passes in Chromium, Firefox and WebKit for both light and dark, with the OS preference deliberately opposite. A fresh tablet capture confirms the calendar icon is visible. Saved-view/export/metadata-theme checks pass **40/40 across all five profiles** after the viewport assertion correction.
|
||||
|
||||
|
||||
## Query controls and gallery checkpoint — September 28
|
||||
|
||||
The light capture inventory now has **51 scenarios at each of three sizes**. New states cover density menu/compact/comfortable, cards, Selection menu, subset dialog, selected rows, and Save View As. Selection/density and both dialogs also have paired dark captures at all three sizes. Inspected evidence includes desktop card layout, phone selected cards, phone/tablet Save View As, and light/dark phone menus. These preserve the reference composition; review of the remaining states continues. Captures alone do not approve the entire query family.
|
||||
|
||||
**VIS-009 (medium):** Selection extends beyond the phone viewport; Selection and Density lack arrow-key navigation, and Density ignores Escape. Browser diagnostics reproduce these failures before the change. Both now use the existing Radix dropdown primitive with the original control styling and actions, bounded placement, keyboard selection, dismissal, and focus return. The selected Density label had 2.43:1 dark-mode contrast; it now uses the same primary/foreground pair as Basic/Advanced. The subset dialog restores focus to Selection.
|
||||
|
||||
Verification: 1765/1765 unit tests; 30/30 real-backend density/selection/bulk/card cases across Chromium, Firefox, WebKit, phone and tablet; production export/typecheck and targeted lint; license and bundle budget (1032 KB gzip). The first acceptance run passed29 and failed the Firefox dedicated phone case because synthetic mouse input did not open the Radix trigger; the corrected test uses actual taps and asserts menu bounds. Exact diagnostics are query-menu-red.log/query-menu-green.log; final acceptance is query-controls-acceptance-green.log.
|
||||
|
||||
The gallery previously missed multi-profile capture manifests. They are now merged into canonical per-profile manifests, future captures update those manifests, and a theme selector exposes light/dark evidence. Browser checks verify both comparison images load at all six theme/size combinations. Save-dialog capture setup now uses the visible Save As action (the separate save-as hook only exists for an existing saved view).
|
||||
|
||||
**Still open:** tablet headings truncate more than the baseline because a hidden sort glyph, touch-sized column menu, and cell padding consume label space. Compare tablet query-save-dialog background and desktop query-density-menu. Preserve configured widths and touch targets when correcting this. Full query state review, all other families, remaining functional ledger gaps and final release gates remain open.
|
||||
|
||||
Final menu evidence: selected Density contrast is **6.70:1** in dark mode (`density-contrast-green.log`), with the final phone capture inspected. The final export/typecheck, changed-file lint, license and bundle checks pass. A further **10/10** density/selection checks pass across all five profiles on the final build, including explicit focus return after the subset dialog closes (`query-controls-final-acceptance.log`). Current preview was refreshed from this export.
|
||||
|
||||
|
||||
## Query headings, states and WebKit checkpoint — September 28
|
||||
|
||||
**VIS-010 (medium), corrected:** hidden unsorted-arrow space plus menu targets and cell padding clipped First Name/Last Name on touch layouts and Annual Salary on desktop. The header now reserves an arrow only for an active sort, reclaims trailing padding next to its menu, and permits two-line touch labels within the existing header height. Default widths remain150px; explicit SIZE widths and resize remain effective; sort/menu targets stay separate and touch menus remain44px. Browser measurements confirm the four tested Person labels fit without increasing the header height (37.5px desktop,60.5px touch). The new assertion failed on all3 initial profiles before the change; **45/45** affected checks pass on all5 profiles afterward, including sorted headings, sorting, resize, column menus, statistics and explicit metadata widths.
|
||||
|
||||
Added paired query statistics, loading and backend-error captures at desktop/tablet/phone. Loading deliberately holds the query response; it is a layout check, not evidence of backend latency. Desktop statistics retains the original arrangement with an added Export button. Phone statistics has a more crowded title beside the added action and remains a visual follow-up. Error-state composition is retained; the corrected dirty-view toolbar grouping remains contained. Column menus and relative-date controls did not exist in the original reference, so those baseline captures are marked non-equivalent. Inventory reaches56 light scenarios with the relative-date menu follow-up.
|
||||
|
||||
**VIS-011 (high), corrected:** full CI and local WebKit reproduced the relative-date menu extending above the viewport, leaving7daysago unreachable. The menu now caps its height by both70dvh and Radix's available placement height, with a viewport width bound. Acceptance asserts the menu's actual bounds before choosing a date. **25/25** date, phone saved-view/export, restriction and report-sharing cases pass across all5profiles after the correction.
|
||||
|
||||
Full remote WebKit job109212960890 finished **509passed/4failed** at93b7b5a. Besides the reproduced relative-date issue, failures were a saved-view touch trigger that stayed closed and two document-navigation diagnostic cases (SEC010 andRPT017). Traces place those API errors39–54ms after a full navigation began while initial reads were still starting; those two tests now let initial reads settle before deliberate document replacement, with API/security assertions unchanged. Saved-view opening now has an immediate expanded-state assertion, but its intermittent CI failure is **not declared resolved** by the focused pass. Other full-CI jobs remain in progress at last check. Artifacts/logs retained under ci-webkit-36507768282 and ci-36507768282-webkit.log.
|
||||
|
||||
Verification: query-heading-red.log then query-heading-green.log45passes; query-heading-measurements.log; query-heading-date-unit.log1765passes; relative-date-and-ci-green.log25passes. Production export/typecheck, changed-file lint, license, bundle anddiff checks pass. Current preview contains the heading/date fixes. Final full acceptance and the complete visual review remain open.
|
||||
|
||||
## September 29 — report controls and statistics checkpoint
|
||||
|
||||
The current preview has been refreshed from the production export including report Basic/Advanced modes, saved-view selection, refresh, density, column configuration and column actions. Existing editor tabs and Cancel → OK → host Save remain. Read-only preview interactions stay local. These additions use the existing Next controls. The original report filter editor was absent, so it has no equivalent baseline state. Light inventory is now 60 scenarios at each of three sizes; captures are not full-family approval.
|
||||
|
||||
Paired statistics screenshots revealed that adding Export squeezed the phone title into three lines. A wrapping header now gives the title its own phone row while keeping desktop actions alongside it. The rendered check changed from 84px (three lines) to 28px (one line) at 393px width. Fresh original/current captures exist at all three sizes. The inspected dark report states include desktop Advanced, tablet Basic, and phone Columns/Density/column menu: popups fit the viewport and the footer remains visible; phone preview pagination requires scrolling the modal body. Dark blue links remain a contrast investigation. No whole visual family is signed off.
|
||||
|
||||
Evidence: report-controls-corrected-acceptance.log has 90/90 cases across five profiles, including draft save/cancel, saved-view import, API-versioned refresh, paging/sort/resize/pins, copy full query, nested statistics, headings and previously intermittent menu/schedule cases. The earlier expanded run used a Person fixture without QUERY_STATS and failed those five statistics cases; corrected qryItem fixtures passed in the clean run. Source build/type checking passed. Latest remote full acceptance at6439944 remains failed; local focused success does not replace that release gate. Touch-menu tests now attach native input/state diagnostics on failure, and schedule tests assert CSV selection before submission.
|
||||
|
||||
## September 29 — dark-mode and form-title recovery
|
||||
|
||||
The report link contrast defect reproduced at 2.58:1 in a browser accessibility scan. Separating primary text from branded fills raised the measured sample link contrast to 8.83:1. Broader tests with OS preference opposite the selected UI mode exposed three more defects: unbranded dark primary buttons at 3.23:1, a record badge following OS dark mode on a light page, and dark table boolean values using a dark green. Tailwind dark variants now follow ThemeProvider's class; unbranded dark fills use the readable default blue, while true boolean values use a brighter green on dark surfaces. Light styling and explicit application theme rules remain. 53 checks across five profiles passed, covering both UI modes, default/brand themes, the six main accessibility routes, grids, record layouts and widgets.
|
||||
|
||||
Compared dark phone record/form entry states, all seven Field Lab phone captures, tablet process widgets, and desktop widget overview against the original Next build. Form geometry remains aligned; native date/time icons and link contrast are corrected. The Create heading still said Creating New: restored the original Create wording and retained CSS hooks, heading overrides and accessible Edit/Copy record labels. The parity row now accurately records this intentional Next presentation (733Done,16DifferentUX,5Partial,2Missing,38NA).
|
||||
|
||||
Evidence correction: widget long-page captures revealed duplicate near-top images because document smooth scrolling had not completed. The capture script now targets the actual main content scroller, uses instant scrolling and verifies actual versus requested position before each screenshot, recording positions in manifests. Scrolling the document could move the application out of view and produce blank evidence. Earlier numbered scroll captures without verified positions must not be counted as complete scroll review; affected long-page evidence is being regenerated. No whole visual family is signed off.
|
||||
|
||||
|
||||
## September 29 — chart restoration verified
|
||||
|
||||
Long-page comparison confirmed two presentation changes: stacked charts moved their value axis to the right, and small line charts gained an axis and grid. The original left axis and compact small-line presentation are restored. Integer ticks, dataset tooltips, metadata colors, legend toggles, navigation and accessible data remain. WID-069 and WID-010 pass 25/25 across Chromium, Firefox, WebKit, phone and tablet; visual-recovery unit tests pass 1768/1768, production export/typecheck succeeds and lint reports zero errors/five existing warnings. The ledger now records these intentional Next differences (731 Done, 18 Different UX, 5 Partial, 2 Missing, 38 N/A).
|
||||
|
||||
The current preview was refreshed from that export. Dark form-field and sample-widget captures were regenerated against the original at three sizes with verified main scroll positions. Phone lower-page comparisons confirm the compact line presentation, HTML/Stepper layout and chart card structure; current legend controls and the As of date add content/height. Remaining label-rotation differences need review. No whole family is signed off.
|
||||
|
||||
Remote c442613 WebKit completed with 513 passes and three failures: Annual Salary header clipping, Selection touch opening and Views touch opening. Its native input diagnostics are being retrieved. General CI and image builds pass; the other full acceptance jobs are still running. Focused local passes do not close these remote failures.
|
||||
|
||||
|
||||
## September 29 — CI menu and header diagnosis
|
||||
|
||||
The completed c442613 runs report Firefox 515 passes/one heading failure and WebKit 513 passes/three failures (heading plus Selection/Views). The Chromium/phone/tablet job remains in progress. Native WebKit input attachments prove the two menus open on pointer-down and close after the same tap's compatibility click. A local probe reproduces dismissal when focus returns to the trigger; the regression unit test failed before the correction. Selection and Views now exclude their own trigger from outside-interaction dismissal, while outside clicks, Escape, selection and focus return remain supported. The regression unit passes and 30 real-backend menu/basic/saved-view checks pass across five profiles, including explicit trigger-focus retention. The full remote suite must still verify this correction.
|
||||
|
||||
The heading failure is font-dependent: the application renders with its system/configured font, and the original two-pixel spacing adjustment did not fit the wider Linux font. A local Verdana probe reproduced 13px of clipping at the preserved150px width; allowing two lines removed clipping. Header labels now use the existing two-line limit on desktop as well as touch. The wider-font browser regression failed before the change;20/20 heading/sorting/shared-view checks now pass across five profiles. Affected grid/menu unit tests17/17, production export/typecheck, changed-file lint and diff checks pass; the full unit suite immediately before the CSS-only header correction passed1769/1769. Bundle budget passes at1031.5KB gzip of1050KB. Logs: menu-trigger-red/green/unit/acceptance, header-font-probe/red/acceptance/build/unit/lint, visual-recovery-final-bundle.
|
||||
|
||||
Current preview includes theme, Create-title, chart, menu and header corrections. Dark tablet Field Lab entry and phone record-person lower viewport were compared with the original and retain field/section geometry; date icons and primary-link contrast are corrected. Desktop/tablet sample-widget lower viewports retain card layout and the compact small chart; legend controls and conditional category rotation still account for visible differences. Complete chart-label review and all other open family/state reviews remain required.
|
||||
|
||||
|
||||
## September 29 — chart labels and single-list filter editing
|
||||
|
||||
**VIS-012, corrected:** the chart rotation heuristic treated every character as 6.5px wide and divided plot width by label count, turning month/fruit labels that fit horizontally in the original Next charts. SVG measurements show neighboring labels fit at desktop, tablet and phone widths. The category axis now measures actual glyph widths and tick positions, remeasuring on chart updates and font loading; long labels retain rotation and bounded axis space. The new WID-009 browser assertion failed before the correction; 40/40 chart data/legend/tooltip/rotation cases pass across all five profiles. Live measurements in chart-label-red.log/chart-label-green.log demonstrate horizontal labels with positive gaps afterward.
|
||||
|
||||
**VIS-013, corrected:** Advanced conditions appeared twice (preview chips plus editable rows), with two Clear actions opening the same dialog. The earlier Next single editable list is restored. Each row still deletes its own condition; one footer Clear action still confirms and retains sorting. Basic mode retains its quick filters and header Clear action. The now-unused duplicate component/test were removed; the replacement unit checks the single-list layout, and QRY-083 now compares SQL results for two conditions before deleting one and proving the second remains active. This is recorded as Different UX in the parity ledger (730 Done, 19 Different UX, 5 Partial, 2 Missing, 38 N/A). Required acceptance coverage remains enabled.
|
||||
|
||||
The new layout unit failed before the correction. Full unit verification passes 1768/1768 in 173 files (one duplicate-component test removed); source lint has zero errors/five existing warnings and license headers pass. The first build caught a Testing Library assertion using Playwright's exact option; that test-only typing error was corrected and the final production export/typecheck passes. Filter acceptance passes 55/55 across five profiles, covering mode switching, quick filters, saved views, per-condition deletion, confirmed clear, sort retention, field validation, dates, boolean/blob and long-text operators.
|
||||
|
||||
Fresh original/current captures cover populated/nested filters and sample widgets in both themes at all three sizes. Inspected light desktop/phone populated filters, tablet nested filters, dark phone populated filters, and restored desktop/phone chart labels. Single-list geometry is recovered; Basic/Advanced and sort controls remain necessary additions. Current quick-filter editing is captured; its baseline explicitly states that the control did not exist. All captured long-page positions are verified. No complete family is signed off yet.
|
||||
|
||||
Full c442613 acceptance 36549979322 is now terminal/failing: Chromium 515/1, phone 490/1, tablet 486/5, Firefox 515/1, WebKit 513/3. Besides heading/menu failures corrected in 5077c78, phone/tablet RPT-009 reproduced an undersized standalone report link (146x20); it now has a 44px target. The two tablet navigation traces show deliberate document replacements overlapping sidebar prefetch requests; those tests now wait for initial reads to finish. No diagnostics or assertions were waived, and their intermittent CI outcome still requires the next full run. Exact artifacts and logs remain in the root visual-review evidence directory.
|
||||
|
||||
Final follow-up verification: nested-and-ci-acceptance.log reproduced the report touch failure on phone and tablet (26 passes/two failures). After correction, nested-and-ci-green.log passes 28/28 across five profiles, including nested AND/OR filtering, lookups, owned/shared records and report touch targets. Affected report/filter units pass 21/21. Final production export/typecheck, changed-file lint, diff check and bundle budget pass (1031.3 KB gzip / 1050 KB). The completed old CI run remains failed; these local passes do not substitute for the next full run.
|
||||
|
||||
|
||||
## September 29 — expanded process review and saved-profile restoration
|
||||
|
||||
Added paired light/dark captures at desktop, tablet and phone for expanded help, required-field errors, component review/download, wizard details/validation/confirmation, cancel dialog, running progress, user-facing error, scanner, state-machine entry, widget result, bulk file mapping, value mapping, review and completed validation. All 192 new combinations have clean setup/runtime capture status and no document-wide horizontal overflow. Scroll captures record and verify main-content positions. The first required-field selectors were too strict about labels or matched a containing section; the final captures use the actual field roles. Completed bulk validation is identified by its result text, since the original did not expose the newer status hook.
|
||||
|
||||
Inspected pairs include desktop component review and both bulk-mapping viewports; phone required-field error and footer, wizard confirmation, cancellation, progress, scanner, error, loop and widget result; dark desktop bulk mapping, dark tablet value mapping and wizard validation, and dark phone bulk-mapping footer and cancellation. These process compositions retain the original Next arrangement. Added controls and readable dark primary text remain. More process variants, saved-profile menu/dialog states, internal-error detail, bulk selection/results and composite modal states still need visual review; this is not full process-family approval.
|
||||
|
||||
**VIS-014 (medium), corrected:** the original bordered Saved Bulk Load Profiles section, heading and visible current-profile status were removed during the profile-action expansion. Restored that section in the shared component. Restored the original `showCurrent` option so file/value mapping identifies the applied profile while the final review summary avoids duplicate status. Current menu, owner permissions, reset/rename/save/delete behavior, help, typed value mapping and searchable fields remain. The extra actions can increase phone height; verified scrolling reaches the footer. Unit assertions first failed for the missing named region and for missing value-step status, then passed after correction.
|
||||
|
||||
**VIS-008 clarification:** browser measurements show Reload occupies the same header row as the title. It increases a compact widget header to 44px on touch devices (24px on desktop); this is the touch-target requirement, not an accidental separate row. The hosted ad hoc body is 2.5px shorter than the original in the fixture. Preserve the usable controls; further composite-specific states remain open. Evidence: `process-layout-probe.mjs` and `process-layout-before.log`.
|
||||
|
||||
Verification: `bulk-profile-panel-acceptance.log` passes45/45 across five profiles; after the final value-step correction, `bulk-profile-final-acceptance.log` passes25/25 including upload/value mapping/review and profile actions. Final full units1768/1768 in173files (`bulk-profile-final-unit.log`), affected units10/10, export/typecheck, changed-file lint, license headers, diff check and production bundle budget pass (1031.4KB gzip/1050KB). Capture logs: `process-states-light`, `process-states-followup`, `process-states-dark-baseline`, `process-states-dark-current`, `process-bulk-restored-light`, and `process-values-final-{light,dark}`. Canonical manifests supersede failed interim captures and orphaned images.
|
||||
|
||||
|
||||
## September 29 — profile menus, shell overlays and Preferences
|
||||
|
||||
**VIS-015, corrected:** the expanded profile menu lacked arrow-key navigation and overflowed the tablet viewport with eight profiles (bottom1105px in a1080px viewport). It now uses the existing Radix dropdown primitive with collision placement and available-height scrolling. Existing menu contents, ownership restrictions, confirmations, status and backend behavior remain. Dialog focus returns to the actual opener, including WebKit clicks that activate without focusing; menu actions return to the persistent menu trigger. Native probes verify desktop/tablet/phone bounds and focus. Export now guards focus returning to its own trigger, fixing the native WebKit tap/focus dismissal reproduced by CI. Outside dismissal and download assertions remain.
|
||||
|
||||
The initial profile/browser follow-up produced27passes/3focus failures; actual-opener handling corrected those. A later34pass/1fail run exposed a separate Home/Enter test race. Trace shows Home at77867.082ms and Enter at77869.872ms; the resulting page had selected Review Profile8. Installed Radix roving-focus source defers focus with setTimeout. Waiting for the expected first menu item preserves and strengthens the keyboard check. `menu-home-focus-acceptance.log` passes35/35 across all profiles.
|
||||
|
||||
Added seven shell states in both themes at three sizes:84 clean captures. Inspected desktop Preferences/search results/command, phone navigation/user menu/Preferences/help/search results/command, tablet help, dark tablet Preferences and dark phone user menu/search results/command. Original navigation, help, user-menu and search geometry is retained; the command palette includes new table actions within its original shell. Preferences' default selection changes from25 to50 records through current metadata/default behavior. These states do not approve notifications, login/session, all branding variants, internal dialog scroll positions or the whole shell family.
|
||||
|
||||
**VIS-016, corrected:** Preferences used z-50 behind the phone sidebar's z-100; its controls were visually covered. It now uses `--qqq-z-overlay` and `--qqq-z-modal`. Closing returns focus to the user-menu button because the Preferences menu item unmounts on selection. The new phone hit-test assertion failed before correction; a unit focus-return assertion also failed. Both pass after correction, and fresh light/dark phone screenshots show the full dialog above the drawer without changing its internal layout.
|
||||
|
||||
Final verification: `preferences-menu-final-acceptance.log` passes50/50 across Chromium, Firefox, WebKit, mobile and tablet, including drawer navigation/focus, profile actions and Export. `preferences-final-unit.log` passes1771/1771 in173files. Final production export/typecheck, changed-file lint, license headers, diff check and bundle1031.7KB/1050KB pass. A test-only Playwright `exact` option was removed from Testing Library before the final build. Capture logs are `shell-states-{light,dark}` and `preferences-restored-{light,dark}`. No whole family is approved and the full remote gate remains incomplete.
|
||||
|
||||
|
||||
## September 29 — inline profile naming recovery
|
||||
|
||||
**VIS-017, corrected:** Save New, Save As and Rename had replaced the earlier inline Profile Name field with a centered modal. Restored the editor inside the named, bordered profile section using the original compact input and bordered Save Profile/Cancel buttons. No nested form is introduced: Enter saves the profile and stops propagation so the enclosing bulk-load process is not submitted. Escape/Cancel close the editor; saving restores focus to the actual opener or the persistent menu trigger if the opener disappears when the current profile changes. Ownership checks, backend name limits/errors, copy semantics, update confirmation and explicit delete behavior remain.
|
||||
|
||||
The inline-region and keyboard regression assertions first failed2/11, then passed11/11. Full units pass1771/1771. The final success-focus assertion also passes in the affected11tests. `profile-inline-acceptance.log` passes40/40 across all5profiles (PRC031/046/058/059/060), covering required/file/value mapping, profile CRUD, duplicate-name server validation, shared ownership, menu bounds and focus. PRC046 retained stale local-panel locators after the menu moved to a portal; those now find the portaled menu while all SQL and behavioral assertions remain. Production export, final typecheck/lint, license and bundle1031.9KB/1050KB pass. The first budget-report write was denied by the sandbox; the authorized rerun completed successfully.
|
||||
|
||||
Fresh `profile-inline-{light,dark}` captures replace the modal snapshots at desktop/tablet/phone. All12entries have clean setup/runtime status and no body horizontal overflow. Inspected light desktop, light/dark phone and dark tablet pairs: editor input/buttons match the original arrangement within the restored section. Expanded profile actions/reset controls still add height and remain part of the broader review. No full family is approved. The ledger explicitly records inline naming as Different UX; its existing-row recount corrects795total/51supplemental rows and the stale OS-driven dark-mode description.
|
||||
|
||||
|
||||
## September 29 — Copy alignment, selected sections and touch selection
|
||||
|
||||
The bounded record/form review inspected 210 existing images against original Next commit `e42ad2b2`. It found two concrete regressions: **RF-001**, Copy scope controls aligned to a wider container than the editable form; **RF-002**, a selected record section inherited Overview's half-width card and full-width field defaults. The Copy container now shares `max-w-4xl` with EntityForm. Selected tabs use a full-width fallback card and two field columns from `sm`; explicit metadata spans retain precedence. Overview and phone accordion defaults are preserved.
|
||||
|
||||
Both browser layout regressions failed on the old export, then passed 10/10 across Chromium, Firefox, WebKit, phone and tablet. Existing record view and create/edit/copy workflows passed 17/17 in Chromium. Full units 1,840/1,840, production export, types, changed-source lint, license headers and bundle budget 1,038 KB/1,050 KB pass. Independent code review found no additional issues.
|
||||
|
||||
**Touch selection:** the 44px selection label exceeded the 44px pinned selection cell because density padding was still applied. Removing horizontal selection-cell padding only for coarse pointers exposes both target edges. Browser geometry reproduced the overlap before the correction; 35 focused checks pass across all 5 profiles, covering both edges, every density and existing selection workflows. The first post-fix test attempt used coordinates from before the selection banner moved the grid; the corrected test remeasures after selection and uses native touch events for touch menus. Failed traces remain preserved.
|
||||
|
||||
Refreshed current captures for Copy, selected section, record lab, audit and populated query in both themes at all 3 sizes: 30 states/40 images, no capture/runtime errors or body overflow. This also replaces the stale light record lab/audit evidence. Root inspection confirms desktop light Copy/selectedsection and tablet touch query. All 34 refreshed record/form images have now been directly reviewed, closing RF-001, RF-002 and the stale-capture gap. The exact-path ledger is `record-form-review-d937507.md` in the ignored evidence directory. The fixed 210-image record/form inventory is closed; other visual families are still open. Logs/evidence: `record-default-layout-{red,green,build,units}.log`, `record-layout-workflows.log`, `query-selection-targets-final.log`, `layout-restored-{light,dark}.log`, and release-worktree `test-results/acceptance/{selection-target-evidence,record-default-layout-evidence}`.
|
||||
|
||||
**Publication hold:** the owner is testing immutable RC1 in real applications for a couple of weeks. These corrections are post-RC1 branch changes. Final publication requires renewed explicit owner approval; see [testing period](../releases/1.0-testing.md).
|
||||
|
||||
|
||||
## September 29 — query and shell fixed inventories reviewed
|
||||
|
||||
The query inventory is fully inspected: **348 images**, covering all 28 existing states in light/dark at desktop/tablet/phone sizes, including scroll positions. The reviewer directly inspected 314 images and retained 34 explicitly attributed root inspections. No new unresolved visual regression was found. Five original controls are unavailable for equivalent comparison; the ledger distinguishes those context images. Older selection-padding appearances are historical; refreshed populated-query images and the separate 35-case interaction gate cover the correction.
|
||||
|
||||
The seven-state shell inventory is fully inspected: **92 images**, comprising 84 initial-position images and eight Preferences scroll captures. Root directly inspected 64 images and retained 28 prior inspections. No new unresolved shell visual regression was found. All six current Preferences scroll captures expose the lower table/record layout choices, with the footer separate. Original search/help/navigation geometry is retained; added theme choices and command actions remain inside the existing layouts. Shell source has not changed between d937507 and e87fe90. Screenshots do not establish persistence for every preference.
|
||||
|
||||
Exact-path ledgers are `query-review-e87fe90.md` and `shell-review-e87fe90.md` in the ignored `test-results/visual-review` evidence directory. This closes those fixed inventories, alongside the previously closed record/form inventory. Process, report and widget review remains open; notifications, login/session and branding variants are not approved by this shell review. RC2 is in preparation and final 1.0 remains held for owner testing.
|
||||
|
||||
|
||||
## September 29 — report fixed inventory inspected
|
||||
|
||||
All **262 report images** in the six per-profile canonical manifests were directly inspected: 131 in each theme (desktop 40, tablet 41, phone 50). This corrects the older combined light manifest, which omitted later states. The exact-path ledger `report-review-defb3ab.md` has no missing or extra images and records no confirmed new visual regression. An initial concern about blank output-format text was withdrawn after direct focused reinspection.
|
||||
|
||||
This is inspection of the existing images, not a claim that all captures represent defb3ab: only 10 of 216 state entries have timestamps/export fingerprints. Fifty-four original editor-state entries lack an equivalent control, and the initial phone Advanced/Columns captures do not show the lower pager. Those comparison and provenance limits remain explicit. Embedded query equivalence and whole-product visual review remain open; final 1.0 is held.
|
||||
|
||||
|
||||
## September 29 — remaining fixed inventories inspected; child export corrected
|
||||
|
||||
All **1,621 unique images** in the six canonical per-profile manifests are now accounted for by completed inspection ledgers: record/forms 210, query 348, shell 92, process 402, report 262, widgets/dashboard 269 and developer/miscellaneous 38. Process review found no additional confirmed regression; explicit full-width upload metadata explains the wide upload control. Developer/miscellaneous content layout retains the original composition, with historical header truncation already addressed by VIS-007. Widget/dashboard review found the child export issue below and no other confirmed unresolved regression. Root independently reconciled the final 269 and 38 paths against the manifests and verified every file exists. Exact ledgers are `process-review-e87fe90.md`, `widgets-dashboard-review-2221999.md` and `developer-misc-review-2221999.md` in ignored evidence.
|
||||
|
||||
**Child-record export, corrected:** current child lists had a generic header icon unable to read the child payload plus a working Export control in a new toolbar row. Two component regressions and the real-backend fixture reproduced the duplicate. The first correction removed the generic icon but retained the extra row; fresh screenshots showed that still differed from original Next. The final correction reuses the child export control in the existing header position. Bare hosts retain inline export. Permission-filtered join columns, shown-row CSV, empty disabled state and the partial-result tooltip remain. Widget export controls follow the same loading/error/permission/required-selection gates as the body. The existing error boundary now covers header and body; Retry requests fresh data and a replacement payload clears the error. Review regressions reproduced and verified denied exports, malformed metadata containment and successful recovery.
|
||||
|
||||
Verification: **1,857 unit tests**, 41 focused widget tests, 10 real-backend child-list scenarios across five profiles, production export/types/lint/license/diff checks and 1038.4KB/1050KB bundle budget pass. Final browser checks verify one header export aligned with Reload, CSV contents against independent SQL, exclusion of the undisplayed third row, record navigation and empty lists. Desktop and phone captures were directly inspected: the extra toolbar row is gone. The first geometry check compared title and button centers and failed on touch because native targets are 44px and the header uses items-start; the final assertion requires Export inside the header and aligned with Reload. Failed evidence is retained; no CSS or target size was changed to pass it. Independent review has no remaining blocker. Logs use `child-export-*`; final evidence is `child-export-final-evidence` in the ignored root directory.
|
||||
|
||||
Inventory completion is **not** whole-product visual approval. Fifty-two gallery/table images repeat near-top content and cannot establish lower-page coverage. Six original choice-open states are unsupported. Most historical images lack build fingerprints; dark widget coverage only includes sample/typed-input/choice states, and developer/miscellaneous has no dark captures. Previously recorded uncaptured shell variants, report pager and provenance limitations remain. The original Next reference remains `e42ad2b2`; Material supplies behavior only. The owner preview remains unchanged while the owner validates it. Final 1.0 is explicitly held.
|
||||
|
||||
|
||||
## September 30: unpublished legacy icon compatibility follow-up
|
||||
|
||||
Existing Next Lucide mappings, widget overrides and image priority are preserved. Additional legacy names use the pinned local Material Icons inventory. Six formerly unmapped block icons, navigation and the raised main tile were checked in light/dark desktop/mobile views. Four workflows passed against the actual development JAR; its font and exported files were verified byte-for-byte. Saved desktop/mobile tile regions contain identical light/dark glyph pixels; the earlier suspected blank tile was an inspection error, and no repaint workaround was introduced.
|
||||
|
||||
The full local Chromium suite passed 589 tests and all 391 required rows, with zero failures, skips, flaky outcomes or report errors and three approved external-service exclusions. Independent code review approved the change. Evidence is retained under `test-results/visual-review/icon-compatibility-investigation/` in the original checkout. This checkpoint covers the icon change; whole-product visual approval remains open. RC5 is immutable and does not include this follow-up.
|
||||
|
||||
|
||||
## September 30: fresh RC6 dark desktop developer and miscellaneous review
|
||||
|
||||
Twelve original/current captures across app home, table developer, record developer and not-found pages were directly reviewed, including lower scroll positions. The fresh manifest records capture times and export fingerprints. No new layout regression was found in those states. Evidence: `test-results/visual-review/rc6-dark-developer-review/review.md` in the original checkout.
|
||||
|
||||
An inherited RapiDoc dark-link contrast defect is confirmed at 2.12:1 in both original and RC6; subsequent browser checks also find light-mode contact/authentication text below 4.5:1. A separate correction is being tested. This closes the missing desktop dark capture evidence for the four entry states; phone/tablet dark coverage, expanded endpoint interactions and whole-product approval remain open.
|
||||
|
||||
|
||||
## September 30 — phone developer view comparison
|
||||
|
||||
Eight fresh original/RC6 phone screenshots in light/dark were directly inspected (initial and lower scroll positions). Four capture entries had no runtime/state errors or body horizontal overflow. Evidence: `test-results/visual-review/rc6-phone-developer-review/` in the original checkout, including export fingerprints and provenance.
|
||||
|
||||
The main composition matches the original. RapiDoc's inner clipping is inherited: its 360px minimum exceeds the 343px phone panel, and hidden overflow clips controls. This remains open for a separate responsive correction. The added command button also reduces breadcrumb space; the trail scrolls to the current page, but header crowding remains a visual-review consideration. This is scoped evidence, not full visual approval.
|
||||
|
||||
## September 30 — API playground contrast follow-up
|
||||
|
||||
Original Next and RC6 both have an inherited dark playground link contrast defect (2.12:1). Focused browser checks also reproduced light contact/status contrast defects. An isolated follow-up applies existing theme tokens without changing layout. Four final desktop/mobile light/dark cases pass, including hovered controls and Preferences changes, with no failures, skips, flaky outcomes or report errors. All four final screenshots were directly inspected after transitions settled. Evidence is retained under `test-results/visual-review/api-playground-contrast/` in the original checkout.
|
||||
|
||||
This is scoped color verification, not whole-product visual approval. Narrow-screen inner clipping, expanded endpoint states and additional application palettes remain open. RC6 is unchanged.
|
||||
|
||||
## September 30 — API specification controls in narrow panels
|
||||
|
||||
Reproduced clipped specification buttons against the packaged RC7 candidate before any focus/click could conceal overflow by scrolling hidden ancestors. The follow-up lets RapiDoc fit its panel and uses a named overview container plus public CSS parts to wrap only the specification actions at narrow widths. It adds no dependency and does not mutate the shadow DOM.
|
||||
|
||||
Twelve real-backend desktop/mobile workflows pass: default device and 320px widths, light/dark themes, complete visibility, actual specification download, keyboard new-tab action, contrast and live Preferences changes. Final phone screenshots were directly inspected. At 1440px, the full playground screenshot is byte-identical to RC7 (SHA256 `ec6d108d5d632d1ee0b3a3ecd6c61ae9279bee705facd2a3b9e422d2e0f1ae37`), and overview/button geometry matches exactly.
|
||||
|
||||
All 1,904 units, types, lint/license, default production export and unchanged bundle budgets pass. Evidence, including earlier test/setup failures, is retained under original-checkout `test-results/visual-review/api-playground-width/`. This is a separate follow-up, not part of frozen RC7. Expanded endpoint states, additional application palettes and whole-product visual approval remain open.
|
||||
|
||||
|
||||
## September 30 — desktop and tablet process comparison
|
||||
|
||||
Compared original Next `e42ad2b2` with the actual RC8 candidate JAR in 32 light/dark desktop/tablet screenshots: bulk file mapping at two scroll positions, running process progress, and expanded internal-error details. Both builds used fresh isolated instances of the same fixture/backend. Capture manifests include export fingerprints and report zero runtime/state errors or body/main horizontal overflow.
|
||||
|
||||
Progress and error panel composition are retained. Bulk mapping visibly changes the saved-profile actions and Add Fields control and introduces explanatory text. Main scroll height increases from 1330 to 1390px on desktop and 1556 to 1748px on tablet. This added density/height remains an owner-review item. Tablet breadcrumb ancestors are more crowded with the added command control; the current page remains visible. Dark mode retains the existing composition with revised readable accent colors. No screen redesign was applied during this review.
|
||||
|
||||
Evidence: original-checkout `test-results/visual-review/rc8-desktop-tablet-process-review/`, including four inspected contact sheets and all 32 source images. This is scoped process evidence, not whole-product approval; the existing uncaptured states and compatibility gaps remain open.
|
||||
|
||||
|
||||
## RC8 lower widget review: desktop, tablet and phone
|
||||
|
||||
Original Next `e42ad2b2` and corrected RC8 `cd3d473` were compared on Widget Gallery and Widget Tables And Charts using the actual candidate JAR, matching fixtures and backend `e0d57dd31`. Evidence: `rc8-widget-lower-review/` in the visual-review archive. Desktop supplied 28 images; tablet/phone supplied 86 more, for 114 images across 24 entries in both themes. Twelve contact sheets were directly inspected. Every lower capture records and verifies its actual scroll position. All entries had zero runtime/state errors and no body/main horizontal overflow. This closes the earlier repeated-near-top capture gap for these two entry states across all three viewport sizes.
|
||||
|
||||
The card structure is retained. Recorded changes include map geography, formatted table cells, per-table export/footers, paging controls, metadata chart colors, readable long category labels, legends, connected line data and As Of timestamps. These additions increase page height, including an extra phone viewport on Tables And Charts. Long phone header text now truncates alongside the added command control. Dark map/plot contrast remains a review concern; this comparison did not measure contrast ratios or verify horizontally scrolled table columns. Expanded interaction states, other widget surfaces and owner visual approval remain open. No product code changed during this review.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Performance budgets (QRun-IO/qqq#710)
|
||||
|
||||
Two automated checks. A breach fails CI. To fix one, make the change smaller or faster. If a
|
||||
budget really has to rise, put the new measurement and the reason in the commit.
|
||||
|
||||
| Check | What it measures | Budget file | Runs in |
|
||||
|---|---|---|---|
|
||||
| Bundle budget | First-load JS per route of the static export, total JS, largest chunk, CSS | `perf-budget.json` | `ci.yml` job "Bundle budget (static export)": `pnpm build:export && pnpm perf:budget` |
|
||||
| Runtime budgets | Large table, heavy dashboard, long process in the production export against the real sample backend | `tests/acceptance/specs/performance/budgets.ts` | `acceptance.yml`: matrix rows PRF-001 to PRF-005 in every browser |
|
||||
|
||||
```bash
|
||||
pnpm build:export && pnpm perf:budget # bundle; report in test-results/performance/bundle.json
|
||||
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit node scripts/acceptance.mjs --skip-build specs/performance
|
||||
```
|
||||
|
||||
## Bundle
|
||||
|
||||
First-load JS for a route is every script and stylesheet that the route's exported HTML loads
|
||||
before hydration, gzip level 9. Next 16 no longer prints these sizes, so the script measures
|
||||
them from `out/`. `_` stands for a dynamic segment: `/app/_` is `/app/[slug]` (app home, record
|
||||
query, process, report) and `/app/_/_` is the record view.
|
||||
|
||||
| Route | Before (KB gzip) | 1.0 (KB gzip) | Budget |
|
||||
|---|---|---|---|
|
||||
| `/`, `/login`, `/callback`, `/token`, `/404` | 218–219 | 218–219 | 230 |
|
||||
| `/app`, `/app/developer`, `/app/search`, `/app/_/key`, `/app/_/dev`, `/app/_/_/dev` | 309–311 | 309–311 | 330 |
|
||||
| `/app/_` (app home, record query, process, report) | **562.9** | **448.2** | 470 |
|
||||
| `/app/_/_` (record view) | 431.6 | 431.5 | 455 |
|
||||
| `/app/_/create`, `/app/_/_/edit`, `/app/_/_/copy` | 351–356 | 351–356 | 370–375 |
|
||||
| `/app/_/savedView/_` | 371.2 | 371.1 | 390 |
|
||||
| Total JS (all chunks) | 689.2 in 54 chunks | 675.0 in 51 chunks | 710 |
|
||||
| Largest chunk (Recharts + d3, loaded only by chart widgets) | 93.8 | 93.8 | 100 |
|
||||
| CSS | 16 | 16 | 18 |
|
||||
|
||||
Regression fixed: `/app/[slug]` imported `AppHome` from the widgets barrel, and that barrel
|
||||
re-exports the chart widgets. Recharts was therefore in the first load of every table and
|
||||
process page, even though `WidgetRenderer` loads charts lazily. The page now imports its
|
||||
components by module path.
|
||||
|
||||
## Runtime
|
||||
|
||||
Fixture: `tests/acceptance/fixture/PerformanceFixtures.java`, all synthetic data.
|
||||
|
||||
- `prfWide`: 10,000 rows, 40 columns of mixed types, read-only.
|
||||
- `prfDashboard`: 24 widgets: 6 statistics, 6 bar, 4 line (60 points) and 4 pie charts, and
|
||||
4 tables of 100 rows.
|
||||
- `prfLongRun`: a job that walks all 10,000 rows in 40 pages with a 250 ms pause per page.
|
||||
|
||||
Each test measures wall time from the user action until the exact expected content is on
|
||||
screen, and records it as a `timing` annotation in `test-results/acceptance/report.json`.
|
||||
|
||||
| Row | Measurement | Budget (ms) | chromium | firefox | webkit |
|
||||
|---|---|---|---|---|---|
|
||||
| PRF-001 | Open the 10,000-row table until the first 25 rows × 40 columns show | 5,000 | 535 | 353 | 602 |
|
||||
| PRF-002 | Page size 25 → 250 (10,000 cells) | 10,000 | 462 | 665 | 738 |
|
||||
| PRF-002 | Next page at 250 rows | 10,000 | 636 | 969 | 1,188 |
|
||||
| PRF-002 | Last page (page 40) at 250 rows | 10,000 | 612 | 1,158 | 1,064 |
|
||||
| PRF-003 | Sort by Name over 10,000 rows | 3,000 | 155 | 234 | 299 |
|
||||
| PRF-003 | Filter Count 04 = 0 (40 matches) | 3,000 | 173 | 157 | 150 |
|
||||
| PRF-004 | Open the 24-widget dashboard until every widget has loaded | 14,000 | 1,086 | 1,567 | 1,760 |
|
||||
| PRF-005 | Job finished until its result screen shows (10.15 s job, 5 status polls) | 5,000 | 415 | 838 | 521 |
|
||||
|
||||
These numbers come from an otherwise idle run of the performance area on an Apple M3 Max
|
||||
(macOS, Darwin 27), Playwright 1.58.2. Each budget is about 8× the slowest browser's time.
|
||||
In a full four-project run with other acceptance suites on the same machine, the same steps
|
||||
took up to about 4× longer: Firefox next page 4,191 ms, dashboard 8,841 ms. Retries are 0,
|
||||
so a tighter budget would flake. The budgets catch order-of-magnitude regressions. The
|
||||
structural checks below catch smaller ones, and don't depend on machine load.
|
||||
|
||||
The rows also check behavior besides time:
|
||||
|
||||
- Only one page of rows is in the DOM: 25 or 250 rows × 40 cells, not the whole table.
|
||||
- The backend is asked for `skip`/`limit` 250 on each page.
|
||||
- Each dashboard widget's data is requested exactly once.
|
||||
- Status polls never overlap, and each poll waits at least 1.5 s (`POLL_INITIAL_MILLIS`)
|
||||
after the previous answer. The job never gets more polls than its length allows.
|
||||
|
||||
Grid rendering fixed for large pages: body rows are memoized. The grid used to re-render all
|
||||
10,000 cells before the page request was sent, and again for the URL sync. Date cells no
|
||||
longer build an `Intl.DateTimeFormat` per cell. A 250-row page turn in chromium, from click to
|
||||
rendered rows: 659–669 ms → 518–561 ms.
|
||||
|
||||
Observed, not changed here:
|
||||
|
||||
- Grid columns follow the field order of the metadata map, not the section order, and not
|
||||
Material's alphabetical-by-label order. For a 40-column table this looks unordered.
|
||||
- The pagination range is not locale-formatted, while the total is ("Showing 9751–10000 of
|
||||
10,000").
|
||||
@@ -0,0 +1,241 @@
|
||||
# Next UI agent guide
|
||||
|
||||
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
|
||||
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
|
||||
|
||||
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) at `12cf774`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
|
||||
|
||||
## Operating rule: metadata is the product boundary
|
||||
|
||||
QQQ's Java backend owns tables, fields, processes, apps, navigation, widgets, permissions, and human labels. The browser adapts to the metadata of the current user. Never make a QQQ application work by hardcoding its table names, field names, routes, or widget data in a React component. Add general rendering behavior and a real backend fixture, or change the backend metadata producer where the contract originates. Do not invent JSON keys because a frontend type accepts `Record<string, unknown>`; inspect the actual producer, versioned API response, and acceptance fixture.
|
||||
|
||||
The UI supports a **versioned** API at `/qqq/v1`. The acceptance diagnostics reject browser calls to legacy unversioned paths. All browser requests should use typed functions in [`src/lib/api`](../../src/lib/api); TanStack Query keys come from [`src/lib/query-client.ts`](../../src/lib/query-client.ts). Treat permission flags as display controls only; the backend must refuse the same forbidden operation.
|
||||
|
||||
## Find the right source before editing
|
||||
|
||||
| Need | Start here | Why |
|
||||
|---|---|
|
||||
| Instance, app, table, field, process, report, widget shapes | [`src/types/metadata.ts`](../../src/types/metadata.ts), [`src/types/enums.ts`](../../src/types/enums.ts) | Frontend contract and enumerated types. |
|
||||
| Backend HTTP contract | [`src/lib/api`](../../src/lib/api), [`src/lib/api/client.ts`](../../src/lib/api/client.ts) | Versioned routes, typed request/response handling, 401 behavior. |
|
||||
| Route and sidebar dispatch | [`src/app/(dashboard)/app/[slug]/page.tsx`](../../src/app/%28dashboard%29/app/%5Bslug%5D/page.tsx), [`use-routes.ts`](../../src/lib/hooks/use-routes.ts), [`use-route-params.ts`](../../src/lib/hooks/use-route-params.ts), [`legacy-paths.ts`](../../src/lib/utils/legacy-paths.ts) | Slug precedence, hidden entries, nested nav, static-export and Material deep links. |
|
||||
| Query and saved views | [`src/components/query`](../../src/components/query), [`use-record-query.ts`](../../src/lib/hooks/use-record-query.ts), [`use-saved-views.ts`](../../src/lib/hooks/use-saved-views.ts) | URL state, filters, variant, selection, backend saved views. |
|
||||
| Record display and forms | [`src/components/records`](../../src/components/records), [`src/components/forms`](../../src/components/forms), [`FormWidgetSection.tsx`](../../src/components/forms/FormWidgetSection.tsx), [`zod-from-metadata.ts`](../../src/lib/utils/zod-from-metadata.ts) | Sections, fields, form widget context, validation, adornments. |
|
||||
| Processes and reports | [`src/components/process`](../../src/components/process), [`ReportRun.tsx`](../../src/components/reports/ReportRun.tsx), [`src/lib/api/processes.ts`](../../src/lib/api/processes.ts) | Step components, state transitions, polling, files. |
|
||||
| Widget entry and dispatch | [`ConnectedWidget.tsx`](../../src/components/widgets/ConnectedWidget.tsx), [`WidgetBlock.tsx`](../../src/components/widgets/WidgetBlock.tsx), [`WidgetRenderer.tsx`](../../src/components/widgets/WidgetRenderer.tsx) | Fetch/params, chrome, data renderer. |
|
||||
| Widget payload details | [`src/components/widgets`](../../src/components/widgets), [`widget-types.ts`](../../src/components/widgets/widget-types.ts), [`src/mocks/fixtures/widgets`](../../src/mocks/fixtures/widgets) | Concrete canonical and demo shapes; demo fixtures are not production guarantees. |
|
||||
| Branding, analytics and CSS | [`material-theme.ts`](../../src/lib/theme/material-theme.ts), [`src/lib/theme`](../../src/lib/theme), [`src/lib/analytics`](../../src/lib/analytics), [`src/styles`](../../src/styles) | Supplemental Material theme, implemented tokens, analytics privacy and `data-qqq-id` customization hooks. |
|
||||
| Owned backend acceptance | [`tests/acceptance`](../../tests/acceptance), [`tests/acceptance/README.md`](../../tests/acceptance/README.md) | QQQ sample fixtures, browser projects, SQL assertions, diagnostics gate. |
|
||||
| Current gaps and risk | [Material parity ledger](../acceptance/material-parity.md), [security review](../security/next-ui-review.md), [browser matrix](../acceptance/browser-matrix.md) | What has actually been verified and what remains open. |
|
||||
|
||||
The repo's [implementation plans](../implementation-plans) explain intended architecture, but some examples predate the current code. Prefer source and executable acceptance evidence. In particular, current `package.json` uses Next.js 16 even though older `CLAUDE.md` and plans mention 15.
|
||||
|
||||
## Preserve the Next visual baseline
|
||||
|
||||
Use the September 25 Next design (`e42ad2b2`) as the visual reference. Use Material source to establish functional behavior and backend contracts. Changes to parity behavior must preserve the Next layout unless the user requests a visual change.
|
||||
|
||||
- Desktop query mode controls, sort and chips belong inside the Filter panel. On phones, Filter opens a sheet containing those same controls and preserves the mode; Advanced shows the nested builder inside it. Check [`RecordQuery.tsx`](../../src/components/query/RecordQuery.tsx).
|
||||
- Query column widths default to 150 px. Preserve `SIZE` adornment widths and saved/user-resized widths.
|
||||
- The record section sidebar requires literal `showRecordSidebar: true` in `supplementalMetaData.materialDashboard` (legacy fallback: `supplementalTableMetaData.materialDashboard`). An absent setting keeps it off.
|
||||
- T1 identity fields omit the primary key and values already contained in `recordLabel`, checking display values before raw values. Preserve the compact header and card field spacing in [`RecordViewHeader.tsx`](../../src/components/records/RecordViewHeader.tsx) and [`RecordView.tsx`](../../src/components/records/RecordView.tsx).
|
||||
- Desktop Copy/Delete remain direct actions unless the Actions menu offers that enabled action, including in a nested menu. Preserve accessibility when a custom menu omits or disables the action; see [`RecordActions.tsx`](../../src/components/records/RecordActions.tsx).
|
||||
- Preserve manual activation in `RecordViewTabs`: Left/Right/Home/End move focus, Enter/Space activates through the existing URL callback. A focused but inactive tab must not mount widgets. Keep one selected tab stop, unique tab/panel links and a focusable named panel. Phone accordion assertions should follow the trigger’s `aria-controls`; the nested section can legitimately have the same accessible name.
|
||||
- Create, edit and copy share the centered `max-w-4xl` container in [`EntityForm.tsx`](../../src/components/forms/EntityForm.tsx).
|
||||
- A fresh profile starts in light mode. A saved dark preference applies only without an application theme; an application theme keeps light mode active. Check [`theme-provider.tsx`](../../src/lib/theme/theme-provider.tsx).
|
||||
|
||||
- Preserve inline naming in `SavedBulkLoadProfiles`: New, Save As and Rename belong inside the original profile section. Prevent Enter from submitting the enclosing process form; retain ownership rules, backend validation and overwrite/delete confirmations.
|
||||
- Dialogs launched from the phone sidebar must use the existing `--qqq-z-overlay` and `--qqq-z-modal` layers. A hardcoded `z-50` puts them behind the drawer. Return focus to a persistent control when the opening menu item unmounts.
|
||||
- For Radix menus, check keyboard navigation, collision placement, available-height scrolling and native touch focus. Returning focus to the same trigger must not dismiss its newly opened menu; outside interactions must still dismiss it. Record a dialog's actual event opener because WebKit can activate buttons without focusing them.
|
||||
- Search-dialog checks must exercise empty and populated Tab cycles, Clear removing its own button, Escape and backdrop dismissal, keyboard reopening, pointer openers that browsers do not automatically focus, and close-before-autofocus cleanup. Build `pnpm build:export` before Javalin acceptance; `pnpm build` produces a separate Node artifact.
|
||||
- Browser keyboard checks must await the resulting focus before sending the next dependent key; Radix defers roving focus. Testing Library `getByRole` uses an exact string name by default and does not accept Playwright's `exact` option.
|
||||
- Standard report input-then-download flows use `compactReportInputs` in `ProcessStepScreen`: preserve the compact card, inline Generate Report action, and accessible form name. Keep the shared form and cancellation; do not replace metadata-driven fields with primitive inputs. Custom components, section labels, process fields and longer flows retain normal step presentation. `DynamicForm.compactInputs` changes ordinary flat-list defaults while preserving explicit grid widths and rich editors.
|
||||
- Test report editor tabs at a viewport short enough to make their panel scroll. Firefox can add a native focus stop on an overflowing container unless it has an explicit `tabIndex`; the report panel uses `-1` so Tab enters the first control. Verify draft preservation and metadata that hides Columns. Check validation contrast on the actual tinted alert background in both themes.
|
||||
- A saved-view reset must discard both the applied report filter and any unapplied quick-filter input. `FilterAndColumnsSetupEditor` changes the quick-filter session key on view selection/reset; do not reset it on ordinary typing. Report view selection/reset must not call saved-view store/delete APIs. Verify Cancel against persisted report and saved-view rows.
|
||||
- Report preview column indicators derive from complete criteria at every nesting level. Opening an indicator reveals the existing filter without appending an empty condition. Preserve read-only preview isolation and editor Cancel behavior; `report-filter-indicators.spec.ts` checks preview rows against SQL and stored report JSON.
|
||||
- Treat table variants as part of the request and cache identity. Report previews reuse `VariantPicker` and the shared per-table storage key; do not query before selection or display rows from the preceding variant while loading. Scope copy and statistics too. Remounting a grid does not cancel an awaiting copy function: guard it against unmount before further requests and clipboard writes. Preserve the delayed-response regression in `report-variants.spec.ts`.
|
||||
|
||||
- Error text must be checked against its rendered background, including Retry hover and visible notifications. The error palette follows configured `errorColor`; Sonner currently uses a light notification surface in both page modes. Request the backend fixture before navigating in theme tests so instance metadata is reset. Assert the actual root theme as well as setting its preference.
|
||||
|
||||
Verify affected screens with paired desktop and phone captures, including a fresh preference state and explicit metadata overrides. Source inspection and passing behavioral tests do not establish full visual review.
|
||||
|
||||
Query URL synchronization in `use-record-query.ts` uses Next-integrated `window.history.replaceState(null, '', url)`. Keep updates synchronous with displayed query state, preserve unrelated parameters/hash, and replace the current entry. Do not reintroduce asynchronous page navigation for filter changes: QRY-093 holds an RSC navigation while checking immediate reload; QRY-017 covers back/forward and legacy JSON links.
|
||||
|
||||
## Workflow for a metadata-driven feature
|
||||
|
||||
1. **Find the Material behavior and acceptance row.** Search the parity ledger by screen or widget type and identify the owning issue and matrix ID. Read the current Material implementation when the behavior is ambiguous. Preserve negative cases and permissions.
|
||||
2. **Trace the QQQ producer.** Locate the Java metadata or renderer that emits the relevant response. Record the exact versioned route and response, including whether data is omitted for unauthorized users. Check the real sample fixture. Do not assume the TypeScript interface is a complete backend specification.
|
||||
For field choices, inspect `inlinePossibleValueSource` in the actual `/qqq/v1/metaData/table/{name}` response. The core Java frontend model can have a property that the v1 response wrapper omits; `REC-062` caught and closed this gap for inline enums.
|
||||
3. **Pick the existing frontend seam.** Add an API function for a new endpoint, a query key for server state, a renderer case for a new widget type, or a metadata-driven component for a new field behavior. Keep application identifiers in metadata/fixtures only.
|
||||
4. **Define behavior for loading, empty, invalid, denied, and failed states.** A malformed widget must not crash neighboring widgets. A denied action must be hidden or disabled and refused by the server. Preserve record identity, selected variant, return path, and unsaved form changes across interactions where applicable.
|
||||
5. **Add focused unit tests for logic and an acceptance scenario for user behavior.** Acceptance tests use the owned QQQ server and check values or SQL state, not only visibility. Give every acceptance test a matrix ID, and use the `diagnostics` fixture. New rows live in the owning `tests/acceptance/matrix/*.json`; the Markdown matrix is generated from those files.
|
||||
6. **Run the affected quality gates, then the full release gate when warranted.** Record commands, build/backend revisions, browsers, and results. Do not write “parity complete” while open Partial/Missing rows remain.
|
||||
7. **Update documentation with verified behavior.** Explain the metadata producer, request/response, UI behavior, and any remaining limitation. Revise this guide's catalog if a widget type changes.
|
||||
|
||||
For code changes, follow import order and file naming in [CLAUDE.md](../../CLAUDE.md): React/Next, external packages, types, library, components; PascalCase component filenames, kebab-case utilities, co-located `*.test.ts(x)`. Client components with interactivity use `'use client'`. User text comes from `label` rather than internal `name`. Interactive elements need a stable `data-qqq-id`, an accessible name, keyboard operation, and appropriate focus management. Inspect existing IDs and [`material-theme.ts`](../../src/lib/theme/material-theme.ts) before promising Material selector or token compatibility.
|
||||
|
||||
## Configuration decision map
|
||||
|
||||
| Desired behavior | Correct source | Verify |
|
||||
|---|---|---|
|
||||
| Add a sidebar entry or nested app | Backend `QInstance.appTree` with `QAppTreeNode.type`; corresponding object in `apps`, `tables`, `processes`, or `reports` | Visible route and `useAppTreeRoutes`; hidden objects omitted from nav but authorized deep link still works; denied user's metadata. |
|
||||
| Add an app home section | `QAppMetaData.sections` (`tables`, `processes`, `reports` by name) | Labels, order, links and permissions in app home. |
|
||||
| Place dashboard widgets | `QAppMetaData.widgets` ordered names; matching `QInstance.widgets` metadata | `WidgetGrid`, widget POST, grid span, denied cases. |
|
||||
| Place a record widget | `QTableSection.widgetName` and compatible record context; [`FormWidgetSection.tsx`](../../src/components/forms/FormWidgetSection.tsx) for editing | Two records render their own values; form widget changes join host validation/save; no cross-record cache leak. |
|
||||
| Configure record layout | `QTableSection.alternatives.RECORD_VIEW`, field/section/widget `gridColumns`, section `collapsible`; Material supplemental table `showRecordSidebar` and `recordViewActionsPlacement`; instance `recordViewActionsPlacement` override | Use [`record-layout-utils.ts`](../../src/lib/utils/record-layout-utils.ts); sidebar defaults off and requires explicit `true`. Preserve the original Next defaults for unset widths: Overview half-width cards, selected section full-width card and two field columns (one on phones); explicit metadata spans win. Verify scroll, saved collapse state, and action location in desktop and phone layouts. |
|
||||
| Configure record action menus | `QTableMetaData.menus` slots `VIEW_SCREEN_ACTIONS` and `VIEW_SCREEN_ADDITIONAL`; [`record-menu-utils.ts`](../../src/lib/utils/record-menu-utils.ts) | Permissions, nested menus, process deduplication, file downloads, and direct Copy/Delete availability. |
|
||||
| Choose basic filter fields | Table dashboard `defaultQuickFilterFieldNames`, otherwise T1 section fields | Read [`quick-filter-utils.ts`](../../src/lib/utils/quick-filter-utils.ts); confirm resolved names, deduplication, basic/advanced conversion and phone sheet. |
|
||||
| Add virtual query columns | Backend `virtualFields` with `isQuerySelectable`; filtering/sorting also requires `isQueryCriteria` | Query payload selects the virtual field; display-only virtual fields cannot become filter or sort criteria. |
|
||||
| Add fields or editors | `QTableMetaData.fields`, sections, adornments, possible-value source | Form, view, validation, server save, keyboard and phone. |
|
||||
| Show a joined field in a record section | Put `joinTable.field` in `QTableSection.fieldNames` and declare a readable `exposedJoins` path | The record GET includes that join; the qualified value renders with joined field metadata. REC-064 covers the real sample in five profiles; permission-denied paths stay absent. |
|
||||
| Read a variant table record | `QTableMetaData.usesVariants` and the query screen's `qqq.tableVariant.<table>` choice | Pass [`storedRecordVariantJson`](../../src/lib/utils/table-variant.ts) to each view/edit/copy record GET; verify the same ID resolves to the selected variant's values ([REC-063](../acceptance/feature-matrix.md)). |
|
||||
| Add a process screen | `QProcessMetaData.frontendSteps` and a supported `QFrontendComponent.type` | Init, submit, async status, cancel/retry, output and server mutations. |
|
||||
| Add report | `QInstance.reports`, optional `processName` | CSV/XLSX/JSON and required inputs; actual downloaded bytes. |
|
||||
| Customize identity | `QInstance.branding` for app/company/logo/banners; `supplementalInstanceMetaData.materialDashboardTheme` for the active application theme | Login subset, footer, header, colors, density, icons, contrast, CSP. `QInstance.theme` is not the active theme input. |
|
||||
| Customize widget chrome | `QWidgetMetaData` fields such as `gridColumns`, `isCard`, `icon`, `collapsible`, `dropdowns`, `showReloadButton`, `showExportButton`, `helpContent`, `icons`, `footerHTML` | Widget response's labels, lists/CSV and interaction; remembered collapse and dropdown state. |
|
||||
| Customize application CSS | `supplementalInstanceMetaData.materialDashboardTheme.customCss` against observed `data-qqq-id`/implemented CSS tokens | Browser rendering, contrast, and actual Material selector/token mapping. |
|
||||
| Configure analytics | `QInstance.environmentValues` with provider settings and privacy opt-ins | No provider initializes without settings; IDs/search/labels withheld by default; CSP for scripts. |
|
||||
| Switch host mode | `QQQ_NEXT_OUTPUT=export` for Javalin jar, or standalone build with `QQQ_BACKEND_URL` | Root deep link, `/qqq/v1` calls, asset paths, browser refresh. |
|
||||
|
||||
Table dashboard settings use `supplementalMetaData.materialDashboard` in v1; `supplementalTableMetaData.materialDashboard` is the legacy fallback. When both objects exist, the v1 object wins. Use [`materialDashboardTableMetaData`](../../src/lib/utils/goto-utils.ts) rather than reading only one spelling.
|
||||
|
||||
The exact type names and optional fields are in [`metadata.ts`](../../src/types/metadata.ts). `supplementalInstanceMetaData`, `supplementalTableMetaData`, and widget `defaultValues` are extension maps, not licenses to invent arbitrary frontend behavior. Inspect each consumer and QQQ backend producer first.
|
||||
|
||||
### A safe widget change, end to end
|
||||
|
||||
For an existing `statistics` widget, the backend can declare metadata equivalent to this TypeScript value:
|
||||
|
||||
```ts
|
||||
import type { QWidgetMetaData } from '@/types'
|
||||
|
||||
const metadata = {
|
||||
name: 'openOrders',
|
||||
label: 'Open orders',
|
||||
type: 'statistics',
|
||||
hasPermission: true,
|
||||
gridColumns: 4,
|
||||
showReloadButton: true,
|
||||
} satisfies QWidgetMetaData
|
||||
```
|
||||
|
||||
The canonical QQQ renderer then returns a payload such as `{ "type": "statistics", "count": 7, "countContext": "orders awaiting review" }`. `ConnectedWidget` calls the typed widget API, `WidgetBlock` renders the frame, and `WidgetRenderer` selects `QqqStatisticsWidget` because `count` is present. The local demo statistic instead uses `value`; do not copy that shape into a QQQ Java renderer without verifying it. To add a new renderer type, add the dispatcher branch, a focused component, a malformed/empty case, real backend fixture, and a WID acceptance row. For existing types, do not add a duplicate dispatcher path.
|
||||
|
||||
For a dropdown, metadata alone is insufficient. The backend payload supplies parallel `dropdownNameList`, `dropdownLabelList`, `dropdownDataList`, and `dropdownDefaultValueList`. The selected option ID is sent back as a widget request parameter. A PVS dropdown uses `possibleValueSourceName` as that parameter name; a `DATE_PICKER` uses its `name`. `storeDropdownSelections` writes a local preference only. Test a stale stored choice and a required selection ([WID-047–WID-050](../acceptance/feature-matrix.md)).
|
||||
|
||||
For `customComponent`, metadata `defaultValues.componentName` and `componentSourceUrl` select a trusted external bundle. The browser loads `window[componentName][componentName]`, exposes `window.React`/`window.ReactDOM`, and passes `qfmdBridge` and `qContext` to the component ([`QqqContainerWidgets.tsx`](../../src/components/widgets/QqqContainerWidgets.tsx), [`qfmd-bridge.tsx`](../../src/components/widgets/qfmd-bridge.tsx)). Verify an existing application's real bundle and CSP before claiming compatibility; the in-repo bridge test alone does not prove it.
|
||||
|
||||
### Widget editors and host forms
|
||||
|
||||
[`WidgetRenderer.tsx`](../../src/components/widgets/WidgetRenderer.tsx) dispatches all 38 renderer labels cataloged in the [developer guide](./next-ui-developer-guide.md#7-widgets-placement-configuration-and-data). Pass the host's `formContext`, `dataVersion`, and `onWidgetData` through the existing connected boundary when the widget edits data. A record form's [`FormWidgetSection.tsx`](../../src/components/forms/FormWidgetSection.tsx) selects editable widgets; a process `WIDGET` step has its own host ([`WidgetComponent.tsx`](../../src/components/process/WidgetComponent.tsx)). Do not make a display widget write directly to the backend or silently mutate a neighboring form field.
|
||||
|
||||
For a row builder, inspect [`row-builder-model.ts`](../../src/components/widgets/row-builder-model.ts) and the backend producer before setting `defaultValues`. It reads `frontendFields` (else `fields`), `associationName` or `outputFieldName`, `orderByFieldName`, `mayReorderRows`, `isForRecordViewAndEditScreen`, `isEditable`, `useModalEditor`, `inlineHeading`, `modalTitle`, and `defaultValuesForNewRowsFromParentRecord`. A record form uses association values when `associationName` is set; a process step uses the named output field. Verify add/delete/reorder, validation, save/cancel and reload with real data. The metadata flags accept booleans or the strings `"true"`/`"false"` where the model explicitly parses them.
|
||||
|
||||
For `pivotTableSetup`, the record view reads `pivotTableJson`; a form supplies the editor context. The editor needs `tableName` and `columnsJson` in the host form and writes serialized `pivotTableJson`. Verify switching the pivot on/off through the actual host form and database readback: a widget-only callback assertion does not prove that clearing survives form validation. Also check modal cancel/save, invalid or missing columns and a persisted reload ([`PivotTableSetupWidget.tsx`](../../src/components/widgets/PivotTableSetupWidget.tsx)). For `dynamicForm`, inspect the payload's `fieldList`/`recordOfFieldValues` and `defaultValues.isEditable`; its editable fields join the process or record form's validation and submission. For a record widget that merges to JSON, verify the named table field exists in a hidden section (rather than marking the field `isHidden`), the renderer returns stored values in `recordOfFieldValues`, required fields block Save, and only the JSON field reaches the record API. [REC-061](../acceptance/feature-matrix.md) proves the full round trip across five browser profiles. `childRecordList` can manage an association when `defaultValues.manageAssociationName` is set. `dataBagViewer` can edit through `storeDataBagVersion` when that process exists.
|
||||
|
||||
`filterAndColumnsSetup` renders a summary and paged, sortable, resizable preview on record view, and an editor when a form context is present, unless the payload sets `overrideIsEditable: false`. [`FilterAndColumnsSetupWidget.tsx`](../../src/components/widgets/FilterAndColumnsSetupWidget.tsx) hands the form to [`FilterAndColumnsSetupEditor.tsx`](../../src/components/widgets/FilterAndColumnsSetupEditor.tsx). The editor loads the selected table's metadata, uses `FilterBuilder` and `ColumnConfig`, offers ordered sort levels and a draft preview inside the modal, and writes `queryFilterJson`/`columnsJson` (or payload-selected field names) to host values on `OK`. Edit opens the filter tab; + Add Filters and + Add Columns appear when the respective saved content is absent and open the matching tab. `Cancel` and closing the dialog discard the draft; persistence still requires saving the host record or advancing a process. A `WIDGET` field adornment with `values.widgetName` uses [`WidgetAdornmentField.tsx`](../../src/components/forms/WidgetAdornmentField.tsx): it sends the host form values and `__formFieldAsWidget_FieldName` to the widget request, overrides `filterFieldName` with the actual field name (including a row index), and writes only that field back. Material supports `filterAndColumnsSetup` in this field path; other widget types show a contained unsupported message. Inspect [`filter-and-columns-utils.ts`](../../src/components/widgets/filter-and-columns-utils.ts) and the backend producer for `hideColumns`, `hideSortBy`, `hidePreview`, `allowVariables`, API-version settings, default filter fields, omitted joins, and custom labels. A missing table disables editing; missing required default-field values produce an alert when an editor control is pressed. A variable saves as a `FilterVariableExpression`; unresolved variables suppress preview and a query opened in a new window until a value is supplied. WID-030, WID-070, WID-071 and WID-072 cover the summary, preview, variable assignment, editor save, field-adornment process path and API-versioned metadata/query/count across five browser profiles. WID-072 supplies version flags to the widget response while the metadata and data requests go to real QQQ API-aware routes. The editor now shares Basic/Advanced controls, saved-view selection, density, refresh, column menus, and statistics with the query components. Saved-view selection is intentionally read-only in this host: it imports the view into the report draft. Read-only preview changes remain local; edited widths/pins serialize into columnsJson on OK and host Save. Starting in RC4, `omitExposedJoins` limits new filter/column choices in both the read-only preview and editor. Existing saved joined columns, criteria and sorts remain usable, and reordering available columns preserves omitted columns in place. Opening the editor and pressing OK preserves those saved fields; Basic/Advanced changes and host Save/reopen retain their values and widths. This is a choice restriction, not a backend permission boundary; existing joined data still uses the normal query permissions. WID-073 checks the configured and unconfigured cases against SQL, including local edits and reload. WID-073 verifies pin/width persistence, full-query copying, and nested statistics. Check the remaining embedded-query parity ledger before claiming complete equivalence; variable assignment currently uses Advanced mode.
|
||||
|
||||
### Composite input editors
|
||||
|
||||
For complete record submissions, call `wireValuesFromForm(table, values, originalValues)`: EntityForm passes `record.values`, association editors pass the existing row values, and process child editors pass the original dialog defaults. Ordinary updates still omit unchanged DATE_TIME fields. Preserve this distinction for copies and child-row edits; omitting the original can shift a repeated DST hour and truncate fractional seconds. REC-065 verifies the copy wire value and SQL source/copy; both child-editor paths have component regressions.
|
||||
|
||||
Use `toLocalDateTimeInput` for seeded process/standalone DATE_TIME values and `fromLocalDateTimeInput(value, originalValue)` at submission. Supplying the original preserves an unchanged instant in the repeated DST hour and its hidden subsecond precision. Normalize the final process payload after contributors have added values, so bulk-edit fields use the same contract. Do not slice UTC text into datetime-local controls or send local text as an instant. PRC-064 and WID-075 exercise America/New_York dates and seconds. Published RC7 sets a one-second step on plain TIME blocks, matching regular/shared fields. Keep TIME values as local clock strings; do not apply instant/timezone conversion. PRC-064 checks unchanged and edited seconds with SQL readback, and WID-074 checks standalone native stepping. This correction is published in RC7 at `f65cf6b`; RC6 remains unchanged.
|
||||
|
||||
`ProcessRun` preserves focus already held inside a configured `[data-qqq-autofocus="true"]` editor; other later screens still focus their heading. Preserve this precedence when changing screen lifecycle. On scalar Enter, normalize text in the hosting form before validation/submission; do not trim unrelated fields or convert typed values to display strings. PRC-063 covers later-screen focus, placeholders, prior values, first blur, whitespace validation and backend-stored text.
|
||||
|
||||
Keep plain block controls visually stable. Backend field metadata supplies the neutral `displayFormat: "%s"`; do not treat that as a request for a different editor. Reuse `BlockInputEditor` and `DynamicFormField` for choices, formatting, behaviors and supported field adornments. Process blocks must bind to the existing screen form and send file contents as multipart files. Never create nested forms or serialize a file as its name. Surrounding process values belong in a field widget's request context, but widget changes must update only its own field.
|
||||
|
||||
Verify hidden/read-only flags, typed choice IDs, numeric conversion, multiline Enter, required uploads and uploaded bytes. For uppercase/lowercase behaviors, replace part of mixed-case text with an uppercase character and check the caret plus stored values. Include Unicode expansion (`ß` to `SS`, `İ` to `i` plus combining dot), then type another character to verify the insertion point; retain backward selection direction. Cover both `STRING` and `TEXT`: textarea Enter must insert a newline without submitting, including with `submitOnEnter: true` (PRC-066/WID-074). For explicitly configured `PASSWORD` fields, verify masked editing, Unicode expansion, show/hide value preservation, Enter/Submit, and independent database readback. Also prove that passwords without a case behavior retain mixed case. The standalone choice field must not collapse below the neighboring plain input's width: a narrow control can place its Clear button under an ordinary center click. Check system and wider native fonts, plus a 320px viewport. The metadata-editor minimum is capped by available width. Sample-dashboard acceptance must initialize the owned backend session and diagnostics before its page-opening hook. PRC-062 exercises the backend round trip, and WID-074 checks the standalone controls. Screenshot comparisons must include the richer editors' height and scroll states; functional success does not approve a changed layout.
|
||||
|
||||
## Navigation and route rules
|
||||
|
||||
`/app/{slug}` resolves an app, table, process, or report from loaded instance metadata, in that order. Do not create a static page for each QQQ object. `isHidden` removes a table/process/report from navigation, while an authorized direct route still works. New dynamic route shapes must work in both standalone and Javalin static export. The export prerenders `_` placeholder paths; [`useRouteParams`](../../src/lib/hooks/use-route-params.ts) translates them from the browser path. Preserve `trailingSlash` handling and avoid constructing deep links that a static host cannot serve. Use [`material-links.ts`](../../src/lib/utils/material-links.ts) for generated links. The not-found route's [`LegacyPathRedirect`](../../src/components/layout/LegacyPathRedirect.tsx) recognizes nested Material paths and `QInstance.redirects` exact or trailing `/*` rules while preserving query/hash. Test deny/not-found loops, a child create URL (`/app/{table}/{id}/createChild/{childTable}`), and refresh of each route shape. Root-path hosting is the documented target; the parity ledger lists base-path support as Missing.
|
||||
|
||||
The [developer route catalog](./next-ui-developer-guide.md#4-navigation-and-deep-links) includes authentication callbacks, table/process developer views, record actions and child creation. `/app/{table}/{id}/{process}` resolves registered processes (table exact name, table suffix, then instance exact name) and redirects with record and return context; the runner enforces permission; preserve that route alongside the reserved edit/copy/dev pages.
|
||||
|
||||
The query URL owns `page`, `pageSize`, `filter`, and `q`; do not move shareable filter state into local storage. Saved views have `/app/{table}/savedView/{id}` and backend persistence. Local storage is for preferences, such as density, column configuration, and widget selections. See [state management](../STATE-MANAGEMENT.md). Put app shell labels, icons, and nested nav in metadata and verify breadcrumb titles against the same tree.
|
||||
|
||||
### Keep configuration coverage executable
|
||||
|
||||
The developer guide's [widget configuration recipes](./next-ui-developer-guide.md#widget-configuration-recipes), [link parameters](./next-ui-developer-guide.md#link-parameters-and-launch-examples), [theme properties](./next-ui-developer-guide.md#theme-property-reference), and [analytics setup](./next-ui-developer-guide.md#analytics-configuration-example) cover the supported keys and copyable response fragments. Update the matching reference whenever a consumer changes; a catalog row with only a widget name is insufficient documentation for new configuration.
|
||||
|
||||
- Compare all `WidgetRenderer` cases with the catalog, including canonical/demo payload branches. Keep `QqqComposite` leaf types and layouts covered separately; they are not top-level widget dispatch labels.
|
||||
- Compare theme properties with `THEME_PROPERTY_KINDS`, including generated typography and radius keys. Keep browser-public `environmentValues` separate from build variables and backend secrets.
|
||||
- Trace dropdown behavior through `ConnectedWidget`: lists pair by position, payload names are request keys, response defaults must be offered options, and `dropdownNeedsSelectedText` gates the body. The interface's `isRequired` and `defaultValue` fields do not by themselves implement those behaviors.
|
||||
- Preserve `gridCols:sizeClass:*` overrides in widget metadata defaults. Their Material breakpoint widths differ from ordinary Tailwind defaults; explicit overrides can keep widgets side by side on smaller screens.
|
||||
- Document `table` cell helper values and `subRows`, chart dataset/URL arrays, and host-specific composite behavior when changing these families. Modal control codes work locally; process `conditional` and interpolation depend on the composite host. RC6 icon compatibility preserves existing Lucide mappings, then lazily resolves valid legacy names through `LegacyMaterialIcon` and the pinned local font. Unknown names retain their fallback. Keep the codepoint inventory, font and license together; verify real JAR font delivery, both themes, existing mapped icons, image precedence, and bundle budgets before integration. Do not attribute this behavior to RC5 or claim support for every Material Symbols name/style.
|
||||
- Check both table-scoped aliases (`/app/{table}/{process}` and `/app/{table}/{report}`) before record GET behavior. Process aliases take precedence over reports; matching names can shadow record IDs. Preserve form hash presets, record tab/view/return parameters, process selection/default inputs, and safe return-path validation.
|
||||
- Preserve the distinction between a displayed preview, a host-form draft, and persisted data. A payload example demonstrates the contract shape; it does not establish backend acceptance for every combination of options.
|
||||
|
||||
## Security and accessibility review points
|
||||
|
||||
- Use the typed API client and same-origin cookie session. Do not put access tokens, passwords, or server secrets in browser storage, public environment variables, widget metadata, or tests.
|
||||
- Session resume now calls `manageSession` with an empty request so browser code can use HttpOnly session cookies; do not restore a `document.cookie` dependency. Test sign-in, refresh, expiry and sign-out against cookie attributes and the real server. Authentication metadata's `?clearAuthenticationMetaDataLocalStorage` switch is a cache reset, not a permission bypass.
|
||||
- Preserve existing identity-provider callback registrations: OAUTH2 uses `/token`, AUTH_0 uses `/`. Provider descriptions may reach login only through the state-matched callback and a one-time browser session value; never render free text from `?error_description=` on a direct link.
|
||||
- Check permission at the UI and the backend. Exercise a denied persona in acceptance; a hidden button alone is insufficient. Do not call widget, process, or record endpoints for an omitted or disabled object.
|
||||
- For exposed query joins, check every target and intermediate table named in `joinPath`. The query screen filters its metadata before rendering columns or filters, and the request hook applies the same rule before sending a join. Keep both paths aligned when changing join UI.
|
||||
- Sanitize HTML. Review CSS, external links, iframes, custom bundle URLs, and CSP when adding a new embed. The Javalin host and standalone server supply security headers; test the deployed response. The [security review](../security/next-ui-review.md) predates some integrated fixes, so reconcile each follow-up with source and current acceptance evidence.
|
||||
- Give forms real labels, `aria-required`/`aria-invalid` where relevant, keyboard operation, error text, and a sensible focus target. Dialogs and phone sheets must trap focus and restore it. Check both desktop keyboard and phone/tablet touch interactions.
|
||||
- The nonmodal Columns popup focuses its named container on open and returns focus to its trigger on explicit close/Escape. Outside clicks keep focus on the clicked control. Embedded report column editors must not take focus when mounted. Verify keyboard search and native browser Tab behavior with QRY-092.
|
||||
- Keep errors local when possible: widget failures show a contained retry and leave neighboring widgets usable; route errors and missing records have dedicated states. Never swallow an API failure into a blank page.
|
||||
|
||||
## Quality gates and evidence
|
||||
|
||||
| Command | Evidence it supplies |
|
||||
|---|---|
|
||||
| `pnpm typecheck` | TypeScript contract check. |
|
||||
| `pnpm lint` | ESLint and license headers. |
|
||||
| `pnpm test` | Vitest unit/regression behavior. |
|
||||
| `pnpm test:e2e` | Mocked Playwright UI paths, useful but not real backend compatibility. |
|
||||
| `pnpm build:export` | Javalin static-export build and dynamic placeholder output. |
|
||||
| `pnpm perf:budget` | Static-export JS/CSS budget after `build:export`; see [performance guide](../acceptance/performance.md). |
|
||||
| `QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet pnpm test:acceptance` | Full real-sample browser and backend gate when `QQQ_SAMPLE_JAR` points to a compatible sample jar. |
|
||||
|
||||
For RC1, prioritize workflow depth in Chromium: normal entry/defaults, validation, edit/cancel, save/reopen, dependent selections, permissions/recovery and persisted records or downloads. Preserve broader tests and report their status; do not treat scenario counts as proof that everything works.
|
||||
|
||||
Read [acceptance setup](../../tests/acceptance/README.md) and [browser matrix](../acceptance/browser-matrix.md) before running the final gate. Default acceptance runs only Chromium; the five-project command is necessary to claim the stated browser scope. Phone and tablet run tagged tests, with matrix coverage enforced by the gate. Every test title needs its matrix ID; use fixtures with a reset database, verify persisted effects through SQL, and include `diagnostics` for console, network, and CSP failures. Do not loosen a matrix row, skip a failing browser, or declare an external service excluded without the documented approval path.
|
||||
|
||||
## Common mistakes to avoid
|
||||
|
||||
- **Inventing config from a plan or TypeScript escape hatch.** Read the actual QQQ producer and renderer; `Record<string, unknown>` does not prove a key is honored.
|
||||
- **Treating mocked widgets as a canonical backend contract.** `statistics.value` and several demo aliases are fixtures; canonical QQQ statistics uses `count`.
|
||||
- **Editing a route for one table.** The slug route and app tree are generic; change backend metadata or general dispatch behavior.
|
||||
- **Fetching inside a presentational metadata component.** Keep metadata as a prop where the component pattern expects it; use hooks/API modules at the connected boundary.
|
||||
- **Calling raw `fetch` for QQQ endpoints.** It bypasses versioned base URL, credentials, and centralized error/session behavior.
|
||||
- **Using `name` as display text.** It is an internal identifier; use `label` and a deliberate fallback only when metadata is genuinely absent.
|
||||
- **Claiming Material theme, custom bundle, URL, or workflow parity from a happy path.** Check current code, the parity ledger, negative/permission rows, and actual browser/backend acceptance evidence. The filter/column editor has real-server coverage in WID-070–WID-072, while the embedded full query screen remains parity work.
|
||||
- **Testing a static deep link on a generic file server.** QQQ Javalin serves placeholder pages for dynamic paths; standalone Next has its own routing path.
|
||||
|
||||
## Completion checklist for an agent change
|
||||
|
||||
1. The backend metadata or widget/process producer and the frontend consumer agree on names, types, and payload fields.
|
||||
2. An authorized user can complete the workflow, while an unauthorized user cannot perform it through the API.
|
||||
3. Loading, empty, malformed, missing, failed, and retry states are addressed where they apply.
|
||||
4. Keyboard, focus, labels, responsive phone/tablet behavior, and contrast have been checked for changed UI.
|
||||
5. Focused tests and the applicable real-server matrix row pass. The final browser claim names the projects actually run.
|
||||
6. Static export or standalone build, as applicable, works on refresh and direct deep link.
|
||||
7. Docs and the parity ledger reflect the observed behavior; any open Partial/Missing rows remain visible until proven closed.
|
||||
|
||||
### Appearance preferences
|
||||
|
||||
Use the real ThemeProvider when rendering UserPreferencesDialog or a sidebar with its user footer. The saved `darkModePreference` is distinct from effective `isDarkMode`: an application theme overrides dark mode without erasing the choice. Preferences exposes Light/Dark, explains a theme lock, and resets both saved appearance and layout defaults. Preserve `qqq-dark-mode` storage compatibility. Verify changes through the actual Preferences flow and reload (NAV-056), including phone drawer layering/focus (NAV-038); injecting localStorage alone does not exercise the user control. There is currently no system appearance choice or account/device synchronization.
|
||||
|
||||
|
||||
### Google Drive integration boundary
|
||||
|
||||
Google Drive picker changes span the frontend component/SDK loader and Javalin's CSP
|
||||
and v1 public-environment allow-list (#704; backend PR #908 merged to develop as `bac663044`). The frontend needs those backend changes to receive its public configuration and load under the configured CSP. PRC-065 tests controlled SDK responses with
|
||||
real process execution and SQL; PRC-039 is the approved real-account exclusion. Preserve
|
||||
the shared-drive view, folder-only validation, cancellation and token expiry handling.
|
||||
Never log or persist the access token, and never publish OAuth client secrets in metadata. Javalin applies conditional CSP to its static export; standalone Node/container deployments derive the same sources from backend `dashboardCspSources` authentication metadata. Older backends that omit that optional field require runtime `QQQ_DASHBOARD_CSP_SOURCES` using the [documented Google directives](./next-ui-developer-guide.md#google-drive-process-configuration). Check the actual dashboard HTML response from the selected host. RC1 remains immutable and retains its historical placeholder; these changes belong to the ongoing final 1.0 work.
|
||||
|
||||
### Script editor suggestions
|
||||
|
||||
`CodeEditor` autocomplete is opt-in from `ScriptEditorDialog`; keep other metadata code fields unchanged unless their contract calls for it. QQQ helpers, language keywords and file identifiers are local suggestions. Preserve native undo/redo, composition input, read-only behavior, caret/selection validation, and Escape-then-Tab focus escape. `records/script-completions.spec.ts` verifies loaded-revision undo/redo, prefix and pointer/touch insertion, both-theme accessibility, and SQL-backed save/reload. Native browsers group consecutive edits differently; do not claim every insertion creates a separate undo transaction.
|
||||
|
||||
|
||||
### RC8 implementation notes
|
||||
|
||||
Public JAR checksum/signature/fresh-consumer checks and 28 affected desktop/mobile workflows passed. Full tag Chromium passed 602 and mobile 563; Firefox, WebKit and tablet retain failures, including tablet Clear-search focus. Consult the release notes before claiming cross-browser completion.
|
||||
|
||||
- Search must contain the entire Tab/Shift+Tab cycle, handle Escape from every dialog control, restore the opener after dismissal, and move focus to the input before Clear removes its own button. Explicitly focus a phone pointer opener because WebKit does not necessarily focus clicked buttons. Cancel delayed autofocus when closing. `navigation/search-focus.spec.ts` covers light/dark and pointer/keyboard openings; preserve the existing layout.
|
||||
- Keep RapiDoc width handling on its public CSS parts. The named `qqq-api-overview` and `qqq-api-overview-wide` containers restrict narrow wrapping and wide content sizing to overview specification actions; do not apply it to authentication or endpoint controls. Test full visibility before focus can scroll a clipped control into view, real JSON downloads, keyboard new-tab behavior, both themes, and label fit with wider application fonts at desktop and phone widths.
|
||||
- LONG range validation must run only after integer syntax succeeds. Preserve integer-string bounds as `BigInt` values; converting through `Number` rounds large bounds. Keep the mixed numeric comparison for fractional numeric bounds. Test `safeParse` error results without exceptions, exact positive/negative boundaries, optional blanks, and correction/submission through block and shared process forms. These changes do not introduce a backend range-metadata contract.
|
||||
@@ -0,0 +1,503 @@
|
||||
# Next UI developer guide
|
||||
|
||||
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
|
||||
> **RC9:** application code is unchanged from RC8. See [RC9 notes](../releases/1.0.0-RC.9.md) and [published releases](https://github.com/QRun-IO/qqq-frontend-next/releases) for versioned verification and public delivery status. Earlier RC8 receipts below remain historical evidence.
|
||||
|
||||
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
|
||||
|
||||
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) (commit `12cf774`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
|
||||
|
||||
The visual baseline is the September 25 Next UI design (`e42ad2b2`). Material remains the reference for backend contracts and functional behavior. The layout defaults below describe current source; they do not certify a completed visual review.
|
||||
|
||||
## 1. What an application supplies
|
||||
|
||||
QQQ supplies an instance metadata document with `apps`, ordered `appTree`, `tables`, `processes`, `reports`, `widgets`, and `branding`; help content, environment values, supplemental metadata, and redirect rules may also be present. The UI renders those objects for the signed-in user's permissions. A table, widget, or route name is an internal identifier; its `label` is the text users see. Define names and behavior on the backend, then verify the versioned metadata response. Do not edit the frontend to add an application-specific table or menu item.
|
||||
|
||||
The source of truth for the frontend shape is [`src/types/metadata.ts`](../../src/types/metadata.ts). A small, **partial response fragment** illustrates the relationships; it is not a complete `QInstance` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"appTree": [{ "name": "operations", "label": "Operations", "type": "APP", "children": [{ "name": "orders", "label": "Orders", "type": "TABLE" }] }],
|
||||
"apps": { "operations": { "name": "operations", "label": "Operations", "widgets": ["ordersToday"], "sections": [{ "name": "work", "label": "Work", "tables": ["orders"] }] } },
|
||||
"widgets": { "ordersToday": { "name": "ordersToday", "label": "Orders today", "type": "statistics", "hasPermission": true, "gridColumns": 4 } },
|
||||
"branding": { "appName": "Operations", "accentColor": "#1d4ed8" }
|
||||
}
|
||||
```
|
||||
|
||||
`appTree` controls the sidebar; `apps[name].widgets` controls ordered dashboard widgets; `apps[name].sections` controls the app home cards. The server may omit denied objects or send disabled permission flags; the frontend also checks flags before requesting data. The server must still enforce authorization. A widget can also be placed in a table section through `QTableSection.widgetName`, or embedded in a process step of type `WIDGET`. `QInstance.redirects` can map old exact or wildcard Material paths to new targets. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
|
||||
|
||||
## 2. Install and run
|
||||
|
||||
The preferred QQQ-hosted distribution is the `com.kingsrook.qqq:qqq-frontend-next` Maven jar. A QQQ 4.1+ application's `QApplicationJavalinServer` serves its static export from `next-dashboard/` at `/` on the same origin as `/qqq/v1`. The [repository README](../../README.md) and [jar README](../maven-artifact/README.md) describe this packaging. The QQQ [sample quickstart](https://github.com/QRun-IO/qqq/blob/main/qqq-sample-project/README.md#quickstart-with-next) exercises the packaged path. Material can be selected with `withServeFrontendMaterialDashboard(true)` or `-Dqqq.javalin.frontend=material`; do not assume Material and Next use the same URL layout.
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.11</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
For frontend development, use a compatible Node.js version (the README specifies 20.19+ or 22.12+) and pnpm 9.15.9:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
NEXT_PUBLIC_MOCK_API=true pnpm dev
|
||||
pnpm build:export
|
||||
pnpm build:jar
|
||||
```
|
||||
|
||||
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). This checkout targets `1.0.0-RC.11`, overriding an older BOM-managed frontend; confirm its availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. It requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for its verified backend commit. Use a matching local backend/frontend build to test later branch changes.
|
||||
|
||||
| Setting | Behavior in this checkout |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | Defaults to `/qqq/v1`; baked into browser requests. Keep the UI and API on the same origin where possible. |
|
||||
| `NEXT_PUBLIC_MOCK_API` | `true` enables MSW development fixtures; `build:export` forces `false`. |
|
||||
| `QQQ_NEXT_OUTPUT` | `export` selects the Javalin-hosted static export; otherwise standalone build. |
|
||||
| `QQQ_BACKEND_URL` | Standalone build only: backend origin for Next rewrites; fixed at build time. |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | Optional version reported to the backend; defaults to `package.json` version. |
|
||||
| `QQQ_DASHBOARD_CSP_SOURCES` | Standalone server runtime setting: additional CSP directives/sources for external services. See Google Drive hosting below. |
|
||||
|
||||
The static export is built for `/`, with `trailingSlash: true`. QQQ serves placeholder HTML for dynamic route shapes; [`useRouteParams`](../../src/lib/hooks/use-route-params.ts) reads real path segments from the browser. Keep that behavior intact when adding routes. The [parity ledger](../acceptance/material-parity.md) currently lists sub-path hosting as unsupported. A plain file server without the QQQ route fallback is not a valid deep-link test.
|
||||
|
||||
## 3. Authentication and security
|
||||
|
||||
`GET /qqq/v1/metaData/authentication` selects `AUTH_0`, `OAUTH2`, `FULLY_ANONYMOUS`, `MOCK`, or `TABLE_BASED` (see [`QAuthenticationMetaData`](../../src/types/metadata.ts) and [`auth-provider.tsx`](../../src/lib/auth/auth-provider.tsx)). OAuth flows use authorization code and PKCE; table-based sign-in sends credentials to the QQQ session endpoint. The server issues `sessionUUID`/`sessionId` cookies; current Next code resumes an existing session with an empty `manageSession` request, allowing HttpOnly cookies that browser script cannot read. Browser API calls use credentials. A 401 clears session state and returns to sign-in. The pre-login response exposes only limited login branding, not banners or custom CSS. Authentication metadata is cached briefly; `?clearAuthenticationMetaDataLocalStorage` clears that cache.
|
||||
|
||||
Existing provider registrations remain usable: OAUTH2 redirects to `{origin}/token`, while AUTH_0 redirects to `{origin}/`. Both use the same state-checked PKCE callback. A state-matched provider denial displays its description once on the login page; a forged callback or direct login link shows only a safe generic error.
|
||||
|
||||
Permissions apply to metadata and data: check table capabilities and the corresponding read/insert/edit/delete flags, `process.hasPermission`, `report.hasPermission`, and `widget.hasPermission`. A disabled widget can show a permission message without fetching; omitted metadata cannot be guessed from the UI. Use the backend to prove a forbidden operation is rejected. HTML from metadata or widget payloads is sanitized before display. Treat theme `customCss` and custom component bundles as trusted application configuration with a stricter review boundary. The Javalin dashboard and standalone Next server configure security headers and CSP; embedding an external provider needs an allowed source, not a disabled policy. The [security review](../security/next-ui-review.md) is an earlier audit; compare its follow-ups with current code and the latest acceptance gate before treating them as resolved.
|
||||
|
||||
## 4. Navigation and deep links
|
||||
|
||||
[`useAppTreeRoutes`](../../src/lib/hooks/use-routes.ts) recursively walks `appTree` to build sidebar links, labels, ancestor breadcrumbs, and the first accessible app. `APP` nodes nest; visible `TABLE`, `PROCESS`, and `REPORT` nodes navigate to `/app/{name}`. Hidden objects are omitted from navigation but can still have direct routes when authorized. An app home uses its `widgets` and `sections`; a table slug opens its query page; a process slug opens its wizard; a report slug opens its runner. The unified route resolves name collisions in that order: app, table, process, report. Use unique names across these maps.
|
||||
|
||||
| URL | Screen |
|
||||
|---|---|
|
||||
| `/` | Opens `/app`, or handles an Auth0 authorization callback. |
|
||||
| `/login`, `/callback`, `/token` | Sign-in and supported authorization callback routes. |
|
||||
| `/app` | Instance dashboard / app entry point. |
|
||||
| `/app/{appName}` | App home and its ordered widgets/sections. |
|
||||
| `/app/{tableName}` | Query or key lookup, according to table capability. |
|
||||
| `/app/{tableName}/create` | Create record. |
|
||||
| `/app/{tableOrProcessName}/dev` | Table or process developer view; table takes precedence. |
|
||||
| `/app/{tableName}/{recordId}` | Record view; `/edit`, `/copy`, and `/dev` are subroutes. |
|
||||
| `/app/{tableName}/{processName}` | Table-scoped process alias; redirects to the process runner with query parameters and a return path to the table. |
|
||||
| `/app/{tableName}/{reportName}` | Alias for a report whose `tableName` matches; redirects to its runner. |
|
||||
| `/app/{tableName}/{recordId}/createChild/{childTable}` | Child create form with parent association values. |
|
||||
| `/app/{tableName}/{recordId}/{processName}` | Resolves a registered process and redirects to its runner with record and return context; execution still checks permission. |
|
||||
| `/app/{tableName}/key?...` | Unique-key lookup. |
|
||||
| `/app/{tableName}/savedView/{viewId}` | Server-backed saved query view. |
|
||||
| `/app/{processName}` | Process run. |
|
||||
| `/app/{reportName}` | Report run and download. |
|
||||
| `/app/search?q=...` | Navigation, recent-record, and available record search results. |
|
||||
| `/app/developer` | Developer information available to permitted users. |
|
||||
|
||||
The header search and `/` dialog locate navigation targets and recent records; record search is offered when the backend supports it. RC8 includes the search-focus correction: Tab/Shift+Tab remain within the search dialog, Clear returns focus to the search input, and Escape or backdrop dismissal restores focus to the opener. A pointer-opened phone search restores focus to its actual button in verified Chromium/mobile cases. RC8 tablet WebKit has two open Clear-search focus failures; see the release notes for remaining browser issues. RC7 does not include this correction. The command palette opens with `.` or Cmd/Ctrl+K and has a header button; `?` opens keyboard help. Keyboard shortcuts on query and record pages are listed in the [feature matrix](../acceptance/feature-matrix.md). The sidebar has a phone drawer, nested app groups, banners, and a user menu. Branding can add a company footer and a themed bar above the shell. `?helpHelp` reveals help-slot keys for content authors.
|
||||
|
||||
Material's nested `/{app}/{table}/{id}` URLs are not the native Next route. A not-found page now checks the signed-in instance metadata, applies exact or `/*` redirect rules, and resolves nested Material app paths to `/app/...` while preserving query/hash ([`legacy-paths.ts`](../../src/lib/utils/legacy-paths.ts), [`LegacyPathRedirect.tsx`](../../src/components/layout/LegacyPathRedirect.tsx)). Verify a specific old bookmark rather than assuming arbitrary legacy URLs match. For links generated by widgets use [`material-links.ts`](../../src/lib/utils/material-links.ts), which normalizes supported legacy links and preserves a trailing slash for static-export routes.
|
||||
|
||||
### Link parameters and launch examples
|
||||
|
||||
The table-scoped process/report aliases are resolved before a record GET, so avoid record IDs that collide with those names. Process resolution checks an exact table process, then an instance process; the record-scoped route also accepts a table process name suffix. A matching process takes precedence over a report. Use full process names for unambiguous new links. See [`material-links.ts`](../../src/lib/utils/material-links.ts).
|
||||
|
||||
| Link context | Supported parameters and behavior |
|
||||
|---|---|
|
||||
| Record view query | `tab` selects a section; `view=tabs` or `view=list` selects the layout. `from` is a safe local return path and `fromLabel` labels it. The default view comes from Preferences. |
|
||||
| Record view hash | `#audit` opens audits when available; `#/launchProcess=<name>` launches a process; `#<sectionName>` selects a section. `#/createChild=<table>` starts child creation and accepts the presets below. |
|
||||
| Create/child-create hash | `#/defaultValues=<encoded JSON object>/disabledFields=<encoded JSON array>` seeds and locks fields. `disabledFields` also accepts a JSON object whose truthy entries name locked fields. These are UI presets; backend validation and authorization still apply. |
|
||||
| Process query | `recordsParam=recordIds&recordIds=1,2` selects IDs; `recordsParam=filterJSON&filterJSON=<encoded QQueryFilter>` selects by filter. The legacy selector `queryFilter` uses the same `filterJSON` value. |
|
||||
| Process context | `defaultProcessValues=<encoded JSON object>` presets inputs; `returnTo=<encoded local path>` supplies the return destination. `tableName` supplies launch context only for a process without its own table and only when that table exists in metadata. |
|
||||
| Dashboard hash | `#<widgetName>` targets a widget anchor. An embedded `process` widget also reads the page's `recordIds` query parameter. |
|
||||
|
||||
Build encoded links instead of concatenating raw JSON. For example, where metadata declares the supplied process/table names:
|
||||
|
||||
```ts
|
||||
const params = new URLSearchParams({
|
||||
recordsParam: 'recordIds',
|
||||
recordIds: selectedIds.join(','),
|
||||
defaultProcessValues: JSON.stringify({ sendNotification: true }),
|
||||
returnTo: `/app/${encodeURIComponent(tableName)}`,
|
||||
})
|
||||
const processHref = `/app/${encodeURIComponent(processName)}/?${params}`
|
||||
const createHref = `/app/${encodeURIComponent(tableName)}/create/#/defaultValues=${encodeURIComponent(JSON.stringify(defaultValues))}/disabledFields=${encodeURIComponent(JSON.stringify(disabledFieldNames))}`
|
||||
```
|
||||
|
||||
Only preset fields actually declared by the application's metadata. Record/form hash decoding is implemented by [`formPresetsFromHash`](../../src/lib/utils/material-links.ts); process query decoding lives in the [slug page](../../src/app/%28dashboard%29/app/%5Bslug%5D/page.tsx). Test a generated link on the static-export host and after refresh.
|
||||
|
||||
## 5. Tables, queries, records, and forms
|
||||
|
||||
A failed query displays its backend error with Retry and Dismiss controls. Retry repeats the request; Dismiss closes the panel without changing the filter. Correct or reset an invalid filter to load records again. Query and pivot error text use theme-derived shades for readability; the top error notification also uses the configured error palette.
|
||||
|
||||
Tables are described by [`QTableMetaData`](../../src/types/metadata.ts): `primaryKeyField`, `fields`, `sections`, `associations`, `exposedJoins`, capabilities, and permission flags. A field supplies `name`, `label`, `type`, `isRequired`, `isEditable`, `isHeavy`, `isHidden`, and `adornments`; possible values, bounds, formats, and help are optional. The supported primitive field types are `STRING`, `INTEGER`, `LONG`, `DECIMAL`, `BOOLEAN`, `DATE`, `TIME`, `DATE_TIME`, `TEXT`, `HTML`, `PASSWORD`, and `BLOB`. [`DynamicFormField`](../../src/components/forms/DynamicFormField.tsx) chooses editors, while [`zod-from-metadata.ts`](../../src/lib/utils/zod-from-metadata.ts) builds validation. Configure fields in QQQ metadata, not in a per-table React component.
|
||||
|
||||
Table dashboard settings are read from `supplementalMetaData.materialDashboard` in the v1 response, with `supplementalTableMetaData.materialDashboard` accepted as a legacy fallback. The v1 object takes precedence when both are present. These settings include `defaultQuickFilterFieldNames`, `gotoFieldNames`, `showRecordSidebar`, and `recordViewActionsPlacement`. Quick-filter defaults use the configured nonempty field list, otherwise T1 section fields; unresolved names and duplicates are removed. Query-selectable `virtualFields` also become columns, but only virtual fields with `isQueryCriteria` can be filtered or sorted.
|
||||
|
||||
For a fixed choice list, put `inlinePossibleValueSource` on the QQQ field with `type: ENUM`, an `idType` matching the field, and ordered `enumValues` containing `id` and `label`. Do not also set `possibleValueSourceName`: QQQ rejects both sources on one field. Next filters inline labels by prefix without a possible-values request and stores the selected `id`. A `CHIP` field adornment with keys such as `color.HIGH` and `icon.HIGH` styles both record values and form options. The v1 table metadata response must include the inline source; [REC-062](../acceptance/feature-matrix.md) verifies its rendering, persistence, and reload against the sample server.
|
||||
|
||||
The query page supports basic quick-filter chips and an advanced nested filter builder, quick search, sort, column visibility/order/width/pinning, density, pagination, CSV export, row selection, bulk process launch, column statistics, and table variants where declared. On desktop, Filter opens a panel containing the Basic/Advanced switch, sort controls, quick-filter chips, and advanced builder. On phones, Filter opens a sheet containing the same mode, sort, and quick-filter controls; Advanced shows the nested builder inside that sheet. Opening Filter preserves the selected mode. Advanced mode uses a single editable condition list with per-row removal and one confirmed Clear action in the footer. Complex criteria remain in Advanced mode; the basic chips expose metadata-selected fields and let the user add more. The Columns popup supports keyboard entry, search, reordering and focus return on close; embedded report column editors keep the surrounding editor focus. Query columns start at 150 px unless a `SIZE` adornment or a saved/resized width supplies another value. `TABLE_QUERY`, `TABLE_GET`, `TABLE_COUNT`, `TABLE_INSERT`, `TABLE_UPDATE`, `TABLE_DELETE`, `TABLE_EXPORT`, and `QUERY_STATS` gate operations alongside permissions. For tables with `usesVariants`, choose a backend-provided variant before querying; it is stored under `qqq.tableVariant.<table>` and sent to query, process and record view/edit/copy GET requests. Backend field-download URLs carry the same variant; inline BLOB data comes from the variant-specific record response. [REC-063](../acceptance/feature-matrix.md) verifies view/edit/copy against duplicate record IDs in two variants. URL parameters `page`, `pageSize`, `filter`, and `q` carry shareable query state ([`use-record-query.ts`](../../src/lib/hooks/use-record-query.ts)). Saved views have backend persistence, quick-view shortcuts and counts, modified-state comparison, and their own routes when the backend capabilities exist ([`use-saved-views.ts`](../../src/lib/hooks/use-saved-views.ts)). Browser local storage holds personal display preferences and last view state, not authoritative records or permissions.
|
||||
|
||||
Record pages render sections, field values, associated records, widgets, metadata-defined actions, help, and audit history. `QTableSection.alternatives.RECORD_VIEW` replaces the base section for this screen. Visible sections appear in metadata order; fields, sections and widget cards use `gridColumns` as twelfths on large screens and stack on narrow screens. When widths are not configured, Overview uses half-width cards on large screens; a selected section uses a full-width card with two field columns from the small breakpoint, stacking fields on phones. Explicit field, section, or widget widths take precedence over those presentation defaults. A collapsible section uses `collapsible.isCollapsible` and `initiallyOpen`, then remembers the choice under `qqq.recordView.collapsibleSectionOpenStates.<table>.<section>`; a section's rule overrides its widget's rule. The sticky desktop section sidebar is off by default; only `showRecordSidebar: true` in the table dashboard settings enables it. `recordViewActionsPlacement` can be set on the table or `supplementalInstanceMetaData.materialDashboard`, with the instance winning; `INLINE_WITH_PAGE_TITLE` places desktop actions beside the local record title, while the default `IN_IDENTITY_SECTION` keeps them in the identity controls. The identity card omits the primary-key T1 field and T1 values already contained in the record label; display values take precedence over raw values for that comparison. Desktop Copy and Delete buttons are omitted when the Actions menu already contains the corresponding enabled action, including in a submenu. An absent or disabled menu action preserves its available direct button. The Audit action reads through `GetAuditsForRecord` when permitted, otherwise through the `audit` table; its dialog groups entries by the viewer's local date, supports oldest/newest sort saved under `audit.sortDirection`, and reports when the 1,000-detail cap truncates results. Create/edit/copy forms use a centered container capped at `max-w-4xl` and follow field metadata, section layout, adjusters, possible-value choices, and server validation; `FILE_UPLOAD` width `full` spans the form grid, while `half` uses the default half width. Copy defaults to Base Copy, which duplicates editable fields; choose Full Copy to edit and submit loaded named associations with the new record. A widget section can edit association rows or form values through `WidgetFormContext` when its type and defaults allow it. Process launch actions can receive selected record IDs or a filter. `gotoFieldNames` in Material supplemental table metadata enables a Go To dialog for the primary key or configured unique keys. Developer views are capability and permission dependent. Consult [record and query acceptance rows](../acceptance/feature-matrix.md) for exact supported cases; do not infer coverage from the existence of a component alone.
|
||||
|
||||
Query changes replace the current URL immediately, preserving unrelated parameters and the hash. Reload and browser back/forward restore the visible filter, sort, search and pagination state; changing those controls does not add a separate history entry.
|
||||
|
||||
Record section tabs support Left/Right, Home and End to move focus. Enter or Space opens the focused section; Tab moves into the named content panel. Moving focus alone does not load section widgets. Phone sections use accordion buttons with Enter/Space activation. The selected tab remains reflected in the URL.
|
||||
|
||||
Query header labels can wrap to two lines when their text and sort indicator do not fit, including with wider system or configured fonts. Configured column widths and 44px touch menu targets remain effective. Active sorts display their direction arrow; unsorted headings remain labeled sort buttons. Relative-date preset menus scroll within the space available beside their trigger.
|
||||
|
||||
Selection and display-density menus support arrow-key navigation, Enter to choose, and Escape to close and return focus to the trigger. They stay within the viewport on phone and tablet. Density choices expose radio-menu semantics; the selected option uses the theme primary/foreground color pair.
|
||||
|
||||
Record action menus come from `QTableMetaData.menus`. The first `VIEW_SCREEN_ACTIONS` menu replaces the default Actions menu; `VIEW_SCREEN_ADDITIONAL` menus add ordered menu buttons. Item types are `BUILT_IN`, `RUN_PROCESS`, `DOWNLOAD_FILE`, `SUB_MENU`, `SUB_LIST`, and `DIVIDER`. Their `values` supply `option`, `processName`, `fieldName`, or nested `items`, respectively. Built-in options are `NEW`, `COPY`, `EDIT`, `DELETE`, `DEVELOPER_MODE`, `AUDIT`, `THIS_TABLE_PROCESS_LIST`, and `ALL_TABLES_PROCESS_LIST`; permissions filter available operations. See [`record-menu-utils.ts`](../../src/lib/utils/record-menu-utils.ts) for resolution and process deduplication.
|
||||
|
||||
For a joined field in a record section, list its qualified name (`person.firstName`) in `QTableSection.fieldNames` and expose a readable join through `QTableMetaData.exposedJoins`. Next asks for that join on the record GET and uses the joined table's field metadata to render the qualified response value. Join paths through tables without read permission are omitted. [REC-064](../acceptance/feature-matrix.md) checks the real sample server in five browser profiles.
|
||||
|
||||
## 6. Processes and reports
|
||||
|
||||
`QProcessMetaData` declares the process name, label, table association, permission, limits on input records, and ordered `frontendSteps`. The wizard initializes a run, submits each step, polls asynchronous jobs, handles validation results, and cancels or retries as appropriate ([`ProcessRun.tsx`](../../src/components/process/ProcessRun.tsx), [`processes.ts`](../../src/lib/api/processes.ts)). It preserves return navigation to its caller. `QFrontendComponent.type` selects a process component. The currently registered types are:
|
||||
|
||||
| Type | Purpose |
|
||||
|---|---|
|
||||
| `HELP_TEXT`, `HTML` | Guidance and sanitized markup. |
|
||||
| `EDIT_FORM`, `VIEW_FORM` | Metadata-driven writable and read-only fields. |
|
||||
| `RECORD_LIST`, `PROCESS_SUMMARY_RESULTS` | Input/output records and completion summary. |
|
||||
| `BULK_EDIT_FORM` | Changes applied to selected records. |
|
||||
| `BULK_LOAD_FILE_MAPPING_FORM`, `BULK_LOAD_VALUE_MAPPING_FORM`, `BULK_LOAD_PROFILE_FORM` | File preview; flat/tall/wide layout and header-row choices; field-to-column/default mappings; per-value translations; saved load/edit profiles. |
|
||||
| `VALIDATION_REVIEW_SCREEN` | Review validation findings before commit. |
|
||||
| `DOWNLOAD_FORM` | Download parameters and result. |
|
||||
| `GOOGLE_DRIVE_SELECT_FOLDER` | Selects one folder from a shared drive using Google OAuth. Requires `GOOGLE_APP_CLIENT_ID` and `GOOGLE_APP_API_KEY` in instance environment values. Submits `googleDriveAccessToken`, `googleDriveFolderId`, and `googleDriveFolderName`; cancellation clears them. |
|
||||
| `WIDGET` | Embedded connected widget. |
|
||||
|
||||
Do not invent `values` keys for these components: their shapes vary by backend process implementation. Start with the QQQ producer and a [real fixture](../../tests/acceptance/fixture), then verify the versioned response and end-to-end step submission. A `WIDGET` step uses the named widget's full dashboard controls; an editable `childRecordList` contributes `frontendRecords`, while an editable `rowBuilder` writes its configured output field. Ad hoc composite blocks share the dashboard block renderer, including modal control codes, typed inputs and layouts. A dashboard widget of type `process` embeds the wizard without its stepper or Cancel action; Return starts a fresh run in place. For bulk load, test duplicate headers, required fields, saved profiles, value mappings, validation review, and the committed records. Reports appear in `QInstance.reports`. [`ReportRun.tsx`](../../src/components/reports/ReportRun.tsx) offers CSV, XLSX, and JSON, handles required inputs and asynchronous process-backed reports, and provides a download. A report without a backing process uses the versioned streaming report route. Saved and scheduled report behavior is covered by the report rows of the acceptance matrix.
|
||||
|
||||
For bulk load, the mapping screen derives required fields, child associations, layout and suggested mappings from the backend's table structure and process values. Its Add Fields menu searches the main and child-table groups; WIDE layouts may repeat a child field. A mapped field can read a file column or a typed default, including possible values, dates and booleans. Header-row changes remap by name and warn about duplicates. File and value mapping show the current profile in a bordered Saved Bulk Load Profiles section; the final review shows that status in its profile summary. Saved bulk-load profiles are grouped by owner; owners can save, rename and delete, while shared profiles can be selected but not modified. Open the profile menu with Enter or Arrow Down; use arrow keys or Home/End to move, Enter to choose, and Escape to close. Long lists scroll within the viewport. New, Save As and Rename open an inline Profile Name field inside the profile section. Save Profile or Enter saves the name without advancing the process; Cancel or Escape closes it. Duplicate-name errors stay beside the editor. Closing returns focus to the opening control, or the profile-menu button if that control was replaced after saving. Updates and deletion retain their confirmations. New, Empty Mapping, Suggested Mapping and Reset All Changes affect only the draft until the process step is submitted. The validation preview can show the associated child grids declared by `previewRecordAssociated*` values.
|
||||
|
||||
Report output formats come from the backend's `reportFormat` possible values unless a process screen declares its own `reportFormat` field. Report input screens use the same dynamic form renderer as other processes, so declared possible values, Booleans, dates and validation rules apply. Intermediate report process screens render before the download; the completed file can be a server path or storage reference.
|
||||
|
||||
Standard report processes with an input form followed by a download use the original compact input card and **Generate Report** action. Cancel remains available beside it. Ordinary fields stack at a 384px maximum width; explicitly configured grid widths and rich editors keep their layout. These inputs retain the shared dynamic form, required validation, possible values and typed submission. Longer or custom report processes retain their step headings and navigation.
|
||||
|
||||
## 7. Widgets: placement, configuration, and data
|
||||
|
||||
Each app dashboard lists widget **names** in order. The matching `QWidgetMetaData` supplies `name`, `label`, optional `type`, and `hasPermission`. The UI posts to `/qqq/v1/widget/{name}` with current dropdown and record parameters ([`ConnectedWidget.tsx`](../../src/components/widgets/ConnectedWidget.tsx), [`widgets.ts`](../../src/lib/api/widgets.ts)). The widget response contains its data and sometimes its type. [`WidgetRenderer.tsx`](../../src/components/widgets/WidgetRenderer.tsx) uses `metadata.type` first, falling back to `payload.type`; it contains errors and unknown types within the widget.
|
||||
|
||||
A valid TypeScript metadata example, suitable for an instance metadata fixture (the backend should produce the equivalent JSON), is:
|
||||
|
||||
```ts
|
||||
import type { QWidgetMetaData } from '@/types'
|
||||
|
||||
const ordersToday = {
|
||||
name: 'ordersToday',
|
||||
label: 'Orders today',
|
||||
type: 'statistics',
|
||||
hasPermission: true,
|
||||
gridColumns: 4,
|
||||
isCard: true,
|
||||
showReloadButton: true,
|
||||
showExportButton: true,
|
||||
tooltip: 'Orders created since midnight',
|
||||
icon: 'inventory_2',
|
||||
collapsible: { isCollapsible: true, initiallyOpen: true },
|
||||
dropdowns: [{ name: 'region', label: 'Region', possibleValueSourceName: 'regions', type: 'POSSIBLE_VALUE_SOURCE' }],
|
||||
storeDropdownSelections: true,
|
||||
} satisfies QWidgetMetaData
|
||||
```
|
||||
|
||||
`gridColumns` spans 1–12 twelfths on large screens (default 12); smaller screens stack widgets unless explicit breakpoint overrides are supplied in `defaultValues` (see the configuration recipes below). `isCard: false` removes card chrome. `icon` supplies the main header glyph using the Next outline presentation. `collapsible.isCollapsible` enables a header toggle, starts according to `initiallyOpen`, and remembers state under `qqq.widget.collapsibleOpenState.<name>`. `minHeight`, `footerHTML`, help content, `icons.topLeftInsideCard`/`topRightInsideCard`, tooltip, reload, and export customize the frame. Payload `label`, `sublabel`, and `footerHTML` can override or add frame text; a parent payload's `isLabelPageTitle` promotes the label to a page title. HTML is sanitized. For export, provide `csvData` as rows of cells; a table payload may also export `columns` and `rows`. The dropdown controls are declared in metadata **and populated by parallel payload lists** (`dropdownNameList`, `dropdownLabelList`, `dropdownDataList`, `dropdownDefaultValueList`). Controls offer searchable choices or a native date input; metadata can set `width`, `startIconName`, `allowBackAndForth`, `backAndForthInverted`, and `disableClearable`. The selected ID becomes a request parameter; a possible-value source uses its source name. Required selection can delay content, and `storeDropdownSelections` persists choices at `qqq.widgets.dropdownData.<widget>.<param>`. See [WID-040–WID-050](../acceptance/feature-matrix.md) for the tested contract.
|
||||
|
||||
Choice dropdowns support search and keyboard selection. A `DATE_PICKER` uses the platform calendar and a Today action; set `allowBackAndForth` for previous/next day buttons. If the `timeframe` dropdown offers `custom`, selecting it opens start and end inputs; after both are entered, the UI sends `custom,<UTC start>,<UTC end>`.
|
||||
|
||||
For example, a canonical QQQ statistics response uses `count`, not the demo fixture's `value`:
|
||||
|
||||
```json
|
||||
{ "type": "statistics", "count": 42, "countContext": "open orders", "percentageAmount": 12.5, "percentageLabel": "vs previous week", "increaseIsGood": true }
|
||||
```
|
||||
|
||||
The catalog below lists **every explicit dispatch type** in `WidgetRenderer.tsx`. Payload fields are from the corresponding renderer; they are examples of what the backend emits, not a replacement for the QQQ Java renderer API. When `type` is `chart` or a chart variant, canonical `chartData.labels` and `chartData.datasets` select the QQQ chart path. A payload with `chartType`, `seriesData`, or demo `data` instead uses the legacy/demo chart path.
|
||||
|
||||
### Canonical QQQ display widgets
|
||||
|
||||
| `type` | Use and important response fields |
|
||||
|---|---|
|
||||
| `alert` | Status message: `html`, `alertType`, optional `bulletList` and `hideWidget`. A demo `message` payload uses the demo alert renderer. |
|
||||
| `divider` | Horizontal separator, optionally `label`; rendered without normal card chrome. |
|
||||
| `fieldValueList` | Labeled record fields: `fields`, `record.values`, optional `record.displayValues`, prefix icon maps, indentation map. Display values win. |
|
||||
| `generic` | Common widget frame fields, especially `sublabel` and sanitized `footerHTML`; optional `html` payload. |
|
||||
| `html` | Sanitized `html` string. A demo payload with `blocks` uses the demo block renderer. |
|
||||
| `location` | `imageUrl`, `title`, `description`, `location`, `footerText`. |
|
||||
| `quickSightChart` | Provider embed `url` in an iframe; supply a server-authorized URL and appropriate CSP. Real AWS account coverage is an approved external exclusion. |
|
||||
| `stepper` | `steps` with labels and optional links/icons, plus zero-based `activeStep` and optional `title`. |
|
||||
| `usaMap` | `mapMarkerList` of names and latitude/longitude; optional CSS `height`. |
|
||||
|
||||
### Canonical QQQ charts, statistics, and tables
|
||||
|
||||
| `type` | Use and important response fields |
|
||||
|---|---|
|
||||
| `barChart` | Vertical bars from `chartData.labels` and `chartData.datasets`; dataset `data`, colors, and URLs. |
|
||||
| `horizontalBarChart` | Horizontal bars; supports negative values relative to zero. |
|
||||
| `stackedBarChart` | Multiple datasets stacked for each label. |
|
||||
| `lineChart` | Line points from labels and datasets. |
|
||||
| `smallLineChart` | Compact line presentation with `title` and optional sanitized `description`. |
|
||||
| `pieChart` | Colored slices and legend; optional `chartSubheaderData`. |
|
||||
| `chart` | Generic canonical chart, displayed as a bar variant when `chartData` is used; can be inferred from payload type. |
|
||||
| `statistics` | Headline `count`, optional `countContext`, `percentageAmount`, `increaseIsGood`, and count/percentage links. Demo `value` payload uses the demo statistic renderer. |
|
||||
| `multiStatistics` | `statisticsGroupData`: groups with `header`, `subheader`, icon, and statistic list. |
|
||||
| `table` | `columns` (`header`, `accessor`, alignment, optional help/width) and `rows`; typed number, HTML, tooltip, image, composite/block cells; hidden helper columns, expandable subrows, optional `noRowsFoundHTML`, entries-per-page, sticky header and last-row behavior. |
|
||||
| `multiTable` | `tableDataList` of table payloads, each with its own label, rows and export action. |
|
||||
|
||||
### Composite and interactive widgets
|
||||
|
||||
| `type` | Use and important response fields |
|
||||
|---|---|
|
||||
| `composite` | QQQ block tree: `blocks`/`blockTypeName`, layout, values, links, tooltips and styles. Without those fields, demo `childWidgets` uses `CompositeWidget`. |
|
||||
| `parentWidget` | Server response `childWidgetNameList`; renders permitted registered children in a 12-column grid or `layoutType: "TABS"`. Children fetch their own data. |
|
||||
| `process` | Inline wizard using `processMetaData`, optional `defaultValues` and selected record IDs. Server permission still applies. |
|
||||
| `childRecordList` | Joined child records from `queryOutput.records` and `childFrontendTableMetaData`/`childTableMetaData`; `includeExposedJoinTables` adds readable join columns, and `defaultValuesForNewChildRecords` hides parent-key columns on screen. Optional `viewAllLink`, add-child defaults, row limits, `disableRowClick`, and CSV export via `showExportButton`. On a record form, `defaultValues.manageAssociationName` enables association editing. |
|
||||
| `customComponent` | Loads a trusted bundle from metadata `defaultValues.componentSourceUrl` and `componentName`, then resolves `window[componentName][componentName]`. The component receives widget metadata/data/record, `qContext`, and `qfmdBridge`; `window.React`/`window.ReactDOM` are exposed. Check CSP and test the actual older bundle before claiming compatibility. |
|
||||
| `cronUI` | Record view displays the expression, time zone and backend `cronDescription`. Put the widget in a table section and set `defaultValues.includeOnRecordEditScreen: true`, `cronExpressionFieldName`, and `timeZoneFieldName` to edit the named record fields on create/edit forms. The expression editor offers Basic days/hours/minutes, Advanced Quartz input, Clear, live description and validation. Give the time-zone field a possible-value source to render a select. |
|
||||
| `dynamicForm` | Labeled `fieldList` and `recordOfFieldValues`, including merged JSON field values. Editable in a form/process host when `defaultValues.isEditable` allows it; fields join host validation and submission. In a record form, `mergedDynamicFormValuesIntoFieldName` names the declared JSON table field that stores the values. Put that field in a hidden section, so QQQ still returns it to the widget renderer; a field marked `isHidden` is omitted from normal record reads. The renderer should return current values in `recordOfFieldValues` for reloads. |
|
||||
| `dataBagViewer` | Data bag/version browser with raw/preview tabs and version editor when `storeDataBagVersion` is available; uses `queryParams.id` or record ID and the built-in `dataBag` and `dataBagVersion` tables. |
|
||||
| `pivotTableSetup` | Record view summarizes saved `pivotTableJson` (`rows`, `columns`, `values`). In a form, a switch and modal edit its definition after table/`columnsJson` fields are available. OK changes the form draft; Save persists it. Turning the switch off clears the stored definition when the host form is saved. |
|
||||
| `filterAndColumnsSetup` | Record view summarizes saved filter, sort and columns JSON and shows a paged, sortable, resizable preview unless `hidePreview` is set. A host form offers Edit, + Add Filters and + Add Columns controls, then opens an editor for nested filters, multiple sort levels, visible/ordered columns and a draft preview; `OK` writes JSON into the host form, `Cancel` discards the draft. When payload `allowVariables` is true, a criterion value can become a `FilterVariableExpression`; its `${VARIABLE}` chip is saved in `queryFilterJson`, and preview and the opened query page wait for a value. Saving the host record or advancing a process persists the JSON. A `WIDGET` field adornment can host the setup in a process or row-builder field; its `widgetName` names the widget and only that field receives its output. `overrideIsEditable: false` keeps it read-only. |
|
||||
| `rowBuilder` | Payload `records`, with columns from `defaultValues.frontendFields` or `fields`. Record view/dashboard is read-only; a form/process host can add, edit, delete and reorder rows when the metadata's edit flags allow it. |
|
||||
| `scriptViewer` | Script revisions, files, logs, test runner, docs and editor; uses `queryParams.id` or record ID and the built-in script tables. |
|
||||
| `ESB_OVERVIEW` | ESB destination/trigger overview; fetches its own ESB API data and does not use the ordinary widget payload for content. Requires the ESB backend module. |
|
||||
|
||||
For `scriptViewer`, pass a script ID through `queryParams.id` or host it on that script's record. The built-in script and script-type tables supply revisions, schema-ordered files, docs and logs. Register `storeScriptRevision` to expose editing and `testScript` to expose the test panel. The editor supports per-file syntax coloring, split panes, API name/version selection when those fields exist, and a guard for unsaved changes. Local suggestions include QQQ API/logger helpers, language keywords and identifiers in the current file. Type a prefix or press Ctrl+Space, use arrows to choose and Enter/Tab to insert, or click/tap a suggestion. Escape dismisses the list; Escape followed by Tab leaves the editor. Completion supports native undo/redo; the browser controls how consecutive edits are grouped. Suggestions do not validate or execute code.
|
||||
|
||||
QQQ composite block types rendered by [`QqqBlocks.tsx`](../../src/components/widgets/blocks/QqqBlocks.tsx) include `TEXT`, `BIG_NUMBER`, `UP_OR_DOWN_NUMBER`, `NUMBER_ICON_BADGE`, `ICON`, `TABLE_SUB_ROW_DETAIL_ROW`, `PROGRESS_BAR`, `DIVIDER`, `IMAGE`, `AUDIO`, `INPUT_FIELD`, and `BUTTON`. Supported layouts include `FLEX_COLUMN`, `FLEX_ROW`, `FLEX_ROW_WRAPPED`, `FLEX_ROW_SPACE_BETWEEN`, `FLEX_ROW_CENTER`, `TABLE_SUB_ROW_DETAILS`, and `BADGES_WRAPPER`. Buttons that submit process actions and standalone input submissions need a host callback. Modal control codes also work locally on dashboard composites. Unknown block types show a contained warning. See [WID-057–WID-059](../acceptance/feature-matrix.md).
|
||||
|
||||
`INPUT_FIELD` blocks read `values.fieldMetaData`, optional `value`, `placeholder`, `autoFocus`, and `submitOnEnter`. Ordinary scalar inputs retain the compact block controls; the backend's neutral `%s` display format does not change their presentation. Possible-value sources, explicit formatting/behaviors, and `CODE_EDITOR`, `FILE_UPLOAD`, or `WIDGET` adornments use the shared Next field editors. Hidden fields are omitted and read-only fields cannot be changed. Standalone metadata editors retain a 14rem minimum width so short choice labels do not collapse the control. For `STRING`, `TEXT`, and explicitly configured `PASSWORD` blocks, `fieldMetaData.behaviors` accepts `TO_UPPER_CASE` or `TO_LOWER_CASE`: text changes case while typing and the caret remains at the edit position, including conversions that expand characters such as `ß` to `SS`. Selection boundaries and direction are preserved. For single-line inputs, process Enter and Submit both send the transformed value; standalone Enter requires `submitOnEnter` and a host callback. Multiline `TEXT` fields preserve newlines: Enter edits the text even when `submitOnEnter` is true, and the process Submit button sends the transformed value. Password inputs remain masked by default; showing or hiding the value does not change it. Passwords without a case behavior retain their entered case. The behavior names come from backend metadata, so do not apply a second conversion in an action callback.
|
||||
|
||||
Numeric block controls in RC4 blur on mouse-wheel scrolling to preserve the entered value, matching shared numeric form fields. Clicking back into the control allows normal typing and arrow-key edits. Text controls keep focus when scrolling. This correction is included in published RC4 (`3c409f9`); RC3 does not contain it.
|
||||
|
||||
In a process, block editors use the screen's form, validation and submission. A BLOB or FILE_UPLOAD field sends the selected `File` as multipart content, and required files block submission until chosen. Named choices on block-only fields search their standalone possible-value source; declared process fields use the process source. WIDGET fields receive surrounding process values (for example `tableName`) merged with current form values; editing writes only the adorned field. Scalar Enter submits only when `submitOnEnter` is true; `->code` submits an action code. Multiline code, choice searches and file controls own their keyboard interactions. Standalone blocks keep their draft locally and send scalar Enter values through their action callback. PRC-062 and WID-074 cover the process and standalone fixture paths. Enter trims text before form validation and submission; required whitespace cannot advance. Process initial values come from the process state, while standalone values come from the block. Configured autofocus takes precedence over heading focus on subsequent process screens, and the first blur does not display a premature required error. PRC-063 verifies this configuration across screen transitions and checks the actual stored text.
|
||||
|
||||
Ordinary record edits omit unchanged timestamps. Base Copy and both child-row editor hosts resubmit complete values, so they retain each unchanged timestamp's source instant and fractional seconds. REC-065 verifies copy persistence during the repeated daylight-saving hour; component regressions cover association and in-process child rows.
|
||||
|
||||
`DATE_TIME` process fields and input blocks display instants in the browser's local timezone, retain seconds, and send edited times as zoned UTC values. Unchanged values retain their original instant and subsecond precision, including a repeated daylight-saving hour. `DATE` and `TIME` remain calendar and wall-clock values. PRC-064 checks seeded and edited values across plain blocks, metadata editors and regular process forms against backend SQL; WID-075 checks standalone display.
|
||||
|
||||
The filter/column editor reads the payload's `tableName` or the form's `tableName`, then loads that table's metadata. It writes to `queryFilterJson` and `columnsJson` by default; `filterFieldName` and `columnFieldName`/`columnsFieldName` override those keys. `hidePreview` controls the read-only summary preview. Starting in RC5, the editing dialog retains its query grid even when that summary is hidden, matching Material behavior; RC4 incorrectly hides both. Payload flags `hideColumns`, `hideSortBy`, `allowVariables`, `isApiVersioned`, `apiName`/`apiPath`/`apiVersion`, `filterDefaultFieldNames`, `filterDefaultFieldNameSourceFieldNames`, `omitExposedJoins`, `editButtonLabel`, and `modalHeader` affect the editor. It waits for a selected table and required default-field values. The preview supports server-side paging, sorting, column resizing, Refresh, column configuration, and column-menu actions (filter, hide, pin, copy page/full-query values, and statistics when permitted). The editor also offers density, Basic quick filters, Advanced conditions, and selection of an existing saved view. Selecting a view imports its filter and columns into the draft; it does not modify that saved view. Column widths and pins survive host Save. Unresolved variables suppress the preview; assign variables in Advanced mode. Interactions in a read-only record preview are temporary and never update the saved report. Starting in RC4, `omitExposedJoins` limits new filter/column choices in both the read-only preview and editor. Existing saved joined columns, criteria and sorts remain usable, and reordering available columns preserves omitted columns in place. Opening the editor and pressing OK preserves those saved fields; Basic/Advanced changes and host Save/reopen retain their values and widths. This is a choice restriction, not a backend permission boundary; existing joined data still uses the normal query permissions. WID-073 checks the configured and unconfigured cases against SQL, including local edits and reload. `OK` updates the host form's values, so the record must still be saved to persist. [WID-030 and WID-070](../acceptance/feature-matrix.md) cover the summary, preview, Cancel, OK, and persisted host save across five browser profiles.
|
||||
|
||||
In the report setup dialog, Left/Right Arrow switches between Filters and sort and Columns; Home/End selects the first/last available tab. Tab enters the selected panel's controls. Switching tabs preserves the draft, and metadata that hides Columns leaves only the filter tab. The tab controls expose `filter-editor-tab-<widget>-filters` and `filter-editor-tab-<widget>-columns` CSS hooks.
|
||||
|
||||
Selecting a saved view imports it into the report draft. **Reset Changes** reapplies that view; **Reset to New View** clears filters and custom quick fields, restores default columns, and sorts by the primary key descending when present. Both discard an open quick-filter edit. These actions leave the saved view unchanged. OK applies the draft to the host form; saving the form persists it. Cancel discards the dialog draft.
|
||||
|
||||
Complete conditions, including nested groups, mark the corresponding preview column header. Use that indicator to open the existing filter. It adds no condition; editing a read-only preview stays local, and editor changes remain a draft until OK and host Save. Very narrow columns retain the shared grid’s compact header treatment.
|
||||
|
||||
When a preview table declares `usesVariants`, select its backend variant before querying. The choice uses the same per-table browser storage as Record Query (`qqq.tableVariant.<table>`). Switching variants starts a fresh preview page; query/count, full-column copy and statistics use the selection. It does not alter the saved report definition. Application-API query/count require the backend fix in [QQQ PR #913](https://github.com/QRun-IO/qqq/pull/913), merged to QQQ develop as `cba758507dcc54cf282a6638ab50a7d3a1904c7c`; verification used the middleware JAR built from its reviewed source. This does not require or announce a QQQ 4.1 release.
|
||||
|
||||
|
||||
|
||||
### Demo and compatibility dispatch labels
|
||||
|
||||
The remaining dispatch labels mainly support local mock fixtures. They are implemented UI paths, but are not a promise that the production QQQ backend emits those shapes. Check [`src/mocks/fixtures/widgets`](../../src/mocks/fixtures/widgets) for demo payloads and the real widget fixtures and matrix for QQQ-backed examples.
|
||||
|
||||
| `type` | Use and important response fields |
|
||||
|---|---|
|
||||
| `recordGrid` | Demo record grid with `tableName`, `records`, and `columns` or full `fields`; optionally `totalCount`. |
|
||||
| `quickLinks` | Demo list of labeled navigation links; inspect the fixture's `links` payload before using it. |
|
||||
| `processSummary` | Demo process status/summary cards; inspect the fixture's status and counts before using it. |
|
||||
| `block` | Chooses QQQ composite blocks when the payload has `blockTypeName` or `blocks`; otherwise falls back to `BlockWidget`. A demo `blocks` list therefore enters the QQQ composite branch. |
|
||||
| `parent` | Demo `CompositeWidget` alias using `childWidgets`; canonical QQQ parent widgets use `parentWidget` and `childWidgetNameList`. |
|
||||
|
||||
Canonical chart types can also fall through to the demo chart components when the response is not a QQQ `chartData` payload.
|
||||
|
||||
### Widget configuration recipes
|
||||
|
||||
These examples are payload fragments emitted by an application's backend renderer, with illustrative names and values. Pair them with matching `QWidgetMetaData`; they are not standalone Java producer definitions. Every dispatch label is listed above; the details below cover the shared and specialized configuration that is easy to miss when copying a basic example.
|
||||
|
||||
**Responsive placement.** Metadata `defaultValues` accepts `gridCols:sizeClass:xs`, `sm`, `md`, `lg`, `xl`, and `xxl`. Their minimum viewport widths are 0, 576, 768, 992, 1200, and 1400 px. Values are column counts from 1 to 12; numeric strings are accepted. Once any override is set, `xs` defaults to 12 and `xxl` defaults to `gridColumns` or 12. Other declared spans apply until the next declared breakpoint. This example gives a half-width widget from 768 px upward, including 1400 px:
|
||||
|
||||
```json
|
||||
{"gridColumns":6,"defaultValues":{"gridCols:sizeClass:xs":12,"gridCols:sizeClass:md":6}}
|
||||
```
|
||||
|
||||
See [`widgetColumnClasses`](../../src/components/widgets/widget-utils.ts). Parent grids use the same sizing. A `parentWidget` payload such as `{"childWidgetNameList":["totals","trend"],"layoutType":"TABS"}` resolves those names in the instance widget registry. Children inherit parent request parameters and dropdown selections and fetch their own data; tab selection is remembered locally. A child omitted from permitted metadata cannot be configured into existence by listing its name.
|
||||
|
||||
**Dropdown data.** Metadata and response lists are paired by position; keep their lengths and order aligned. For the `region` dropdown example above, a response fragment is:
|
||||
|
||||
```json
|
||||
{"dropdownNameList":["regions"],"dropdownLabelList":["Region"],"dropdownDataList":[[{"id":"east","label":"East"},{"id":"west","label":"West"}]],"dropdownDefaultValueList":["east"]}
|
||||
```
|
||||
|
||||
The request parameter is the payload's `dropdownNameList` entry (`regions` here). The backend convention uses the possible-value source name for choice controls and the declared name for date controls. A valid stored choice wins over the response default; unavailable stored choices are cleared. `labelForNullValue` adds an explicit empty option only when the payload has no default. `disableClearable` removes the clear button. To gate the body, return `dropdownNeedsSelectedText` until the required choice is supplied. Metadata `isRequired` and `defaultValue` alone are not consumed as client-side gating/default rules by [`ConnectedWidget`](../../src/components/widgets/ConnectedWidget.tsx). A date control sends local `YYYY-MM-DD` text; the special custom timeframe sends the UTC range described above.
|
||||
|
||||
**Charts and statistics.** All seven chart dispatch labels use the canonical shape below. `backgroundColors` and `urls` are arrays aligned with data points; `color` supplies the series color and `backgroundColor` is the stacked-series fallback. Top-level `chartData.urls` supplies category links. `height` is numeric; `isCurrency` or `isYAxisCurrency` enables the renderer's currency formatting.
|
||||
|
||||
```json
|
||||
{"type":"barChart","height":280,"isCurrency":true,"chartData":{"labels":["Open","Closed"],"datasets":[{"label":"Value","data":[1200,800],"backgroundColors":["info","success"],"urls":["/app/orders/","/app/archive/"]}]}}
|
||||
```
|
||||
|
||||
`chartSubheaderData` accepts `mainNumber`, `vsPreviousPercent`, `vsPreviousNumber`, `isUpVsPrevious`, `isGoodVsPrevious`, `vsDescription`, `mainNumberUrl`, and `previousNumberUrl`; use it for the optional comparison summary. See [`QqqChartPayload`](../../src/components/widgets/widget-types.ts). Statistics adds `countFontSize`, `countURL`, `percentageURL`, and `isCurrency` to the basic example; numeric currency counts format as USD, while string counts pass through. A zero or absent percentage hides the change row. Multi-statistics uses this nested shape:
|
||||
|
||||
```json
|
||||
{"type":"multiStatistics","statisticsGroupData":[{"header":"Orders","subheader":"Today","icon":"inventory_2","iconColor":"#0062ff","statisticList":[{"label":"Open","value":7,"url":"/app/orders/"}]}]}
|
||||
```
|
||||
|
||||
**Tables.** Each `table` payload (and each member of `multiTable.tableDataList`) uses the same configuration. `rowsPerPage` defaults to 10; `hidePaginationDropdown` hides its size selector, `fixedHeight` constrains the scroll area, and `fixedStickyLastRow` fixes the final row. `linkText`/`linkURL` add the table link. Columns accept `type`, `header`, `accessor`, `width`, `align`, and `verticalAlign`; widths can be fixed CSS lengths or `fr` shares.
|
||||
|
||||
```json
|
||||
{"type":"table","rowsPerPage":5,"columns":[{"header":"Name","accessor":"name","width":"2fr"},{"header":"Count","accessor":"count","width":"1fr"},{"accessor":"tooltip","type":"hidden"}],"rows":[{"name":"Open","count":7,"tooltip":"Current backlog","subRows":[{"name":"Priority","count":2}]}]}
|
||||
```
|
||||
|
||||
Cell `type` is `default`, `html`, `htmlAndTooltip`, `composite`, `block`, `image`, or `hidden`. An `htmlAndTooltip` cell reads the row's `tooltip`; `image` reads `imageUrl`, `imageLabel`, `imageTotal`, and `imageTotalType`. Composite/block cells read a block payload at the column accessor. Hidden helper columns do not display but remain available to cells and export. `subRows` provides expandable rows. See [`QqqTablePayload`](../../src/components/widgets/QqqTableWidget.tsx) and [`TableWidgetColumn`](../../src/components/widgets/table-widget-utils.ts).
|
||||
|
||||
**Display and editor details.** These keys supplement each catalog row; they are read by the linked implementations.
|
||||
|
||||
| Widget | Additional configuration |
|
||||
|---|---|
|
||||
| `stepper` | Each step accepts `label`, `linkText`, `linkURL`, `iconOverride`, and `colorOverride`; only the active step shows its action link. |
|
||||
| `fieldValueList` | Use `fieldLabelPrefixIconNames`, `fieldLabelPrefixIconColors`, and `fieldIndentLevels`, keyed by field name. Fields use the shared record formatter, including declared formats/adornments and possible-value record links. |
|
||||
| `alert` | `alertType` supports ERROR, WARNING, SUCCESS and INFO; absent/unknown values use INFO. `bulletList` contains sanitized HTML strings. Empty `html` renders no alert body. |
|
||||
| `usaMap` | `mapMarkerList` entries use `name`, `latitude`, and `longitude`; the UI includes the US basemap and an accessible location list. |
|
||||
| `html`, `generic`, `divider`, `location`, `quickSightChart` | Use the fields in the catalog and shared chrome; these types do not expose host-form editing. QuickSight's optional payload `label` names the iframe. |
|
||||
| `childRecordList` | `omitFieldNames` and `onlyIncludeFieldNames` control columns. `canAddChildRecord` enables Add; `disabledFieldsForNewChildRecords` locks named defaults, and `defaultValuesForNewChildRecordsFromParentFields` maps child field names to parent field names. `totalRows` supplies the total. A process host uses `isInProcess`, `allowRecordEdit`, and `allowRecordDelete` to control in-memory rows. |
|
||||
| `rowBuilder` | Metadata defaults include `frontendFields` (fallback `fields`), `associationName`/`outputFieldName`, `orderByFieldName`, `mayReorderRows`, `isForRecordViewAndEditScreen`, `isEditable`, `useModalEditor`, `inlineHeading`, `modalTitle`, and `defaultValuesForNewRowsFromParentRecord`; see the [agent editor guide](./next-ui-agent-guide.md#widget-editors-and-host-forms). |
|
||||
| `process` | `processMetaData.name` selects the process; payload `defaultValues` presets its inputs. Reloading the widget reinitializes the embedded run. |
|
||||
| `cronUI`, `dynamicForm`, `pivotTableSetup`, `filterAndColumnsSetup` | These edit host fields through their documented bindings above; changing the widget draft alone does not save a record. Use the shared form context and inspect each editor's validation before introducing a new field binding. |
|
||||
| `dataBagViewer`, `scriptViewer`, `ESB_OVERVIEW`, `customComponent` | These require their respective backend module/process or trusted bundle described above; ordinary display payloads cannot replace those dependencies. |
|
||||
| `recordGrid`, `quickLinks`, `processSummary`, `parent`, demo `block` | Use the [demo fixtures](../../src/mocks/fixtures/widgets) and matching renderer for the demo contract. These labels do not imply an equivalent canonical Java renderer. |
|
||||
|
||||
**Composite blocks.** `link` and `tooltip` apply to the block; `linkMap` and `tooltipMap` apply to named slots, such as `NUMBER`. Links carry `href` and optional `target`; tooltips carry `title`, `placement`, or nested `blockData`. Help slots can provide fallback tooltip text. `styles` configures the leaf block, while composites also accept `styleOverrides`, sanitized `overlayHtml`, and `overlayStyleOverrides`.
|
||||
|
||||
| `blockTypeName` | Values and styles to supply |
|
||||
|---|---|
|
||||
| `TEXT` | `values.text`, optional `startIcon`/`endIcon`; `styles.color`, `format`, `size`, `weight`. |
|
||||
| `NUMBER_ICON_BADGE` | `values.number`, `iconName`. |
|
||||
| `UP_OR_DOWN_NUMBER` | `values.number`, `context`, `isUp`, `isGood`; `styles.colorOverride`, `isStacked`. |
|
||||
| `TABLE_SUB_ROW_DETAIL_ROW` | `values.label`, `value`; `styles.labelColor`, `valueColor`. |
|
||||
| `PROGRESS_BAR` | `values.percent`, `heading`, `value`; `styles.barColor`. |
|
||||
| `BIG_NUMBER` | `values.heading`, `number`, `context`; `styles.width`, `numberColor`. |
|
||||
| `DIVIDER` | No values required. |
|
||||
| `IMAGE` | `values.path`, `alt`; `styles.width`, `height`, `bordered`. |
|
||||
| `AUDIO` | `values.path`, `showControls`, `autoPlay`; playback still follows browser policy. |
|
||||
| `ICON` | `values.name`; `styles.color`, `fontSize`. |
|
||||
| `BUTTON` | `values.label`, `actionCode` or `controlCode`, optional `startIcon`/`endIcon`; `styles.format` is filled, outlined or text. |
|
||||
| `INPUT_FIELD` | `values.fieldMetaData`, `value`, `placeholder`, `autoFocus`, `submitOnEnter`; host behavior is detailed above. |
|
||||
| `COMPOSITE` | Nested `blocks`, one of the documented `layout` values, and optional `blockId`/`modalMode`. |
|
||||
|
||||
A host evaluates `conditional` as the truthiness of the named host value and interpolates `${name}` in text; it is not a JavaScript expression. Dashboard composites without a process host do not evaluate those conditions. Modal buttons use `showModal:<blockId>`, `hideModal:<blockId>`, or `toggleModal:<blockId>` control codes. A nonempty `modalMode` marks the target composite; a host value with that block ID set to literal `true` opens it initially. This local dashboard example needs no process submission callback:
|
||||
|
||||
```json
|
||||
{"type":"composite","blocks":[{"blockTypeName":"BUTTON","values":{"label":"Details","controlCode":"showModal:details"}},{"blockTypeName":"COMPOSITE","blockId":"details","modalMode":"dialog","blocks":[{"blockTypeName":"TEXT","values":{"text":"Details appear here."}}]}]}
|
||||
```
|
||||
|
||||
See [`QqqComposite`](../../src/components/widgets/blocks/QqqComposite.tsx), [`QqqBlocks`](../../src/components/widgets/blocks/QqqBlocks.tsx), and [`BlockSlot`](../../src/components/widgets/blocks/BlockSlot.tsx). RC6 icon compatibility preserves existing Lucide mappings and adds a locally served glyph for valid unmapped names from the pinned 2,234-name legacy Material Icons inventory. Examples include `3d_rotation`, `account_balance_wallet`, `battery_6_bar`, and `60fps`. Unknown names retain their fallback; this does not include every Material Symbols name or distinct outlined/rounded font variants. Metadata image paths still take precedence. The font and Apache license ship with the static export/JAR; no Google Fonts request is needed at runtime. See the [font provenance](../../public/fonts/material-icons/README.md). This behavior first ships in RC6. The parity ledger also retains open INPUT_FIELD configuration and visual review work.
|
||||
|
||||
## 8. Theme, branding, and customization
|
||||
|
||||
`QInstance.branding` supplies app/company name, logo/icon, accent colors, banners and optional `gravatarDefault`. A company footer appears when both `companyName` and a safe HTTP(S) `companyUrl` are present. The active application theme is `QInstance.supplementalInstanceMetaData.materialDashboardTheme`, read by [`material-theme.ts`](../../src/lib/theme/material-theme.ts); do not configure `QInstance.theme` and expect these styles to apply. Its optional fields cover palette, fonts, typography, radii, density, logo/icon/favicon, icon style, branded header, app bar, sidebar, table colors, and `customCss`. Invalid theme values are ignored. For example:
|
||||
|
||||
```json
|
||||
{"supplementalInstanceMetaData":{"materialDashboardTheme":{"primaryColor":"#245ca6","fontFamily":"Inter, sans-serif","borderRadiusCard":"12px","brandedHeaderEnabled":true,"brandedHeaderTagline":"Operations","customCss":"[data-qqq-id='sidenav-root'] { border-right: 1px solid #ccc; }"}}}
|
||||
```
|
||||
|
||||
[`apply-branding.ts`](../../src/lib/theme/apply-branding.ts), [`material-theme.ts`](../../src/lib/theme/material-theme.ts), [`tokens.ts`](../../src/lib/theme/tokens.ts), and [`qqq-theme.css`](../../src/styles/qqq-theme.css) define the CSS mapping, including supported Material `--qqq-*` variables. Scope custom CSS to observed `data-qqq-id` attributes, and inspect actual rendering and contrast before migrating a Material stylesheet. A declared application theme sets the app look and keeps light mode active. Without an application theme, the local display-mode preference applies; a fresh profile starts in light mode regardless of the operating system preference. Density is available through theme metadata and local preferences. Open the sidebar user menu → Preferences → Appearance to choose Light or Dark. Changes apply immediately and persist in `localStorage` as `qqq-dark-mode` for this browser profile and origin (scheme, host and port), not the signed-in account; there is no cross-device sync or operating-system mode. Clearing site data removes the preference. When an application theme is active, the choices are disabled with an explanation and the saved preference is retained. Reset to Defaults resets appearance to light along with table and record defaults.
|
||||
|
||||
See the [Material CSS hook guide](../CSS-HOOKS.md) for the ID sanitizer, selector patterns, named hooks, and phone/tablet visibility rules.
|
||||
|
||||
Analytics is configured by the backend's `QInstance.environmentValues` ([`src/lib/analytics`](../../src/lib/analytics)). It stays off when no analytics values are published. `ANALYTICS_PROVIDERS` can name Google, PostHog, or registered plugins; provider-specific IDs and enable flags are also required. The default privacy behavior omits record IDs, labels and query strings and does not identify users. Only publish `ANALYTICS_INCLUDE_RECORD_DATA=true` or `ANALYTICS_IDENTIFY_USERS=true` after deciding those disclosures are appropriate for the application.
|
||||
|
||||
### Theme property reference
|
||||
|
||||
All keys below belong to `supplementalInstanceMetaData.materialDashboardTheme`. Unset properties retain the Next defaults. The executable allow-list and value validation are [`THEME_PROPERTY_KINDS`](../../src/lib/theme/material-theme.ts); [`QThemeMetaData`](../../src/types/metadata.ts) defines their types.
|
||||
|
||||
| Area | Keys or key pattern |
|
||||
|---|---|
|
||||
| Palette | `primaryColor`, `secondaryColor`, `backgroundColor`, `surfaceColor`, `textPrimary`, `textSecondary`, `errorColor`, `warningColor`, `successColor`, `infoColor`. |
|
||||
| Action color | `preferInfoColorToPrimaryColor` chooses the info color for the controls that honor that option. |
|
||||
| Fonts | `fontFamily`, `headerFontFamily`, `monoFontFamily`, `fontSizeBase`, `fontWeightLight`, `fontWeightRegular`, `fontWeightMedium`, `fontWeightBold`. |
|
||||
| Typography | `typography{variant}{property}`, where variant is `H1`–`H6`, `Body1`, `Body2`, `Button`, or `Caption`, and property is `FontSize`, `FontWeight`, `LineHeight`, `LetterSpacing`, or `TextTransform`. Example: `typographyH2FontSize: "1.5rem"`. |
|
||||
| Radii | `borderRadiusGlobal`, `borderRadiusScale`, and `borderRadius{component}` for `Button`, `Card`, `Chip`, `Dialog`, `OutlinedInput`, `LinearProgress`, `MenuPaper`, `PaperRounded`, `PopoverPaper`, and `Tooltip`. A component radius overrides the global radius; the scale applies when no global radius is set. |
|
||||
| Density and icons | `density`: `compact`, `normal`, `comfortable`; `iconStyle`: `filled`, `outlined`, `rounded`, `sharp`, `two-tone`. Unsupported icon names can still fall back to the default glyph. |
|
||||
| Images and CSS | `logoPath`, `iconPath`, `faviconPath`, `customCss`. |
|
||||
| Branded header | `brandedHeaderEnabled`, `brandedHeaderBackgroundColor`, `brandedHeaderTextColor`, `brandedHeaderLogoPath`, `brandedHeaderLogoAltText`, `brandedHeaderHeight`, `brandedHeaderTagline`. |
|
||||
| App bar | `appBarBackgroundColor`, `appBarTextColor`. |
|
||||
| Sidebar | `sidebarBackgroundColor`, `sidebarTextColor`, `sidebarIconColor`, `sidebarSelectedBackgroundColor`, `sidebarSelectedTextColor`, `sidebarHoverBackgroundColor`, `sidebarDividerColor`. |
|
||||
| Tables | `tableHeaderBackgroundColor`, `tableHeaderTextColor`, `tableRowHoverColor`, `tableRowSelectedColor`, `tableBorderColor`. |
|
||||
| Borders | `dividerColor`, `borderColor`, `cardBorderColor`. |
|
||||
|
||||
Lengths use CSS length strings; colors use supported named, hex, RGB or HSL values. Font weights must be integers from 1 through 1000; radius scale is 0 through 10. Text transforms are `none`, `uppercase`, `lowercase`, or `capitalize`. Invalid values are ignored rather than inserted into CSS. Application CSS and external assets remain trusted configuration, and their actual appearance/CSP must be checked on the host.
|
||||
|
||||
### Help content configuration
|
||||
|
||||
Fields and sections expose `helpContents`; widgets expose a slot map in `helpContent`. Entries supply `content`, `format` (`TEXT`, `HTML`, or `MARKDOWN`), and optional `roles`. The shared selector prefers the first entry matching the most specific screen role, then an entry without roles. View roles are `VIEW_SCREEN`, `READ_SCREENS`, `ALL_SCREENS`; query substitutes `QUERY_SCREEN`; edit uses `EDIT_SCREEN`, `WRITE_SCREENS`, `ALL_SCREENS`; create/copy substitutes `INSERT_SCREEN`; processes use `PROCESS_SCREEN`, `ALL_SCREENS`.
|
||||
|
||||
For example, widget help can use `{"helpContent":{"label":[{"content":"Current totals","format":"TEXT"}],"columnHeader=count":[{"content":"Matching records","format":"TEXT"}]}}`. Table widget headers use `columnHeader=<accessor>` slots; composite slots use `<blockId>,<slot>` keys. Turn on `?helpHelp` to inspect the exact key at the target control before defining content. See [`help-utils.ts`](../../src/lib/utils/help-utils.ts) and [`widgetSlotHelp`](../../src/components/widgets/widget-utils.ts).
|
||||
|
||||
### Analytics configuration example
|
||||
|
||||
Publish these values through `QInstance.environmentValues`, not frontend build variables. Google uses `GOOGLE_ANALYTICS_ENABLED="true"` and `GOOGLE_ANALYTICS_TRACKING_ID` (a valid GA4 tracking ID). PostHog uses `POSTHOG_ENABLED="true"`, `POSTHOG_API_KEY` (alias `POSTHOG_PROJECT_API_KEY`), and optional `POSTHOG_HOST`, which defaults to `https://us.i.posthog.com`.
|
||||
|
||||
```json
|
||||
{"environmentValues":{"ANALYTICS_PROVIDERS":"google,posthog","GOOGLE_ANALYTICS_ENABLED":"true","GOOGLE_ANALYTICS_TRACKING_ID":"G-EXAMPLE123","POSTHOG_ENABLED":"true","POSTHOG_PROJECT_API_KEY":"<public-project-key>","ANALYTICS_INCLUDE_RECORD_DATA":"false","ANALYTICS_IDENTIFY_USERS":"false"}}
|
||||
```
|
||||
|
||||
Replace the illustrative IDs with the application's public provider identifiers. `ANALYTICS_PROVIDERS` accepts comma-, semicolon-, or newline-separated names; names are lowercased. If omitted, Google and PostHog are considered, but each still requires its own enable/configuration values. `ANALYTICS_PLUGIN_SCRIPTS` (alias `ANALYTICS_PLUGIN_SCRIPT_URLS`) loads configured provider scripts; those register through `window.QQQAnalytics`. Use the [provider registry](../../src/lib/analytics/registry.ts) and [provider interface](../../src/lib/analytics/types.ts), and configure host CSP for each script/endpoint. Privacy defaults and opt-ins are described above; never place server-side provider secrets in environment values exposed to browsers.
|
||||
|
||||
## 9. Accessibility, testing, and known boundaries
|
||||
|
||||
Components use labeled fields, keyboard navigation, focus-managed dialogs and drawers, inline errors, contained widget failures, and touch layouts. Below 768 px, queries become record cards and filters/actions use sheets; tablet testing uses a touch WebKit project. Test with keyboard and assistive technology in addition to axe. See [browser matrix](../acceptance/browser-matrix.md) and [accessibility rows](../acceptance/feature-matrix.md).
|
||||
|
||||
Run `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm test:e2e`, `pnpm build:export`, and `pnpm perf:budget` for the relevant change. For RC1, prioritize complete Chromium workflows and the full Chromium gate before expanding browser/viewport coverage; verify defaults, validation, save/reopen and persisted results. The final release acceptance gate is `QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet pnpm test:acceptance` with `QQQ_SAMPLE_JAR` pointing at a compatible real sample build. It verifies SQL/backend behavior as well as browser behavior; mocked tests are not a substitute. See [test setup](../../tests/acceptance/README.md), [performance budget](../acceptance/performance.md), and [API error handling](../API-ERROR-HANDLING.md).
|
||||
|
||||
Current limits to account for: the Material parity ledger has open Partial/Missing rows beyond the narrower feature matrix; the report filter/column editor supports variables and API-versioned metadata but still needs complete embedded-query equivalence review; a built-in QFMD bridge does not prove every third-party Material bundle works; root-path static hosting is the documented deployment; and Google Picker requires a backend with the #704 metadata/CSP changes (backend PR #908 merged to develop as `bac663044`). Real Auth0 tenant, Google account, and AWS QuickSight service tests have approved external-service exclusions in the feature matrix. Validate any additional feature against the current branch, backend contract, and matrix before describing it as production-compatible.
|
||||
|
||||
|
||||
### Google Drive process configuration
|
||||
|
||||
This implementation is part of work after the immutable RC1 release. RC1 itself retains the disabled picker placeholder; the final 1.0 implementation requires the matching backend changes below.
|
||||
|
||||
Set the public browser OAuth client ID and browser API key as `GOOGLE_APP_CLIENT_ID`
|
||||
and `GOOGLE_APP_API_KEY` in the QInstance environment (normally `QQQ_ENV_` environment
|
||||
variables). Enable the Google Picker and Drive APIs and configure the application's
|
||||
JavaScript origin in Google Cloud. Restrict the API key to the required APIs and website
|
||||
origins, including `https://docs.google.com/*` as required by the
|
||||
[Google Picker setup guide](https://developers.google.com/workspace/drive/picker/guides/web-picker).
|
||||
Client secrets are not browser configuration and are not published in v1 metadata.
|
||||
|
||||
The Next UI middleware must include the #704 changes (backend PR #908, merged to develop as `bac663044`) that publish these two settings
|
||||
and permit Google's SDK and picker origins in CSP only when both are configured.
|
||||
Those conditional headers apply to the Javalin-hosted static export. The standalone
|
||||
Node/container host also derives them when the backend publishes `dashboardCspSources`
|
||||
in authentication metadata. With an older backend that omits that optional field, supply
|
||||
`QQQ_DASHBOARD_CSP_SOURCES` at runtime. Merge these sources with any other deployment
|
||||
additions and start through [`standalone/qqq-server.mjs`](../../standalone/qqq-server.mjs):
|
||||
|
||||
```bash
|
||||
export QQQ_DASHBOARD_CSP_SOURCES='script-src https://accounts.google.com/gsi/client https://apis.google.com; connect-src https://accounts.google.com/gsi/; frame-src https://accounts.google.com/gsi/ https://docs.google.com; style-src https://accounts.google.com/gsi/style'
|
||||
```
|
||||
|
||||
This matches the #704 backend directive additions. The standalone parser adds these to
|
||||
its base policy; custom reverse proxies/hosts must supply equivalent allowances. See
|
||||
[`security-headers.mjs`](../../standalone/security-headers.mjs). Keep the metadata client
|
||||
ID/API key configured too: CSP permissions alone do not enable the picker.
|
||||
|
||||
The component preserves Material's
|
||||
shared-drive folder view and `drive` OAuth scope. It loads the official SDKs before the
|
||||
button becomes ready, supports retry after loading or authorization errors, rejects files,
|
||||
and requires selection again if authorization expires before submission. Tokens remain
|
||||
in screen memory and are submitted to the backend process; they are not persisted in
|
||||
localStorage or sessionStorage. Without configuration the picker stays disabled while
|
||||
other inputs remain usable. PRC-065 verifies the browser/backend contract with controlled
|
||||
Google SDK responses; PRC-039 retains the approved real-account testing exclusion.
|
||||
|
||||
|
||||
### Post-RC4 table appearance correction
|
||||
|
||||
RC5 restores the original Next uniform row background; RC4 has alternating row shading. Selection highlighting and opaque pinned cells are retained. Applications can target `data-row-parity="odd"` or `"even"` through their configured CSS if they explicitly want alternating colors.
|
||||
|
||||
### API playground theme integration (RC7)
|
||||
|
||||
The embedded RapiDoc viewer uses its own shadow DOM. Next applies existing dashboard color tokens to its dark accent/inverse pair and light contact/status colors, preserving the surrounding layout. Preferences theme changes apply without reloading the page. Focused Chromium desktop/mobile checks cover the fixture's visible text, hovered controls and live theme changes; expanded endpoint states, arbitrary custom palettes and narrow-screen inner layout still require review. This correction is not included in RC6.
|
||||
|
||||
### TIME block correction (RC7)
|
||||
|
||||
Plain process and standalone TIME blocks now use a one-second native input step, matching regular/shared form fields. This permits second-level clock values without a minute-step validation error and makes native stepping advance one second. TIME values remain local clock strings, with no timezone conversion. This correction is published in RC7 at `f65cf6b`; earlier RCs remain unchanged.
|
||||
|
||||
|
||||
### Narrow API controls and LONG validation (RC8)
|
||||
|
||||
The API specification Download and View actions fit narrow panels, including 320px phones, and wrap their labels when necessary. Their download/new-tab behavior is preserved. On wide panels, buttons grow to fit the rendered font instead of clipping labels at a fixed width. This correction is not included in RC7.
|
||||
|
||||
When the frontend receives LONG range bounds, invalid integer text produces a field error before range comparisons run. Integer-string bounds retain exact precision beyond JavaScript's safe-number range; optional blanks and required-field messages retain their existing behavior. This describes the frontend schema's supported inputs, not a new backend metadata contract. RC8 includes this correction; RC7 does not.
|
||||
@@ -0,0 +1,26 @@
|
||||
# API playground controls in narrow panels — GH-724
|
||||
|
||||
## Requirement
|
||||
|
||||
Keep the original Next developer view and make its API specification actions fully visible and usable in narrow panels, including 320px phones. Preserve the wide desktop layout, real spec download/new-tab actions, light/dark themes and keyboard access.
|
||||
|
||||
## Evidence and implementation
|
||||
|
||||
- The packaged RC7 JAR fails the new real-backend visibility test: the Download button is partially clipped before any focus/click can horizontally scroll hidden ancestors. The View button extends outside the panel too.
|
||||
- RapiDoc 9.3.8 has a 360px host minimum, a negative overview info margin and two fixed-width, non-wrapping buttons.
|
||||
- Allow the host to fit its panel. Use the public overview/button CSS parts under a panel-width container query to provide safe inset and share available button width with wrapped labels. A named overview container limits button adjustments to specification actions, excluding authentication and endpoint controls. No dependency changes or shadow DOM mutation.
|
||||
|
||||
## Verification plan
|
||||
|
||||
1. Retain the failing Chromium phone trace and screenshot.
|
||||
2. Run light/dark, normal-width and 320px workflows with desktop and touch input. Check complete visibility before interaction, download the real OpenAPI JSON, and open the same spec using keyboard navigation.
|
||||
3. Inspect screenshots and compare wide desktop before/after layout.
|
||||
4. Run existing API contrast/theme workflows, unit suite, types, lint, export and bundle budgets. Obtain independent review before integration.
|
||||
|
||||
Evidence: original checkout `test-results/visual-review/api-playground-width/`. This follow-up is separate from frozen RC7 candidate `f65cf6b`.
|
||||
|
||||
## Results
|
||||
|
||||
The final production export passed 12 focused desktop/mobile workflows, with zero failures, skips, flaky outcomes or report errors. All 1,904 unit tests, types, lint/license checks and the default Turbopack export passed. The unchanged bundle budget passed at 963.3KB/1050KB. The 1440px overview/button geometry and entire RapiDoc screenshot are identical to RC7; final phone screenshots were inspected.
|
||||
|
||||
Earlier evidence is retained: the first green attempt exposed a relative-versus-absolute URL assertion error; a later run used a running Java server with a replaced export, invalidating its startup asset/CSP registry. The server was restarted against the finished export before the passing run. A Webpack build exceeded the size budget; the release/CI Turbopack pipeline passed without changing budgets. The incompatible old font-response mock was removed from the build command; the final build uses the normal font pipeline.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Bounded LONG validation — GH-728
|
||||
|
||||
## Defect and correction
|
||||
|
||||
Configured LONG ranges exposed two defects in the shared metadata schema: chained Zod refinements called `BigInt` after format validation had already failed, and exact integer-string bounds were rounded through `Number`. Seven new schema cases reproduced exceptions or wrongly accepted values before the fix.
|
||||
|
||||
The schema now pipes successfully validated integer text into its range checks. Integer-string bounds remain BigInt values; numeric fractional bounds use native mixed numeric comparisons. Optional blanks, required-field errors, signs, and existing field messages are preserved. No rendering changes or dependencies are introduced.
|
||||
|
||||
## Verification
|
||||
|
||||
- Seven schema regressions passed after failing on the previous source.
|
||||
- Block and shared process-form component tests verify invalid fractional text, a below-minimum error, blocked submission, and correction to exact digits beyond the safe-number range.
|
||||
- An unrelated intermittent unit failure expected a globally unique “Green” label although both view and edit fields display it. Its separate correction targets the read-only value being tested.
|
||||
- All 1,913 units across 180 files passed; the 95 affected tests, types, lint/license and diff checks passed. Independent review approved both corrections.
|
||||
- Evidence: original checkout `test-results/visual-review/long-range-validation/`. The initial full-run failure remains retained in `units.log`; the final full run is `units-final.log`.
|
||||
|
||||
This follow-up is not included in immutable RC7 `f65cf6b`. Production export and real-backend verification of the next integrated candidate remain required; these results establish schema/component behavior, not a new backend range-metadata contract.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Next UI 1.0 testing period
|
||||
|
||||
## Final release is on hold
|
||||
|
||||
On September 29, 2026, the project owner requested a couple of weeks to test RC1 in real applications and report issues. **Do not tag, publish, or deploy final 1.0 without renewed, explicit owner approval.** Elapsed time and passing CI are not release approval.
|
||||
|
||||
RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.1), source `e901df91017204add7ebffa81765a02094e581b0`. See [RC1 release notes](1.0.0-RC.1.md) for installation details.
|
||||
|
||||
## Release candidate cadence
|
||||
|
||||
The owner authorizes publishing new RCs at verified stability or daily milestones. Each RC gets a new immutable version and notes describing its fixes, backend prerequisites, verification, and known gaps. RC publication does not authorize final 1.0.
|
||||
|
||||
Previously verified public candidate at the RC11 preparation checkpoint: **[1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC9 remain available and unchanged.
|
||||
|
||||
## Fixes during testing
|
||||
|
||||
This checkout targets [RC11](1.0.0-RC.11.md); confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. The candidate adds searchable-selector and script-completion input-race corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24). These changes are absent from immutable RC10. Candidate notes distinguish versioned local checks from public delivery and retain the historical browser failures and remaining RC limits. Local previews may intentionally preserve an earlier version.
|
||||
|
||||
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
|
||||
|
||||
## Reporting application issues
|
||||
|
||||
Create or update the central QQQ ticket and Project 12 item using the [tracking policy](../ISSUE-TRACKING.md). Include the exact UI artifact/version or branch commit, QQQ backend version, page/workflow, expected and actual results, reproduction steps, and relevant screenshots or sanitized errors. This distinguishes an issue in a published RC from a later branch regression. Never include credentials or sensitive application records.
|
||||
|
||||
The compatibility ledger and remaining acceptance work still govern final readiness. The testing hold does not waive those requirements or authorize a QQQ 4.1 release.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Next UI 1.0.0-RC.1
|
||||
|
||||
Release candidate for testing in real QQQ applications. Published from commit `e901df91017204add7ebffa81765a02094e581b0`. Final 1.0 visual review and compatibility work remain open.
|
||||
|
||||
## Install
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.1</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Override any older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the dashboard at `/` alongside `/qqq/v1`; no Node server is required. Keep Material available during evaluation if you need to switch back with `-Dqqq.javalin.frontend=material`.
|
||||
|
||||
[Maven Central JAR](https://repo.maven.apache.org/maven2/com/kingsrook/qqq/qqq-frontend-next/1.0.0-RC.1/qqq-frontend-next-1.0.0-RC.1.jar) — verified by download, checksum, a consuming Maven project, and real-backend runtime checks.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.1` (linux/amd64 and linux/arm64).
|
||||
|
||||
```text
|
||||
ghcr.io/qrun-io/qqq-frontend-next@sha256:6edd16f20557c189bc618079851cd2d567c04001c6e34a5d3b7dde30895f9958
|
||||
```
|
||||
|
||||
The quickstart image expects its backend at `http://host.docker.internal:8000`; that origin is fixed at build time. For other deployments, use the Maven artifact or build the image with the appropriate backend configuration.
|
||||
|
||||
This candidate is tested with the QQQ **4.1.0-SNAPSHOT development backend**, including the Sleep Interactive fix merged in [QQQ PR #896](https://github.com/QRun-IO/qqq/pull/896) (`e33e1fc52c55c203e3ee3014837970ef2614b809`). It does not publish QQQ 4.1 or establish compatibility with every older backend release. Pin and report your backend version/commit.
|
||||
|
||||
## Verification
|
||||
|
||||
- **547 real-backend Chromium scenarios passed**, with zero failures, skips or flaky retries; all 389 required acceptance rows passed, with three previously approved external-service exclusions. [Exact-candidate CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36601699322/job/109521050432).
|
||||
- 1,806 unit tests, type checking, lint, mocked E2E, Storybook, license and bundle-budget checks passed. Candidate dependency audit reported zero known vulnerabilities on 2026-09-29.
|
||||
- The actual Central JAR resolved in a consuming Maven project. It contains 186 dashboard export files, expected routes and license, and no development mock worker. Its checksum verified; a signature is published. The CI production export uses a different bundler from the local 204-file verification build.
|
||||
- The downloaded JAR ran against the real sample backend: Sleep Interactive defaults, required validation and completion passed; report-filtered statistics and exported CSV matched independent SQL.
|
||||
- The published container manifest includes both architectures; the downloaded arm64 image started and served the dashboard successfully. This container check is a runtime/HTML smoke test, separate from the full backend workflow suite.
|
||||
|
||||
Maven JAR SHA-256: `5a5f56e892164c2b3c220d929372cf57c019dabaa35340839be3b9a6907076eb`.
|
||||
|
||||
Testing prioritizes complete Chromium workflows: normal entry/defaults, validation, editing/cancellation, save/reopen, dependent selections, permissions, recovery and persisted/downloaded results. Test counts do not prove every application configuration works.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Secondary-browser verification is complete and has unresolved failures. In the [candidate run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36601699322), WebKit passed 546 and failed NAV-034 (legacy process redirect, internal navigation error); Firefox passed 546 and failed WID-021 (process widget visibility timeout); phone/tablet passed 1,043 and failed tablet SEC-002 (internal WebKit navigation error). The [tag run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36605084236) passed Chromium again but failed Firefox RPT-016 (report sharing), WebKit REL-007 (association navigation), and tablet NAV-015 (document-title navigation), with the same respective passing counts. Logs/traces are retained; these failures have not been waived or established as infrastructure-only. No complete cross-browser pass is claimed.
|
||||
- Hosting beneath a URL base path is not implemented ([QQQ #732](https://github.com/QRun-IO/qqq/issues/732)); evaluate at the origin root.
|
||||
- Google Drive folder selection remains a stub ([QQQ #704](https://github.com/QRun-IO/qqq/issues/704)).
|
||||
- Full embedded report/query equivalence remains under review ([QQQ #722](https://github.com/QRun-IO/qqq/issues/722)).
|
||||
- Remaining input-block configurations and unsupported Material icon names need completion ([QQQ #728](https://github.com/QRun-IO/qqq/issues/728)).
|
||||
- Script editor autocomplete is absent ([QQQ #724](https://github.com/QRun-IO/qqq/issues/724)).
|
||||
- Full visual comparison against the original Next UI design remains open. Material is the behavior reference; the original Next UI is the visual reference.
|
||||
|
||||
These are recorded RC1 gaps, not approved exclusions from final 1.0. The [behavior parity ledger](https://github.com/QRun-IO/qqq-frontend-next/blob/e901df91017204add7ebffa81765a02094e581b0/docs/acceptance/material-parity.md) remains authoritative.
|
||||
|
||||
## Guides and bug reports
|
||||
|
||||
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/e901df91017204add7ebffa81765a02094e581b0/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/e901df91017204add7ebffa81765a02094e581b0/docs/guides/next-ui-agent-guide.md) · [Acceptance instructions](https://github.com/QRun-IO/qqq-frontend-next/blob/e901df91017204add7ebffa81765a02094e581b0/tests/acceptance/README.md).
|
||||
|
||||
[Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new) with frontend/backend versions, browser, affected widget or metadata, steps from a fresh page, expected/actual behavior and whether it survives reload. Include screenshots or sanitized errors where useful. For reports, include table, filters, sorts and columns; for processes, include step/input configuration. Remove credentials, session cookies and private application data.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Next UI 1.0.0-RC.10 — preparation
|
||||
|
||||
RC10 is being prepared for application testing and **is not yet published**. [RC9](1.0.0-RC.9.md) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.10` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.10`. Public checksums, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Changes from RC9
|
||||
|
||||
- Fix section-hash loss and stale immediate URLs when changing record tabs or views (#989), preserving URL context, return links, focus and history. The baseline eventually restored the tab; this is not a claim of proven tab loss.
|
||||
- Preserve search-dialog keyboard focus (#950), WebKit possible-value selections (#978), and authentication loading through logout/provider departure (#984).
|
||||
- Add ESB developer views and overview/management controls, with real-backend acceptance. See the [ESB contract](../acceptance/esb.md).
|
||||
- Improve browser readiness and diagnostic checks while preserving assertions. The pinned Playwright runner contains the corrected Linux WebKit library; stable-runner migration remains #973. See [change details](unreleased.md).
|
||||
|
||||
## Verification
|
||||
|
||||
Verification uses source `5e3738b73f14d7ed25580560f3c97b91aed58fce` ([PR21](https://github.com/QRun-IO/qqq-frontend-next/pull/21)) with RC10 metadata and QQQ backend `55b33e6bd7f8cc2aef3b41546ef8409ad307ff05`.
|
||||
|
||||
- **1,979 unit tests / 184 files**, typecheck, lint/license (zero errors, five existing warnings), static export, Maven packaging and bundle budgets pass.
|
||||
- The actual RC10 JAR passes **85/85** auth/focus/selection/ESB real-backend cases: Chromium, Firefox and WebKit 21 each; mobile and tablet 11 each.
|
||||
- Four navigation regressions pass **8/8 in Firefox/WebKit**, using the same JAR's assets with mocked APIs. These are separate from real-backend API coverage.
|
||||
|
||||
Both browser reports have zero failures, skips, flaky outcomes or report errors, with retries disabled. All 192 dashboard files are packaged without a mock service worker; Maven and compiled frontend versions are RC10. Served JavaScript and font bytes match the JAR. The primary fixture serves the JAR directly; authentication variants use verified assets extracted from it. These are local candidate checks, not public delivery receipts. PR23 changes test selection and documentation only; production code and test assertions are unchanged from this verified candidate.
|
||||
|
||||
## Browser coverage and remaining RC limits
|
||||
|
||||
The historical [PR18 run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) failed its touch-coverage gate despite all 2,972 executed cases passing: ESB-001–010 were required but not selected on phone and tablet. The original failed result is retained under [#990](https://github.com/QRun-IO/qqq/issues/990).
|
||||
|
||||
[PR23](https://github.com/QRun-IO/qqq-frontend-next/pull/23) corrects selection by adding touch markers to the ten existing ESB tests, preserving production code and every assertion. Its separate focused real-broker run on the PR18 export and QQQ `55b33e6` passed all 20 phone/tablet cases with zero retries, failures, skips, flaky outcomes or report errors. This focused result is not a fresh full hosted matrix pass; full hosted verification remains pending at this preparation checkpoint.
|
||||
|
||||
The secondary-browser/tablet RC exception remains documented in [#985](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5943724086) and [#798](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5943457468). No gate, test requirement or exclusion was weakened. This exception does not waive product defects or authorize GA.
|
||||
|
||||
Remaining parity, real Auth0/Drive/QuickSight checks, browser investigations, visual review and application soak stay tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#985](https://github.com/QRun-IO/qqq/issues/985), [#711](https://github.com/QRun-IO/qqq/issues/711) and [#712](https://github.com/QRun-IO/qqq/issues/712). The container backend origin is fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin. This candidate does not itself publish QQQ 4.1.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Next UI 1.0.0-RC.11 — preparation
|
||||
|
||||
At this preparation checkpoint, RC11 is being prepared for application testing and **is not yet published**. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.11` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.11`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Changes from RC10
|
||||
|
||||
[PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24), merged source `a1d3e48419812fcb8d47091cb4af315cad4e76e9` (tree-identical to reviewed head `ea73118fcabdf3c08ea8614c8bd0ad63749fb598`), invalidates stale searchable-selector results immediately when input changes (#970), and keeps script-completion suggestions open when a delayed scroll event reports an unchanged position (#954). Actual position changes still dismiss suggestions. No acceptance assertion or gate is weakened.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR24's [hosted primary acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36954433088) passes on its exact source: 614 cases and 401 required rows. The actual merged source above has the same tree. With RC11 metadata, fresh verification passes **1,984 unit tests / 184 files**, typecheck, lint/license (zero errors, five existing warnings), static export, Maven packaging and bundle budgets. All **192 dashboard files** match the export byte-for-byte, with no mock service worker and matching RC11 frontend/Maven versions.
|
||||
|
||||
The actual RC11 JAR passes **125/125 real-backend cases** against QQQ `55b33e6bd7f8cc2aef3b41546ef8409ad307ff05`: 25 each in Chromium, Firefox, WebKit, phone and tablet. These include the 105 auth/focus/selection/ESB cases and 20 selector/completion regression cases. A separate **8/8 record-navigation regressions in Firefox/WebKit** use the same JAR assets with mocked APIs. Both reports have zero failures, skips, flaky outcomes or report errors, with retries disabled. Served versioned JavaScript and font bytes match the JAR. The scoped matrix check passes only the exercised rows; it is not a full hosted matrix result. These local candidate checks are separate from eventual public-artifact verification; no RC10 test count or artifact digest is attributed to RC11.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
Historical observations remain preserved: REC-054 on mobile light expected three completion options but found none; PRC-061 in WebKit left the Casey selection at its placeholder. See [#954](https://github.com/QRun-IO/qqq/issues/954), [#970](https://github.com/QRun-IO/qqq/issues/970) and [RC10 delivery notes](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10). The original [PR18 touch-coverage failure](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) remains historical evidence. [#990 is complete](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159): PR23’s complete touch report covers all 401 required IDs on each profile, with zero missing, and all ten ESB scenarios pass first attempt on both phone and tablet. That report records **1,149 passes and one unrelated tablet NAV-056 failure**; its full touch gate and whole workflow remain **failed**. [NAV-056 diagnostics remain under investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383), provisionally tracked in [#904](https://github.com/QRun-IO/qqq/issues/904#issuecomment-5944558037), without claiming a shared historical cause. Scoped #990 completion does not imply an all-browser pass or waive that failure.
|
||||
|
||||
The documented secondary-browser/tablet RC exception remains under [#985](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5943724086) and [#798](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5943457468); it does not waive new product defects or authorize GA. Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711) and [#712](https://github.com/QRun-IO/qqq/issues/712). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin. This candidate does not publish QQQ 4.1.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Next UI 1.0.0-RC.2
|
||||
|
||||
Release candidate for testing in real applications. Final 1.0 remains on hold for owner testing and explicit approval. RC1 remains unchanged.
|
||||
|
||||
## Install
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.2</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.2` (linux/amd64 and linux/arm64). This quickstart image uses the build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild the image for a different backend origin.
|
||||
|
||||
## Changes since RC1
|
||||
|
||||
- Report previews support backend table variants consistently across query, count, statistics and full-result copy. Variant switches clear stale preview state and cannot let an old copy request overwrite the clipboard.
|
||||
- Report previews honor query and count capabilities independently, including Refresh, and stop showing cached data/errors when a capability is removed.
|
||||
- Report filter editors restore saved-view reset behavior and show active filters in column headers. Statistics and CSV checks compare results with independent SQL.
|
||||
- Configured Google Drive folder selection replaces the placeholder, with backend metadata/CSP prerequisites and controlled-provider tests. Real-provider verification retains its documented external-service limitation.
|
||||
- Script editing provides local QQQ helper, keyword and file-identifier suggestions, with keyboard/touch selection and native undo/redo behavior.
|
||||
- Copy controls align with the form. Selected record sections retain the original Next full-width card and responsive field columns when metadata omits widths; explicit widths still take precedence.
|
||||
- Pinned table selection cells expose the full checkbox touch target at every density.
|
||||
- Child-record CSV export returns to the original Next header position with one working control. Export respects content visibility/permissions, and widget render failures can retry with fresh backend data.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Use the QQQ 4.1.0-SNAPSHOT development backend with PR #913 (`cba758507dcc54cf282a6638ab50a7d3a1904c7c`) for report variants and concurrent metadata handling, and PR #908 (`bac663044`) for configured Google Drive selection. The primary CI run used backend `cba758507dcc54cf282a6638ab50a7d3a1904c7c`. This release does not include a QQQ 4.1 release; record the backend commit used in your application.
|
||||
|
||||
## Verification
|
||||
|
||||
- Source: `0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd`; signed tag `v1.0.0-RC.2`.
|
||||
- **563 real-backend Chromium tests passed**, covering all 390 required rows, with zero failures, skips or flaky results. The CI merge checkout and tagged source have identical file trees. [Primary CI evidence](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36640179043).
|
||||
- **1,857 unit tests passed**. Types, lint, coverage, Storybook, mocked Chromium tests, bundle budgets and CircleCI passed. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36640179012).
|
||||
- Public Maven Central JAR, POM, checksum and signature verified. A fresh, independent Maven consumer resolved the identical JAR. License, route assets, RC2 marker and absence of the mock service worker verified. All 77 application/runtime JavaScript chunks match primary CI byte-for-byte; generated build IDs and unused CSS utilities differ between build environments.
|
||||
- The actual downloaded JAR passed five Chromium workflow checks: Sleep Interactive, parent CSV export, child CSV export, count-only report refresh, and filtered report statistics/CSV. These used an isolated local fixture backend; they supplement the full primary CI run.
|
||||
- Both container architectures published; anonymous manifest retrieval verified. The actual downloaded arm64 image passed HTML and referenced-JavaScript runtime checks. [Image publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36642612472).
|
||||
|
||||
Maven JAR SHA-256: `872d7175feb98dab7d10fd50c659066449ce6801dc511011ead89438b6ef226a`.
|
||||
|
||||
Container digest: `sha256:dfd1b793c947a17e3c9f43473e4b8dfc12c5e45c8c1a9b57800bcbd6c3912b64`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Hosting beneath a URL base path remains unresolved; evaluate at the origin root.
|
||||
- Full embedded report/query equivalence, remaining input-block configurations, and unsupported Material icon names remain open.
|
||||
- The existing 1,621-image review is complete, but missing states, scroll positions, themes and historical provenance still limit whole-product visual approval. The reference is the original Next UI design.
|
||||
- Real Google Drive provider verification retains its external-service limitation.
|
||||
- On this source's PR CI run, WebKit passed 560 tests and failed three: QRY-030 density-menu interaction, NAV-026 navigation widget-request access-control errors, and NAV-056 appearance-preference query-request access-control errors. Traces are retained and causes are not declared resolved. Firefox passed 562 tests and failed REC-048: the expected validation toast was visible, but the subsequent one-toast count observed zero. Mobile passed all 538 tests. Tablet passed 536 and failed two: QRY-030 density-menu interaction and REL-005, where the child list did not show the renamed record within the assertion timeout. These failure causes remain under investigation. No blanket cross-browser pass is claimed.
|
||||
|
||||
- The [completed tag acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36642612560) independently passed **563 Chromium tests / 390 required rows** and **538 mobile tests**, without skips or flaky results. WebKit passed 561 and failed QRY-030 (density menu) and RPT-012 (internal navigation error opening a saved report). Firefox passed 561 and failed PRC-061 (bulk-load default choice did not remain selected) and INT-011 (expected failed-save feedback was absent). Tablet passed 537 and failed QRY-030 (density menu). All traces are retained; these causes remain under investigation. The overall tag run failed because of these secondary-browser results.
|
||||
- Confirmed after publication: multiline `TEXT` fields do not apply configured uppercase/lowercase behavior while editing. A fix and regression tests are in progress for the next candidate; RC2 does not contain that fix.
|
||||
|
||||
These are candidate limitations, not waived final-release requirements. See the [compatibility ledger](https://github.com/QRun-IO/qqq-frontend-next/blob/0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd/docs/acceptance/material-parity.md) and [browser matrix](https://github.com/QRun-IO/qqq-frontend-next/blob/0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd/docs/acceptance/browser-matrix.md) for prior checkpoints.
|
||||
|
||||
## Guides and issue reports
|
||||
|
||||
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/0905cfb0cd046fcc98b058cd0b489e7bb00d5fbd/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
|
||||
|
||||
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. State whether the issue persists after reload.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Next UI 1.0.0-RC.3
|
||||
|
||||
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and explicit approval.** RC1 and RC2 remain immutable.
|
||||
|
||||
## Install
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.3</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. If an earlier attempt cached a missing RC3 artifact, retry Maven with `-U`.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.3` (linux/amd64 and linux/arm64). This quickstart image uses the build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild the image for a different backend origin.
|
||||
|
||||
## Changes since RC2
|
||||
|
||||
- Multiline TEXT fields rendered by the shared editor honor configured uppercase/lowercase behavior while typing and submit the displayed value. Enter remains a newline.
|
||||
- Case conversion preserves cursor position when Unicode characters expand, including ß to SS and İ to i plus combining dot. Continued typing and backward selections retain the intended boundaries.
|
||||
- Added standalone and process regression coverage with exact database readback. Developer and agent guides document these behaviors. Existing layout and styling are preserved.
|
||||
|
||||
## Verified artifacts and workflows
|
||||
|
||||
Source: `f1a6a3a16868c04d30a0a807f85ea34c7170c508`; signed tag `v1.0.0-RC.3`.
|
||||
|
||||
- Hosted real-backend Chromium: **570 tests and all 391 required feature rows passed**, with zero failures, skips or flaky results. Backend commit: `cba758507dcc54cf282a6638ab50a7d3a1904c7c`. The tested PR merge has an identical Git tree to the tagged source. [Candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36652168977).
|
||||
- **1,873 unit tests passed**, plus types, lint, unit coverage, mocked Chromium, bundle budgets and Storybook. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36652168856).
|
||||
- Public Maven JAR checksum and PGP signature verified; a separate consumer resolved the same JAR through normal Maven dependency resolution. The actual downloaded JAR passed seven Chromium case-conversion workflows with independent database readback. [Maven deployment](https://circleci.com/gh/QRun-IO/qqq-frontend-next/122).
|
||||
- Public container manifest verified anonymously for both architectures. The actual arm64 image served the app route and all 21 referenced JavaScript assets. [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36654410994).
|
||||
|
||||
Public JAR SHA-256:
|
||||
`596d270893ab733376c7b2c39dfdf4d7bf5027cbb984e67ea95d9090df3e7109`
|
||||
|
||||
Container manifest digest:
|
||||
`sha256:fe7141ded4a625bbfa67675945eff4fa6ba0e1562bf4563fe8fab8933ffee69a`
|
||||
|
||||
## Backend and known limitations
|
||||
|
||||
Use the QQQ 4.1.0-SNAPSHOT development backend including PR #913 (report variants/concurrent metadata handling) and PR #908 (configured Google Drive selection). Record your application's backend commit. This release does not release QQQ 4.1.
|
||||
|
||||
The exact candidate acceptance run is complete and failed overall. Results (zero skips or flaky results):
|
||||
|
||||
| Browser | Passed | Failed | Failure evidence |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Chromium | 570 | 0 | All 391 required feature rows passed |
|
||||
| Mobile | 545 | 0 | All tagged phone scenarios passed |
|
||||
| Firefox | 569 | 1 | WID-005: Widget Gallery heading was not found within 15 seconds |
|
||||
| WebKit | 568 | 2 | NAV-056: application-theme override reported access-control errors for application requests; QRY-030: density-menu coverage timed out |
|
||||
| Tablet | 543 | 2 | QRY-030: density-menu option did not appear; REC-046: WebKit internal error navigating to the create route |
|
||||
|
||||
Reports and traces are retained; causes remain under investigation. The [tag-triggered acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36654410984) passed **570 Chromium tests and all 391 required rows**. Its WebKit job passed **568 tests and failed two**: NAV-016 site-root navigation reported application-request access-control errors; QRY-030 density-menu coverage timed out. Its Firefox job passed **568 tests and failed two**: QRY-007 did not observe the light-theme error toast within 15 seconds; WID-073 timed out waiting for the Render Report action. Its mobile job passed **545 tests**; tablet passed **543 and failed two**: NAV-026 navigation-drawer coverage reported widget-request access-control errors; QRY-030 density-menu coverage timed out. The tag run is complete and failed overall, with zero skips or flaky results. All reports and traces are retained; these failures remain open.
|
||||
|
||||
Full embedded report/query equivalence, remaining input configurations, unsupported Material icon names, origin sub-path hosting, real Google Drive provider verification and whole-product visual approval remain open. A separately verified follow-up fixes explicitly configured PASSWORD case behavior; it is not included in RC3. Ordinary password case is unchanged. Original Next UI remains the visual reference.
|
||||
|
||||
These limitations remain final-release requirements. Final 1.0 needs renewed owner approval after application testing.
|
||||
|
||||
## Guides and issue reports
|
||||
|
||||
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/f1a6a3a16868c04d30a0a807f85ea34c7170c508/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/f1a6a3a16868c04d30a0a807f85ea34c7170c508/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
|
||||
|
||||
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Next UI 1.0.0-RC.4
|
||||
|
||||
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and explicit approval.** RC1, RC2 and RC3 remain immutable.
|
||||
|
||||
## Install
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.4</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. If a previous attempt cached a missing RC4 artifact, retry Maven with `-U`.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.4` (linux/amd64 and linux/arm64). This quickstart image uses build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild for a different origin.
|
||||
|
||||
## Changes since RC3
|
||||
|
||||
- Explicitly configured PASSWORD uppercase/lowercase behavior preserves Unicode-aware cursor position, masking and show/hide values. Ordinary passwords retain their entered case.
|
||||
- Report previews and editors honor `omitExposedJoins` for new choices while retaining saved joined filters, sorts, columns and widths. Reordering available columns preserves omitted columns in place. Cancel, Basic/Advanced switching, OK, host Save and reopening preserve the intended data. Backend permissions remain authoritative.
|
||||
- Numeric process and widget inputs preserve their values while scrolling; deliberate typing and arrow-key edits still work.
|
||||
- Density menus stay open when touch-generated focus returns to their own trigger. Trigger toggling, Escape, focus return and outside dismissal remain functional. This fixes the reproduced Linux WebKit density failure.
|
||||
- Developer documentation and regression coverage updated. The original Next UI remains the visual reference; these fixes preserve its layout.
|
||||
|
||||
## Verified artifacts and primary workflows
|
||||
|
||||
Source: `3c409f918699bc1fb9981f5b8829737bc608b55a`; signed tag `v1.0.0-RC.4` (tag object `bbdfea79fb4c5e044f717a735baa2073c32a8c39`).
|
||||
|
||||
- Both [candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36664932061) and [tag acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36714593674) passed **577 Chromium tests and all 391 required feature rows**, with zero failures/skips/flaky outcomes and three approved external-service exclusions. Both used backend `e0d57dd31b979b17215370546ef4e26cd8a38d21`. The tested PR merge tree equals the tagged source tree.
|
||||
- **1,884 unit tests** and **104 mocked Chromium tests** passed, plus types, lint, coverage, Storybook and bundle budgets. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36664931987).
|
||||
- Public Maven checksum and PGP signature verified. A clean consumer resolved the identical JAR through normal Maven dependency resolution, without manually installing it. [Maven publication](https://app.circleci.com/workflow/fbb640fc-b3a1-4594-abb8-3d95f341fccb).
|
||||
- The actual downloaded public JAR passed **17 affected real-backend Chromium workflows**, with zero failures, skips, flaky outcomes or report-level errors. Coverage includes configured case conversion/passwords, report preview/editor preservation, numeric inputs and density-menu interactions.
|
||||
- Public container manifest verified anonymously for AMD64 and ARM64. The downloaded ARM64 image served the app route and all 21 referenced JavaScript assets. This verifies image delivery; backend workflows are verified separately through the Maven JAR. [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36714593869).
|
||||
|
||||
Public JAR SHA-256:
|
||||
`ee521bc61f21b1deb24094578e17fbb4f636a33f34ce7e0e646cab3e6cc43bf6`
|
||||
|
||||
Container manifest digest:
|
||||
`sha256:e01502691008c5f2ce444e3a212875819cd0bbd8500cee5827d7a20fa5e67f20`
|
||||
|
||||
## Browser results and remaining limitations
|
||||
|
||||
Both acceptance runs completed and failed overall because of secondary-browser failures. All profiles had zero skips or flaky outcomes; failures were retained, not waived or silently retried.
|
||||
|
||||
| Browser | Candidate result | Tag result |
|
||||
| --- | --- | --- |
|
||||
| Chromium | 577 passed | 577 passed |
|
||||
| Mobile | 547 passed, 1 failed: REC-054 script-suggestion listbox bounding-box timeout in dark mode | 548 passed |
|
||||
| Firefox | 576 passed, 1 failed: WID-026 record-heading timeout | 576 passed, 1 failed: WID-019 dashboard-heading timeout |
|
||||
| WebKit | 576 passed, 1 failed: REC-046 internal navigation error on a read-only user's copy route | 576 passed, 1 failed: REL-005 internal navigation error returning to the pet list |
|
||||
| Tablet | 547 passed, 1 failed: NAV-026 widget-request access-control errors during drawer navigation | 547 passed, 1 failed: SEC-002 internal navigation error opening a denied ledger create route |
|
||||
|
||||
Density scenarios passed in every profile of both runs. The other failures remain open; a later pass does not erase the earlier failure or prove full browser compatibility.
|
||||
|
||||
Use the QQQ 4.1.0-SNAPSHOT development backend including report-variant and configured Google Drive support. Record your application's exact backend commit. This RC does not release QQQ 4.1.
|
||||
|
||||
Full embedded report/query equivalence, remaining input configurations, unsupported Material icon names, origin sub-path hosting, real Google Drive provider verification and whole-product visual approval remain open. Final 1.0 requires renewed owner approval after application testing.
|
||||
|
||||
## Guides and issue reports
|
||||
|
||||
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/3c409f918699bc1fb9981f5b8829737bc608b55a/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/3c409f918699bc1fb9981f5b8829737bc608b55a/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
|
||||
|
||||
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Next UI 1.0.0-RC.5
|
||||
|
||||
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and explicit approval.** Earlier RCs remain immutable.
|
||||
|
||||
## Install
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.5</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Override an older BOM-managed frontend version and select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. If Maven cached an earlier missing-artifact result, retry with `-U`.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.5` (linux/amd64 and linux/arm64). Its build-time backend origin is `http://host.docker.internal:8000`; use the Maven JAR or rebuild the image for a different origin.
|
||||
|
||||
## Changes since RC4
|
||||
|
||||
- Restored original Next uniform table backgrounds, retaining selection highlighting, pinned cells and application CSS hooks.
|
||||
- Restored original record overview typography and spacing, retaining metadata widths and selected-section layout.
|
||||
- Report `hidePreview` hides the read-only summary preview while keeping the editing query available.
|
||||
- Disabling a saved report's pivot table now clears its stored definition when Save is pressed, correcting silent validation failure.
|
||||
- Column menus fit short windows and scroll when needed, keeping keyboard and pointer actions reachable.
|
||||
|
||||
## Verification
|
||||
|
||||
Source: `ac2294697519b5ee108d0ba776400afe30d28927`; signed tag `v1.0.0-RC.5`.
|
||||
|
||||
- [Candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36751436362): **587 real-backend Chromium tests and all 391 required feature rows passed**, with zero failures, skips, flaky results or report errors. Three approved external-service exclusions remain. Backend: `e0d57dd31b979b17215370546ef4e26cd8a38d21`. The actual tested PR merge has the same Git tree as the tagged source.
|
||||
- **1,884 unit tests** and **104 mocked Chromium tests** passed, plus types, lint, coverage, Storybook and bundle budgets. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36751436048).
|
||||
- Public Maven checksum and PGP signature verified. A clean consumer resolved the identical JAR through ordinary Maven dependency resolution. The actual downloaded JAR passed **12 affected real-backend Chromium workflows**, with zero failures, skips, flaky results or report errors. [Maven publication](https://circleci.com/gh/QRun-IO/qqq-frontend-next/131).
|
||||
- Public container manifest verified anonymously for both architectures. The actual ARM64 image served the app and all 21 referenced JavaScript assets. This checks image delivery; backend workflows were verified through the public JAR. [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36754867839).
|
||||
|
||||
Public JAR SHA-256:
|
||||
`3d4955a763b73cb2e3a3a385164977772fe5e8ec6000699603ab649589070d41`
|
||||
|
||||
Container manifest digest:
|
||||
`sha256:5eba52c5e0a67529048ddb992c609aa622dda5ecd6805ab6f0d6ea1e7ed0b264`
|
||||
|
||||
## Known limitations and continuing checks
|
||||
|
||||
Candidate WebKit finished with **586 passed and one failed**: REC-046 encountered an internal WebKit navigation error opening a read-only user's copy route. Mobile passed **548 tests**. Tablet passed **547 tests and failed one**: NAV-014 encountered an internal WebKit navigation error opening the Person create route during breadcrumb coverage. These completed profiles had zero skips, flaky outcomes or report errors. Both failures remain open. Candidate Firefox completed with **586 passed and one failed**: WID-073 did not observe the initial report-variant preview row within 15 seconds. It had zero skips, flaky outcomes or report errors. The candidate run is complete and failed overall; all failures remain open. The independent [tag acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36754867847) also passed **587 Chromium tests and all 391 required feature rows**, with zero failures, skips, flaky results or report errors; its WebKit profile completed with **585 passed and two failed**: NAV-026 reported widget-request access-control errors during navigation-drawer coverage, and NAV-015 timed out after an internal WebKit navigation error opening the unknown-route page. WebKit had zero skips, flaky outcomes or report errors. Tag Firefox completed with **586 passed and one failed**: INT-010 did not observe the failed-query error panel within 30 seconds. It had zero skips, flaky outcomes or report errors. Tag mobile passed **548 tests**; tablet passed **546 tests and failed two**: NAV-014 hit an internal WebKit navigation error opening the interactive greeting process, and SEC-043 hit an internal WebKit error reloading during different-user recent-record isolation coverage. Both profiles had zero skips, flaky outcomes or report errors. The tag run is complete and failed overall. Reports and traces for every failed scenario are retained; causes remain under investigation. Prior secondary-browser failures remain under investigation.
|
||||
|
||||
Use the QQQ **4.1.0-SNAPSHOT development backend** including report-variant and configured Google Drive support, and record your application's exact backend commit. This frontend RC does not release QQQ 4.1.
|
||||
|
||||
Complete embedded report/query equivalence, remaining input configurations, unsupported Material icon names, hosting under an origin sub-path, real Google Drive provider verification and whole-product visual approval remain open. The original Next UI is the visual reference. A separate icon follow-up is not part of this immutable RC5 release. A subsequent regression check also found that plain TIME blocks use minute increments: process blocks can mark second-level values invalid. A separate correction is being verified; it is not included in RC5.
|
||||
|
||||
Final 1.0 requires renewed owner approval after application testing.
|
||||
|
||||
## Guides and issue reports
|
||||
|
||||
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/ac2294697519b5ee108d0ba776400afe30d28927/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/ac2294697519b5ee108d0ba776400afe30d28927/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
|
||||
|
||||
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Next UI 1.0.0-RC.6
|
||||
|
||||
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and renewed explicit approval.** Earlier RCs remain immutable.
|
||||
|
||||
## Changes since RC5
|
||||
|
||||
- Legacy metadata icon names missing from the existing Next maps now render from a pinned, locally bundled Material Icons inventory of 2,234 unique names.
|
||||
- Existing Next Lucide mappings, widget overrides, custom image priority, colors, sizing and layout are preserved. Unknown names keep generic fallback icons.
|
||||
- The extra inventory loads only when needed, without an external font service or new package dependency.
|
||||
- Developer and agent guides document icon resolution and its limits.
|
||||
|
||||
## Install
|
||||
|
||||
Maven coordinate: `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.6`. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.6`, AMD64 and ARM64. Its build-time backend origin is `http://host.docker.internal:8000`; use the Maven JAR or rebuild for a different origin.
|
||||
|
||||
Both public artifacts are available and verified. If Maven cached a missing-artifact result from an earlier attempt, retry with `-U`.
|
||||
|
||||
## Verified candidate
|
||||
|
||||
Source: `7ddf45fa4d7c94627bafb1ee40916c3f6c47536f`; signed tag `v1.0.0-RC.6`. Tested PR merge `f52387f10885da1d8ad22961915b76fa9e08ab72` has the same source tree.
|
||||
|
||||
- [Hosted candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36764199293): **589 real-backend Chromium tests and all 391 required feature rows passed**, with zero failures, skips, flaky results or report errors. Three approved external-service exclusions remain. Backend checkout: `e0d57dd31b979b17215370546ef4e26cd8a38d21`.
|
||||
- [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36764199430): **1,901 unit tests**, **104 mocked Chromium tests**, types, lint, coverage, Storybook and bundle budgets passed.
|
||||
- Both candidate container architecture builds passed. The local RC6 JAR passed four light/dark desktop/mobile icon workflows; its 193 static export files and bundled font were verified against the build output.
|
||||
|
||||
Candidate WebKit completed with **586 passed and three failed**, with zero skips, flaky outcomes or report errors: NAV-034 encountered an internal WebKit navigation error opening a record process; NAV-002 expected raw sidebar text without decorative legacy icon glyphs; WID-073 reported an application-metadata request access-control error. Reports and traces are retained; these failures remain recorded. A subsequent check reproduced NAV-002 against the public JAR and confirmed its raw-text assertion counted decorative SVG characters. A separately reviewed test correction verifies accessible names, order and hidden glyphs; it passed focused Chromium/WebKit/mobile checks and is not part of the original run. The other failures remain under investigation. The candidate run is now complete and failed overall. Firefox finished **586 passed / 3 failed**: NAV-014 and NAV-015 reported legacy icon font download errors; INT-010 timed out waiting for the failed-query error panel. Mobile finished **549 passed / 1 failed** (NAV-002 decorative-glyph raw-text assertion). Tablet finished **548 passed / 2 failed** (the same NAV-002 assertion and NAV-034 internal WebKit session/navigation failure). These profiles had zero skips, flaky outcomes or report errors. All reports and traces are retained. The independent [tag-triggered acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36767986263) passed **589 Chromium tests** with zero failures, skips or flaky results. Its WebKit job finished **585 passed / 4 failed**: NAV-026 application-request access-control errors, NAV-016 missing Person grid after site-root navigation, NAV-002 the decorative-glyph raw-text assertion, and SEC-043 an internal WebKit reload error. Firefox finished **586 passed / 3 failed**: NAV-002 the decorative-glyph raw-text assertion, and REC-056/SEC-043 legacy icon font download errors. Mobile finished **548 passed / 2 failed**: NAV-002 and REC-054 a missing script-suggestion popup bounding box in light mode. Tablet finished **549 passed / 1 failed**: NAV-002. The tag run is complete and failed overall; all profiles had zero skips, flaky outcomes or report-level errors. Reports and traces are retained. Earlier secondary-browser failures remain open; Chromium success does not establish full browser compatibility.
|
||||
|
||||
## Public artifact verification
|
||||
|
||||
- [Maven publication](https://circleci.com/gh/QRun-IO/qqq-frontend-next/135) built the exact tagged source. The downloaded JAR checksum and PGP signature were verified; a clean consumer resolved identical bytes through ordinary Maven dependency resolution.
|
||||
- The actual public JAR passed **four affected light/dark desktop/mobile icon workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 dashboard files, three version markers, pinned font and license were checked; no mock worker is packaged.
|
||||
- [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36767986139) succeeded. Anonymous inspection verified both architectures; the downloaded ARM64 image served the app and all 21 JavaScript assets. Backend behavior was verified separately through the JAR.
|
||||
|
||||
Public JAR SHA-256: `81ef0a869afb3caeb356704bd8b0345a7601b7cf908d042f66d5c3c9835b2697`.
|
||||
|
||||
Container manifest digest: `sha256:8591aaf10946eb1b44da5a4a5f45a9782dac9afc734bdd5187630c5b1351a5d3`.
|
||||
|
||||
## Backend and known limitations
|
||||
|
||||
Use the QQQ **4.1.0-SNAPSHOT development backend** with report-variant and configured Google Drive support. Record your application's exact backend commit. This frontend RC does not release QQQ 4.1.
|
||||
|
||||
Complete embedded report/query equivalence, remaining input and association-editing configurations, developer-tool gaps, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next is the visual reference. Arbitrary Material Symbols names and distinct icon font-style variants are not claimed.
|
||||
|
||||
Plain TIME blocks retain a known seconds-handling defect in this RC. A separate correction is verified and committed, but not included. API playground contrast corrections are also being verified separately and are not included.
|
||||
|
||||
## Guides and feedback
|
||||
|
||||
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/7ddf45fa4d7c94627bafb1ee40916c3f6c47536f/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/7ddf45fa4d7c94627bafb1ee40916c3f6c47536f/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
|
||||
|
||||
Include the exact frontend and backend versions/commits, browser, workflow, relevant metadata, reproduction steps, expected/actual results and sanitized screenshots or errors.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Next UI 1.0.0-RC.7
|
||||
|
||||
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and renewed explicit approval.** Earlier RCs remain immutable.
|
||||
|
||||
## Changes since RC6
|
||||
|
||||
- Plain process and standalone TIME inputs support seconds, matching shared forms. Seeded values remain valid, native stepping advances one second, and saved values retain seconds. Existing timestamp timezone and daylight-saving behavior is preserved.
|
||||
- API playground text and button contrast use the dashboard theme in light/dark mode. Preferences changes apply immediately; layout is preserved.
|
||||
- Sidebar acceptance checks wait for legacy icons and verify accessible labels, order and decorative glyph hiding. This corrects a test assertion; no sidebar product behavior changes.
|
||||
|
||||
## Install
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.7</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. Use Maven `-U` if an earlier lookup cached a missing artifact.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.7` (AMD64/ARM64). This quickstart image uses build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild for a different origin.
|
||||
|
||||
## Verified candidate
|
||||
|
||||
Source: `f65cf6bfbd1583538afe1b66f8764812980880c3`. Tested PR merge `f11bb1a6656de71e5a60d5781ac244e5117daa81` has the identical source tree `623efded5c6de13cbe389dc27c9227468a7419c9`.
|
||||
|
||||
- [Hosted candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36771453216): **592 real-backend Chromium tests and all 391 required feature rows passed**, with zero failures, skips, flaky outcomes or report errors. Three approved external-service exclusions remain. Backend checkout: `e0d57dd31b979b17215370546ef4e26cd8a38d21`. WebKit completed with **591 passed / 1 failed**: NAV-026 navigation-drawer coverage reported widget-request access-control errors. There were zero skips, flaky outcomes or report-level errors. The cause remains under investigation; original reports and traces are retained. Mobile completed with **553 passed / 0 failed**. Tablet completed with **552 passed / 1 failed**: NAV-014 breadcrumb navigation reported API request access-control errors. Both had zero skips, flaky outcomes or report-level errors. Firefox completed with **590 passed / 2 failed**: NAV-016 legacy icon font download failure and WID-041 missing plain widget. The candidate run is complete and failed overall; all profiles had zero skips, flaky outcomes or report-level errors.
|
||||
- [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36771452885) passed, including unit/coverage, types, lint, mocked Chromium, Storybook and bundle budgets. Fresh local checks passed **1,904 unit tests**, types, lint, export and Maven packaging.
|
||||
- The actual local candidate JAR passed **14 affected desktop/mobile workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 export files match the build output, version markers and pinned icon font are correct, and no mock service worker is packaged.
|
||||
|
||||
## Publication verification
|
||||
|
||||
- [Maven publication](https://circleci.com/gh/QRun-IO/qqq-frontend-next/138) built the exact tagged source. The public JAR checksum and QQQ publishing signature were verified. A clean consumer resolved identical bytes through ordinary Maven dependency resolution.
|
||||
- The actual downloaded public JAR passed **14 affected desktop/mobile workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 dashboard files, three version markers, pinned icon font and license were verified; no mock worker is packaged. An initial run used stale compiled test fixtures and failed four missing-clock assertions; that evidence was retained, the fixtures were compiled from tagged source, and the complete affected suite passed.
|
||||
- [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36775272526) succeeded. Anonymous manifest verification confirmed AMD64 and ARM64. The downloaded ARM64 image served the application and all 21 referenced JavaScript assets. This verifies image delivery; backend workflows were verified through the public JAR.
|
||||
|
||||
Public JAR SHA-256: `f23f8ede0d82d30f47621efacd14119afb7c00a6961a1936a17ffa2203bebdf0`.
|
||||
|
||||
Container manifest digest: `sha256:6d6fc39afd3f6e00aebe619b49a0350a64802f4e6f7cebdacb8aac20284fdba3`.
|
||||
|
||||
The independent [tag-triggered acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36775272542) is complete and failed overall. Original reports and traces are retained; there were zero skips, flaky outcomes or report-level errors.
|
||||
|
||||
| Profile | Passed | Failed | Failure evidence |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Chromium | 592 | 0 | All 391 required feature rows passed |
|
||||
| Mobile | 553 | 0 | All tagged phone scenarios passed |
|
||||
| WebKit | 591 | 1 | NAV-015 incorrect not-found title with a session-request internal error |
|
||||
| Firefox | 590 | 2 | NAV-016 legacy icon font download failure; WID-024 missing record heading during child export |
|
||||
| Tablet | 551 | 2 | NAV-014 and NAV-015 internal navigation errors |
|
||||
|
||||
These failures remain open. Passing primary workflows does not establish complete browser compatibility.
|
||||
|
||||
## Backend and known gaps
|
||||
|
||||
Use QQQ 4.1.0-SNAPSHOT development backend including report-variant and configured Google Drive support. Record your application's exact backend commit. This RC does not release QQQ 4.1.
|
||||
|
||||
Complete embedded report/query equivalence, remaining input and association-editing configurations, developer-tool gaps, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next remains the visual reference.
|
||||
|
||||
The API playground's inherited narrow-screen specification-button clipping is not fixed in RC7; a separately reviewed follow-up addresses it. A newly reproduced bounded LONG validation issue can throw for non-integer text or round large string bounds; its separate follow-up passed schema/component tests and independent review, with combined candidate verification underway. Neither follow-up is included here. A separate unit test also has an ambiguous duplicate-label assertion; its correction is outside this immutable source.
|
||||
|
||||
Final 1.0 requires renewed owner approval after application testing; passing the current matrix does not establish complete compatibility.
|
||||
|
||||
## Guides and issue reports
|
||||
|
||||
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/b9b1f3da84f49eec16ead0da3fda3af3e5f16b98/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/b9b1f3da84f49eec16ead0da3fda3af3e5f16b98/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
|
||||
|
||||
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Next UI 1.0.0-RC.8
|
||||
|
||||
**Public Maven and container delivery verified; RC8 prerelease published for application testing.** Tag `v1.0.0-RC.8` points to `cd3d4731d1910e68e994da9c74a8c28a2f1b7c1c`. RC8 supersedes RC7 as the current application-testing prerelease. Final 1.0 remains on hold for owner application testing and renewed explicit approval.
|
||||
|
||||
## Changes since RC7
|
||||
|
||||
- API specification Download and View controls remain fully visible and usable in narrow panels, including 320px phones. Labels wrap when needed; wide-panel buttons grow to fit the application font. Real specification download and keyboard new-tab behavior are covered in light/dark and touch workflows.
|
||||
- Search keeps keyboard focus inside the dialog, returns focus to its opener on close, handles Escape from all controls, and returns focus to the input after clearing.
|
||||
- Bounded LONG fields reject invalid integer text with a field error instead of throwing during range validation. Exact integer-string bounds retain precision beyond JavaScript's safe-number range. Optional blanks and existing required-field messages are preserved.
|
||||
- A process unit test scopes its choice-label lookup to the read-only field under test, avoiding ambiguity when an editable field displays the same label. This changes no product behavior.
|
||||
|
||||
## Historical verification before version change
|
||||
|
||||
Both product fixes passed independent review. API layout verification passed 12 affected desktop/mobile workflows; its 1440px RapiDoc screenshot was identical to RC7. The API layout source passed 596 real-backend Chromium tests and all 391 required feature rows, with zero failures/skips/flaky outcomes/report errors and three approved external-service exclusions.
|
||||
|
||||
The combined fixes passed all 1,913 unit tests across 180 files, types, lint/license, a clean production export and existing bundle budgets. The first full combined Chromium run finished with 595 passed and one failed (NAV-025 search navigation), zero skips/flaky outcomes/report errors. Its report and trace are retained; investigation remains open. The trace requested the correct search route successfully and the final snapshot contained the expected results despite the URL assertion reporting the prior record route. Twenty focused repetitions against the RC8 JAR passed; they do not erase the original failure. No timeout, retry or assertion changes were made. Fresh RC8-version checks passed all 1,913 units, types, lint/license, production export, Maven packaging and existing bundle budgets. All 193 packaged dashboard files match the export; version markers and pinned font are verified. The actual RC8 JAR passed 16 affected desktop/mobile workflows with zero failures/skips/flaky outcomes/report errors: API specification visibility/download/keyboard actions, contrast/live theme changes, exact LONG/DECIMAL persistence and composite metadata-editor submission. The fresh full RC8 run at source `f0f0eae98e4b6c883475956a7c59e060d2e5f0a5` finished with **588 passed / 8 failed**, zero skips/flaky outcomes/report errors. The failed cases were QRY-007, QRY-009, two QRY-001 theme cases, QRY-052, QRY-053, REC-063 and QRY-071. macOS power events overlap 98–100% of each failure interval, including a browser-launch timeout; test and teardown timers advanced during host sleep. The run is retained as failed, not converted to passing evidence. This explains its abnormal duration but does not prove every affected workflow correct. An uninterrupted hosted run is required, along with public delivery checks. The earlier NAV-025 search failure remains independently recorded. At that historical checkpoint, no RC8 tag or public artifacts existed.
|
||||
|
||||
LONG regression coverage establishes frontend schema/component behavior, including correction and submission of exact digits. It does not establish a new backend range-metadata contract.
|
||||
|
||||
## Historical September 30 follow-up verification
|
||||
|
||||
The first hosted RC8 candidate at `14d6966` completed its primary Chromium job with **594 passed / 2 failed** (REC-053, desktop API label clipping in both themes). General CI and image build passed. Secondary acceptance jobs remain in progress. Publication is blocked until the corrected combined candidate has fresh release evidence.
|
||||
|
||||
A wider-font regression reproduced label overflow at 210px content versus 198px available. The reviewed correction uses RapiDoc public CSS parts scoped to the wide overview. It passed **14 desktop/mobile API workflows**, 1,913 units, types, lint/license (five existing warnings), clean export and bundle budgets. Desktop/mobile light/dark screenshots were inspected. The reviewed search correction independently passed **601 full Chromium workflows and all 391 required feature rows**, with three approved external-service exclusions and zero failures/skips/flaky outcomes/report errors; its unit total is 1,919 and it also passed 34 focused browser cases. These are separate source checks; combined candidate verification is next. Earlier failed runs remain retained.
|
||||
|
||||
## Corrected candidate verification and publication
|
||||
|
||||
Corrected source `cd3d473` passed **1,919 unit tests**, types, lint/license (five existing warnings), clean static export, Maven packaging and bundle budgets. All 193 packaged dashboard files match the export. The actual local JAR passed **24 affected desktop/mobile workflows**, with zero failures/skips/flaky outcomes/report errors. Local full Chromium and [hosted primary acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799502) each passed **602 cases and all 391 required feature rows**, with three approved external-service exclusions and zero failures/skips/flaky outcomes/report errors. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799444) and [image build](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799498) passed. Corrected WebKit finished **600 passed / 2 failed** (NAV-034 legacy process-URL redirect timeout; REL-005 internal navigation error), zero skips/flaky/report errors. Reports/traces are retained and causes remain unproven. Corrected Firefox finished **600 passed / 2 failed** (NAV-014 font-download diagnostics during breadcrumbs; REC-009 save-success toast not observed after updated record display), zero skips/flaky/report errors. The corrected touch step is marked cancelled; its retained artifact has no final report or gate. No passing touch total is claimed. At artifact collection, GitHub still reported the job in progress despite every exposed step being completed. The cancellation cause is unconfirmed; all four candidate artifacts were archived before the later documentation push.
|
||||
|
||||
The original candidate finished WebKit 596/0, mobile 557/0 and tablet 556/1 (REL-005 internal WebKit navigation error). Its unfinished Firefox job was cancelled by the owner-requested candidate push; no passing Firefox result is claimed. Earlier failures remain retained as history and are not erased by the corrected primary passes.
|
||||
|
||||
Signed RC8 tag publication started CircleCI Maven jobs 141 and 142, [image publication36800964056](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36800964056), and [tag acceptance36800964105](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36800964105). CircleCI jobs 141 and 142 and image publication 36800964056 completed successfully. Maven Central initially returned HTTP 404 after upload; the public JAR is now available and verified (October 1). Its SHA-1 matches Central, the signature matches publishing key `68A49476754103988EA3ED924B88CBB3D6DCB237`, and a fresh Maven consumer resolves identical bytes. The public JAR passed all **28 affected desktop/mobile workflows**, zero failures/skips/flaky outcomes/report errors. SHA-256: `238c9ae80beea336717be759219f0d65a1d614e8e7d6a145a16374cc392a73a1`. The anonymous public image manifest/pull passed (AMD64/ARM64); the downloaded ARM64 image served its route, all 21 scripts, both stylesheets and the pinned font successfully. Manifest digest `sha256:2be3b74f614b1d038d79aba2894b210b988025000e86a572364a902fb0106c25`. This is an image delivery/assets check, not a replacement for backend workflows.
|
||||
|
||||
The immutable tag's Chromium job in run 36800964105 also passed **602 cases and all 391 required rows**, with three approved exclusions and zero failures/skips/flaky outcomes/report errors. Its complete artifact is retained separately in `rc8-tag-ci-chromium/`; tag WebKit completed **601 passed / 1 failed** (PRC-021 process display after a failed `/qqq/v1/manageSession` request with a WebKit internal error), with zero skips/flaky outcomes/report errors. Its complete artifact is retained in `rc8-tag-ci-webkit/`; cause unproven. Tag Firefox finished **600 passed / 2 failed** (NAV-015 and SEC-043 font-download diagnostics), with zero skips/flaky outcomes/report errors. No user-isolation assertion failure is reported for SEC-043; the font diagnostics still fail the test and their cause is unproven. The complete artifact is retained in `rc8-tag-ci-firefox/`. Tag mobile passed **563 cases**; tablet passed **559 and failed 4**: NAV-025 Clear-search focus in light/dark, REL-005 WebKit internal navigation error, and SEC-043 recent-record visibility after same-user reload before the user switch. Zero skips/flaky outcomes/report errors. Full artifacts are retained in `rc8-tag-ci-touch/`; these failures remain open.
|
||||
|
||||
## Remaining requirements
|
||||
|
||||
Complete embedded report/query equivalence, remaining input configurations, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next remains the visual reference. Do not infer full compatibility from the current acceptance matrix.
|
||||
|
||||
Use the QQQ 4.1.0-SNAPSHOT development backend with report-variant and configured Google Drive support. Record the application's exact backend commit. This candidate does not release QQQ 4.1.
|
||||
|
||||
## Local owner preview
|
||||
|
||||
A separate RC8 preview is running at `http://127.0.0.1:18781/app/person/` using the public ARM64 container and an isolated sample backend. Entry/grid/branding/custom-component checks passed with five Person rows and zero page errors in light mode. The fixture proxy routes acceptance assets and sample branding; the container uses explicit fixture CSP sources. This preview check does not claim full workflow coverage. RC7 remains separately available at port 18779.
|
||||
|
||||
Additional diagnostic evidence: candidate Firefox REC-009 captured the save-success toast before an approximately 7.45-second automation delay preceding later checks. Its four-second toast had expired before the subsequent visibility check; save/refetch requests succeeded quickly. This establishes that the notification appeared, while the automation delay remains unexplained. A separate Chromium probe of the local RC8 JAR verified that a preview popup carries temporary sort and stored criteria, matches SQL row order, and leaves the saved report unchanged. These findings do not alter the failed-run totals or the 602-case gate.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Next UI 1.0.0-RC.9
|
||||
|
||||
Release candidate for application testing, October 1, 2026. The [GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) records public artifact availability and final delivery verification; a source tag alone is not delivery confirmation. Final 1.0 remains held for owner application testing and explicit approval.
|
||||
|
||||
## Changes from RC8
|
||||
|
||||
RC9 integrates the GitHub tracking policy, current roadmap links, and updated developer/agent documentation. Application code and dependencies are unchanged from RC8. This candidate does not claim fixes for the open functional or visual findings.
|
||||
|
||||
[QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713) is the source of truth for current status and remaining work. Original issue reports and historical acceptance evidence remain available. See the [tracking policy](../ISSUE-TRACKING.md).
|
||||
|
||||
## Verification baseline and known issues
|
||||
|
||||
The documentation source `354ee2709d8ee553de5e3ad823ce9d1190887d4c`, merged through PR #16, passed general CI (typecheck, lint, unit coverage, mocked Chromium E2E, Storybook and bundle budgets) and both container architecture builds. Real-backend [run 36861609503](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36861609503) passed all 602 Chromium cases and all 391 required rows, with three approved real-provider exclusions.
|
||||
|
||||
Its secondary reports remain failed: Firefox 599 passed / 3 failed (NAV-014, NAV-016 and REC-056 legacy-font diagnostics, [#951](https://github.com/QRun-IO/qqq/issues/951)), WebKit 601 / 1 (WID-073 metadata access-control diagnostic, [#961](https://github.com/QRun-IO/qqq/issues/961)), and combined touch 1,124 / 2 (tablet NAV-025 and REL-005). Reports contain zero skips/flaky outcomes/report errors. These are pre-RC9-version source results; final tag results and public artifact receipts are recorded separately below. No all-browser pass is claimed.
|
||||
|
||||
Current open work includes embedded report/query equivalence, remaining input configurations, subpath hosting, real Auth0/Google Drive/QuickSight checks, browser investigations, original-Next visual approval, real-application soak and final release approval. See [#713](https://github.com/QRun-IO/qqq/issues/713) and [browser evidence](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual issues. Material remains the behavior reference; the original Next design remains the visual reference.
|
||||
|
||||
## Versioned local verification
|
||||
|
||||
RC9 passes all 1,919 unit tests across 181 files, typechecking, lint/license (zero errors, five existing warnings), production export, Maven packaging and unchanged bundle budgets. All 193 packaged dashboard files are present; the Maven version is RC9 and no mock service worker is included. The actual locally packaged RC9 JAR passes 28 affected desktop/mobile browser cases with zero failures/skips/flaky outcomes/report errors. These checks cover API controls/theme/search focus, exact numeric persistence and composite field submission against backend e0d57dd31. They do not replace public artifact verification or certify other profiles.
|
||||
|
||||
## Installation
|
||||
|
||||
Maven coordinate: `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.9`.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.9` (AMD64/ARM64 verified). The container backend origin is fixed at build time; the published image targets `host.docker.internal:8000`. Use the JAR or rebuild for another origin.
|
||||
|
||||
As with RC8, verification uses the QQQ 4.1 development backend. The locally verified backend source is `e0d57dd31b979b17215370546ef4e26cd8a38d21`; hosted workflows resolve their configured QQQ ref separately. This release does not publish QQQ 4.1 or certify every real consumer/backend combination.
|
||||
|
||||
## Public delivery — October 1, 2026
|
||||
|
||||
[RC9](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) was published at 19:25:28 UTC as a prerelease, latest=false. PR16 and PR14 are merged. The signed immutable tag points to `c029ad2a70948aac6469d8a71ccefc80c6e00d74`.
|
||||
|
||||
- Public JAR: 1,864,121 bytes; SHA256 `969034566c6b30a5034680dcf2d46aec327240db6cdd3fb7b31f1fae7cb9f17d`; SHA1 `3943c8641c14554999206cf52357c32b8f397b7b`.
|
||||
- Release signature verified against fingerprint `68A49476754103988EA3ED924B88CBB3D6DCB237`; a clean ordinary Maven consumer resolved identical bytes without manual installation.
|
||||
- Actual public JAR: all 28 targeted desktop/mobile cases passed, zero failures/skips/flaky outcomes/report errors. All 193 dashboard files and RC9 version markers are present, without the mock service worker.
|
||||
- Public image: `sha256:c2c684733deb98fd8355d4a9e2a53bf4af429bd16d9e7802641f9ef9d44e1017`. Anonymous AMD64/ARM64 pulls and both architecture startup, route, JavaScript, pinned-font and version checks passed.
|
||||
- Merged-source general CI36912597438 passed. Fresh full dependency audit reports zero advisories across1,038dependencies; GitHub marked all four outstanding alerts fixed, none dismissed. Issues #647/#698 are closed with that evidence.
|
||||
|
||||
Full immutable-tag acceptance [36912641399](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36912641399) completed after publication: Chromium 602 passed / 0 failed; desktop WebKit 602 / 0; Firefox 600 / 2; mobile 563 / 0; tablet 560 / 3. All reports contain zero skips, flaky outcomes and report errors. Firefox failed NAV-016 legacy-font diagnostics and REC-048 validation-toast visibility; tablet failed NAV-015 JavaScript loading, NAV-016 root navigation (both WebKit internal errors), and NAV-025 Clear-search focus in dark mode. The overall run failed; later passing cases do not resolve older investigations. Follow [frontend#12](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual failure tracking and [#713](https://github.com/QRun-IO/qqq/issues/713) for the live backlog. Final 1.0 remains held.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Next UI — unreleased
|
||||
|
||||
[RC11 is published and immutable](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11). It includes the searchable-selector and script-completion corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24); its release details remain in [RC11 notes](1.0.0-RC.11.md). Current main also contains the later menu, copy-focus and grid fixes, which are not in that published artifact. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
|
||||
## New features
|
||||
|
||||
- SQL Workbench: render the `SQL_WORKBENCH` widget of the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit as a super-user SQL notebook.
|
||||
- Personal and shared notebooks with SQL and markdown cells, `:parameters`, versions and pinned results.
|
||||
- A CodeMirror 6 editor with dialect highlighting, schema and QQQ-name autocomplete, and formatting.
|
||||
- A typed, sortable, paged result grid with CSV/XLSX/JSON export, quick charts, and EXPLAIN plan trees.
|
||||
- Read-only runs by default, and a two-step write confirmation for users with the write permission.
|
||||
- CodeMirror and the formatter load only on that page (new `lazyChunks` budgets).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Clear remembered query filters and saved-view selections on logout or a detected user identity change. Same-user resumes keep their query state; global appearance, density and legacy column preferences remain intact. This correction awaits its own branch acceptance. [#1006](https://github.com/QRun-IO/qqq/issues/1006)
|
||||
- In searchable possible-value fields, invalidate old results immediately when the search term changes, so a debounced request cannot remove an option during its selection. [#970](https://github.com/QRun-IO/qqq/issues/970)
|
||||
- Keep code suggestions open when a delayed textarea scroll event reports the position already used for the popup; dismiss them when the scroll position actually changes. [#954](https://github.com/QRun-IO/qqq/issues/954)
|
||||
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
|
||||
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
|
||||
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Pin the official Playwright runner containing the fixed Linux WebKit network library, and retain native browser diagnostics for navigation failures. The previous bundle's heap corruption was reproduced; migration to a stable runner remains tracked separately. [#904](https://github.com/QRun-IO/qqq/issues/904), [#973](https://github.com/QRun-IO/qqq/issues/973)
|
||||
- Correlate interrupted Firefox font diagnostics with real request and decode evidence, wait for initial fonts before the deliberate redirect, and observe transient feedback during the action that produces it. [#951](https://github.com/QRun-IO/qqq/issues/951), [#953](https://github.com/QRun-IO/qqq/issues/953), [#967](https://github.com/QRun-IO/qqq/issues/967)
|
||||
- Wait for nested report metadata and the destination's owned iframe before advancing acceptance navigation. [#961](https://github.com/QRun-IO/qqq/issues/961), [#972](https://github.com/QRun-IO/qqq/issues/972)
|
||||
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
|
||||
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
|
||||
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
|
||||
- Select all ten ESB scenarios for both phone and tablet acceptance, retaining real broker delivery, lifecycle, replay, SQL and permission checks. Matrix references now use the central QQQ tickets. [#986](https://github.com/QRun-IO/qqq/issues/986), [#990](https://github.com/QRun-IO/qqq/issues/990)
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
Current main `5ca3e0b` has a complete [3,010-case first-attempt acceptance pass](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36989591498): 620 per desktop browser and 575 per touch profile. That run qualifies the main tree before the query-state correction above. The earlier checkpoints below are retained separately.
|
||||
|
||||
The [combined acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) on head `7ff90f1` (the same tree as merge `289eca19`) passed all 2,972 executed cases: 614 each in Chromium, Firefox and WebKit, and 565 each on phone and tablet. All desktop gates covered 401 required rows. The workflow still failed its touch coverage gate because ten ESB rows were not selected; that original failure remains retained under [#990](https://github.com/QRun-IO/qqq/issues/990). After adding the selection markers, a separate native Linux run passed all 20 ESB phone/tablet cases, with zero retries, skips, flaky outcomes or report errors. Its frozen frontend export is unchanged; its freshly rebuilt backend is `55b33e6`. Complete touch collection is 575 cases per profile. The subsequent [complete PR23 outcome](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159) covers all 401 required IDs on each profile with zero missing and all 20 ESB touch cases passing, completing #990. Touch totals are 1,149 passes and one unrelated tablet NAV-056 failure; the full gate and workflow remain failed. [NAV-056 investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383) remains separate, with no shared-cause claim or new waiver.
|
||||
|
||||
Each linked issue and PR records the source commit, reproduction and scoped verification. Preserve failed full-run snapshots separately from later focused passes. The broader historical navigation inventory [#904](https://github.com/QRun-IO/qqq/issues/904), same-user session stall [#952](https://github.com/QRun-IO/qqq/issues/952), legacy process-navigation stall [#960](https://github.com/QRun-IO/qqq/issues/960), and original toast-paint uncertainty [#967](https://github.com/QRun-IO/qqq/issues/967) remain open. The [RC10 preparation notes](1.0.0-RC.10.md) retain their historical checkpoint; its [public release entry](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) records final delivery verification. See [RC11 notes](1.0.0-RC.11.md) for the successor candidate. No final-release approval is claimed by these results.
|
||||
|
||||
Final 1.0 remains held for real-application soak and explicit owner approval under [#712](https://github.com/QRun-IO/qqq/issues/712). This work does not authorize a QQQ 4.1 release. See the [testing-period policy](1.0-testing.md) and [issue lifecycle](../ISSUE-TRACKING.md).
|
||||
|
||||
## Pending standalone branding compatibility (QQQ #1009)
|
||||
|
||||
The standalone Node server can route the exact root-relative `branding.logo` and
|
||||
`branding.icon` paths published by authentication metadata through fixed backend
|
||||
image roles. Local Node `public` files keep precedence; external image URLs and
|
||||
Java dashboard serving are unchanged. This does not expose the whole application
|
||||
overlay or add a general proxy.
|
||||
|
||||
This requires **a QQQ middleware-javalin build containing QQQ #1009's
|
||||
`/qqq/branding/logo` and `/qqq/branding/icon` endpoints**. They are absent from
|
||||
published **4.1.0-RC.1**. The minimum numbered release is not yet assigned; a
|
||||
source build must record the exact backend commit/JAR hashes. Older deployments
|
||||
must supply branding through their Node `public` files or an existing external
|
||||
image URL. No automatic compatibility with old relative backend assets is claimed.
|
||||
|
||||
The preserved standalone qualification against public RC1 remains **5 passed,
|
||||
38 failed** out of 43 cases because of missing branding assets. The original
|
||||
staging failure is also retained. The corrected cross-repository pairing has
|
||||
not yet received its browser qualification; neither #1009 nor #734 is closed by
|
||||
focused unit/HTTP results.
|
||||
@@ -0,0 +1,343 @@
|
||||
# Next UI security review (QRun-IO/qqq#696)
|
||||
|
||||
## Current status — October 4, 2026
|
||||
|
||||
This addendum supersedes the historical disposition statements below where noted.
|
||||
The September 25 review remains intact as evidence of its original source and scope.
|
||||
Current findings and release decisions are tracked in
|
||||
[QQQ #696](https://github.com/QRun-IO/qqq/issues/696#issuecomment-5982040326).
|
||||
This is a scoped security review, not a certification of ASVS Level 2 compliance.
|
||||
|
||||
| Finding | Current disposition |
|
||||
| --- | --- |
|
||||
| NUI-04: dashboard headers | [#695](https://github.com/QRun-IO/qqq/issues/695) is closed for the Javalin static-export scope, with current-source provider tests and browser evidence. Standalone qualification is separate. |
|
||||
| NUI-05: readable session cookie | [#733](https://github.com/QRun-IO/qqq/issues/733) is closed. Next resumes through the backend-owned cookie; the legacy endpoint correction is merged. Material's explicit readable-cookie compatibility mode remains supported. |
|
||||
| NUI-06: previous-user browser state | The additional query-filter and saved-view-pointer retention finding is fixed in [#1006](https://github.com/QRun-IO/qqq/issues/1006), merged in frontend `f367756`. Global visual preferences are preserved. |
|
||||
| NUI-09: standalone headers | [#734](https://github.com/QRun-IO/qqq/issues/734) and the metadata-derived CSP correction [#1017](https://github.com/QRun-IO/qqq/issues/1017) are closed after scoped qualification and integration. The paired implementation passed 48 standalone first-attempt cases across five profiles. [Frontend PR #40](https://github.com/QRun-IO/qqq-frontend-next/pull/40) merged as `138c3c6d`, tree `328bc322`, exactly matching the qualified combined candidate. Its separate full Javalin acceptance passed 3,015/3,015 first attempts against backend `1d16a70c`. HEAD/304, bounded metadata and Java-customizer limits remain documented in the closeout. |
|
||||
| NUI-11: custom CSS | Current code treats application `customCss` as trusted developer configuration and inserts it with `textContent`. It is not filtered by the historical deny-list or sanitized by DOMPurify. CSP and application configuration remain separate controls. |
|
||||
| Auth0 token verification | [#1008](https://github.com/QRun-IO/qqq/issues/1008) confirms issuer/audience validation and cache-boundary findings. [Backend PR #1012](https://github.com/QRun-IO/qqq/pull/1012) contains the correction; 164 focused tests passed on its combined backend tree. The exact-head CI passed and the correction merged into backend develop as `fc67393b`. An unconfigured audience remains supported; no audience enforcement is claimed in that configuration. |
|
||||
| Signed back-channel logout | [#909](https://github.com/QRun-IO/qqq/issues/909) remains open for stable release delivery. [Backend PR #1021](https://github.com/QRun-IO/qqq/pull/1021) merged the qualified correction into stable main as `9168e434`; its merge tree equals the qualified candidate and the automatic post-merge reactor/sample/static gates passed. Published 4.0.0 remains affected; planned 4.0.1 is not published or artifact-qualified. PR #1018 is closed as superseded, not merged; its original Mongo failure is retained. The corrected 4.1 RC line remains distinct. |
|
||||
|
||||
The other scoped finding dispositions remain as reconciled in #696. NUI-10's
|
||||
runtime-style exception remains deliberate; it does not allow inline scripts.
|
||||
Negative security tests intentionally produce denied-resource and CSP diagnostics.
|
||||
A passing test run does not mean those diagnostic arrays are all empty.
|
||||
|
||||
### Evidence limits
|
||||
|
||||
The reference remains ASVS 4.0.3. Owned identity-provider fixtures do not qualify a
|
||||
real Auth0 tenant, MFA or step-up policy, provider-grant revocation, or deployment
|
||||
TLS, HSTS and proxy trust. The approved real-provider exclusion remains explicit.
|
||||
Configurable TABLE_BASED idle expiration does not prove a 12-hour active / 30-minute
|
||||
idle policy. Cookie-prefix and shared-host suitability, generic CSRF/Origin
|
||||
protection, all-session revocation, and application-sensitive storage/cache policy
|
||||
also remain outside the demonstrated guarantees. These limits are not newly
|
||||
confirmed High/Critical exploits and must not be presented as universal compliance.
|
||||
|
||||
The 48-case standalone result covers its selected security scope and paired
|
||||
candidate backend. Its analytics case uses an owned plugin through the real
|
||||
application hook, not a vendor SDK or real account. It does not
|
||||
replace full-browser acceptance. The separate 3,015-case Javalin gate qualifies
|
||||
the merged PR40 tree; neither result resolves separately tracked browser failures
|
||||
or certifies a real provider or deployment. Final 1.0 still requires owner visual
|
||||
acceptance and real-application soak.
|
||||
|
||||
## Historical review — September 25, 2026
|
||||
|
||||
Independent review of the QQQ Next admin UI against OWASP ASVS 4.0.3 Level 2, for the
|
||||
Next UI 1.0 milestone (epic QRun-IO/qqq#713). Reviewed on 2026-09-25 at
|
||||
`feature/GH-649-next-acceptance` (`2b6569f`, release 0.2.0) with the backend at
|
||||
`feature/GH-649-next-default` (`f7bf8c4ac`). Fixes are on `feature/next-1.0-security`
|
||||
(frontend), `feature/next-1.0-security-backend` (qqq) and, for TABLE_BASED,
|
||||
`feature/next-1.0-security-auth-backend` (qqq, on top of `feature/next-1.0-auth-backend`).
|
||||
|
||||
## Scope
|
||||
|
||||
- Sign-in lifecycle for every supported authentication type: MOCK, FULLY_ANONYMOUS,
|
||||
OAUTH2 (authorization code + PKCE, code redeemed by the backend) and AUTH_0
|
||||
(authorization code + PKCE as a public client). TABLE_BASED (password sign-in, being added
|
||||
in QRun-IO/qqq#700) was reviewed at the backend: session validation, password attempts
|
||||
and the protection of its user and session tables (NUI-12 to NUI-14).
|
||||
- Token and session storage in the browser: the `sessionUUID` / `sessionId` cookies, the
|
||||
localStorage auth-metadata cache and stored identity, the sessionStorage PKCE attempt.
|
||||
- Logout and server-side revocation (QRun-IO/qqq#674, #675).
|
||||
- Post-login `returnTo` handling (QRun-IO/qqq#669).
|
||||
- HTML rendering: every `dangerouslySetInnerHTML` / `innerHTML` sink (field values, grid
|
||||
cells, help content, widgets, blocks, process steps, banners, the rich text editor) and
|
||||
branding `customCss`.
|
||||
- Permission gating in the UI (QRun-IO/qqq#671) and its server-side enforcement.
|
||||
- Response headers for the dashboard (QRun-IO/qqq#695, delivered with this review).
|
||||
|
||||
Out of scope: the QQQ backend authentication modules themselves (token validation, session
|
||||
store lifetime), the Material dashboard, and deployment concerns (TLS, HSTS at the proxy).
|
||||
|
||||
## Method
|
||||
|
||||
Manual code review of `src/lib/auth`, `src/lib/api`, `src/app/(auth)`, the dashboard layout
|
||||
and every HTML sink, the backend session and cookie code in `qqq-middleware-javalin`, and the
|
||||
built static export (inline scripts, runtime style injection, external origins). Each
|
||||
finding was reproduced (unit test or browser) before it was fixed, and every fix has a
|
||||
regression test: a Vitest unit test and, where the behavior is visible end to end, a row in
|
||||
the real-backend acceptance matrix (`tests/acceptance/matrix/security.json`), run in
|
||||
Chromium, Firefox, WebKit and the phone profile.
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Finding | ASVS 4.0.3 | Severity | Disposition |
|
||||
|----|---------|------------|----------|-------------|
|
||||
| NUI-01 | `returnTo` paths with dot segments normalize to `//host` (open redirect) | 5.1.5 | Medium | Fixed |
|
||||
| NUI-02 | Session cookies had no `SameSite` and no `Secure` | 3.4.1, 3.4.3 | Medium | Fixed (backend) |
|
||||
| NUI-03 | Sanitized HTML could still add forms, password inputs, style sheets and page-covering overlays | 5.2.1 | Medium | Fixed |
|
||||
| NUI-04 | No Content-Security-Policy, framing, referrer or permissions headers on the dashboard | 14.4.3, 14.4.6, 14.4.7 | Medium | Fixed (#695) |
|
||||
| NUI-05 | `sessionUUID` is readable by script (not `HttpOnly`) | 3.4.2 | Medium | Follow-up QRun-IO/qqq#733 |
|
||||
| NUI-06 | Recently viewed records and the stored identity survive a session expiry and are shown to the next user of the browser | 8.2.3 | Low | Fixed |
|
||||
| NUI-07 | The login page displays arbitrary `?error=` text (content spoofing) | 7.4.1 | Low | Fixed |
|
||||
| NUI-08 | Unused browser auth SDKs and cookie/crypto helpers in the dependencies | 14.2.2 | Low | Fixed |
|
||||
| NUI-09 | The standalone (container image) Next server sends none of the #695 headers | 14.4.3 | Low | Follow-up QRun-IO/qqq#734 |
|
||||
| NUI-10 | `style-src` allows `'unsafe-inline'` | 14.4.3 | Info | Accepted |
|
||||
| NUI-11 | Branding `customCss` is filtered with a deny-list | 5.2.1 | Info | Accepted |
|
||||
| NUI-12 | TABLE_BASED: the inactivity timeout was not checked for 30 minutes after each validation | 3.3.2 | Medium | Fixed (backend) |
|
||||
| NUI-13 | TABLE_BASED: no rate limiting or lockout of password attempts | 2.2.1 | Medium | Fixed (backend) |
|
||||
| NUI-14 | TABLE_BASED: the user table (password hashes) and session table (session ids) were readable by any session unless the application protected them | 4.1.3, 8.3.4 | High | Fixed (backend) |
|
||||
|
||||
No Next UI finding allowed script execution, session theft without prior script execution,
|
||||
or a permission bypass: the backend enforced every permission the UI hides (SEC-001 to
|
||||
SEC-015 prove the refusals by API and SQL), DOMPurify removed all active content, and React
|
||||
19 blocks `javascript:` URLs. The most serious finding is in the TABLE_BASED backend module
|
||||
(NUI-14): its default metadata left the session table, and with it other users' session ids,
|
||||
readable by any signed-in session.
|
||||
|
||||
## Findings
|
||||
|
||||
### NUI-01 Open redirect through dot segments in `returnTo` (fixed)
|
||||
|
||||
`safeReturnTo()` rejected `//host` and `/\host` before parsing, then returned the parsed
|
||||
`pathname`. The WHATWG URL parser removes dot segments, so `/.//evil.example`,
|
||||
`/..//evil.example`, `/app/..//evil.example` and `/app/../\evil.example` passed the check and
|
||||
came back as `//evil.example`, which `router.replace()` treats as another origin. The login
|
||||
page and the OAuth callback redirect to this value.
|
||||
|
||||
Fix: `src/lib/auth/return-to.ts` also rejects a normalized path that starts with `//` or
|
||||
`/\`. Tests: `src/lib/auth/return-to.test.ts` (seven bypass shapes, harmless dot segments
|
||||
still normalize); acceptance SEC-024 (new negative scenario, `headers.spec.ts`).
|
||||
|
||||
### NUI-02 Session cookies without `SameSite` and `Secure` (fixed, backend)
|
||||
|
||||
`sessionUUID` and `sessionId` were set with `context.cookie(name, value, maxAge)`: no
|
||||
`SameSite`, no `Secure`. Chromium treats that as `Lax`, but Firefox and Safari send such
|
||||
cookies with cross-site POSTs, and several legacy endpoints accept form-encoded and multipart
|
||||
bodies, so another site could submit requests with the user's session (CSRF, ASVS 4.2.2).
|
||||
Over HTTPS the cookies could also be sent over plain HTTP.
|
||||
|
||||
Fix: `QJavalinImplementation.setSessionCookie()` sets both cookies (legacy and v1
|
||||
manageSession, the per-request `sessionId` refresh) with `Path=/`, `SameSite=Lax`, and
|
||||
`Secure` when the request came over HTTPS (the connection or `X-Forwarded-Proto`). Plain
|
||||
HTTP development keeps working. The OAuth return from the identity provider is a top-level
|
||||
GET, which Lax cookies accompany. Tests: `ManageSessionSpecV1Test.testSessionCookieAttributes`;
|
||||
acceptance SEC-044.
|
||||
|
||||
### NUI-03 Stored HTML could impersonate the application (fixed)
|
||||
|
||||
All sinks used DOMPurify's default profile. It removes scripts, handlers and `javascript:`
|
||||
URLs, but keeps `<style>`, `<form>`, form controls and inline `position: fixed`. An HTML field
|
||||
value (any user who can edit the record), a widget or a process step could therefore show a
|
||||
full-page "session expired" overlay with a password field that posts elsewhere, or restyle
|
||||
the whole page, inside the trusted origin.
|
||||
|
||||
Fix: one sanitizer, `src/lib/utils/sanitize-html.ts`, used by every sink (FieldValue,
|
||||
DataCell, HelpContent, SafeHtml and the widgets that use it, BlockWidget, HtmlComponent and
|
||||
ProcessStepScreen, Banner, RichTextField). It keeps DOMPurify's defaults and additionally
|
||||
removes `style`, `form`, `input`, `button`, `textarea`, `select`, `option`, `optgroup`,
|
||||
`datalist` and `dialog`, and drops inline `position` other than `static`/`relative`
|
||||
(including `var()` values). Formatting, links, images, tables and other inline styles stay.
|
||||
The Content-Security-Policy's `form-action 'self'` blocks off-site form posts as a second
|
||||
layer. Tests: `src/lib/utils/sanitize-html.test.ts`; acceptance SEC-042 (a stored form,
|
||||
password input, style element and fixed overlay render as plain content; the database value
|
||||
is unchanged); REC-020 still passes.
|
||||
|
||||
### NUI-04 No security headers on the dashboard (fixed, QRun-IO/qqq#695)
|
||||
|
||||
`NextDashboardRouteProvider` sent only `X-Content-Type-Options`. It now sends, per
|
||||
`NextDashboardSecurityHeaders`:
|
||||
|
||||
```
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' <custom component origins> 'sha256-<each inline script>';
|
||||
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:;
|
||||
connect-src 'self' <identity provider origins>; frame-src 'self' <QuickSight when used>;
|
||||
worker-src 'self' blob:; manifest-src 'self'; media-src 'self' data: blob:; object-src 'none';
|
||||
base-uri 'self'; form-action 'self'; frame-ancestors 'none'
|
||||
X-Frame-Options: DENY
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), hid=(),
|
||||
magnetometer=(), microphone=(), midi=(), payment=(), serial=(), usb=()
|
||||
X-Content-Type-Options: nosniff
|
||||
```
|
||||
|
||||
- The static export has two inline scripts per page (the router payload bootstrap). The
|
||||
provider hashes the inline scripts of each exported HTML file once and lists exactly those
|
||||
hashes, so no other inline script, inline event handler or `javascript:` URL runs, and no
|
||||
`'unsafe-eval'` is needed.
|
||||
- Origins come from metadata: OAUTH2 and AUTH_0 `baseUrl` (browser discovery and the Auth0
|
||||
token exchange), `https://*.quicksight.aws.amazon.com` when a quickSightChart widget
|
||||
exists, and each customComponent widget's `componentSourceUrl` origin.
|
||||
- Override hook: `QApplicationJavalinServer.withNextDashboardSecurityHeadersCustomizer(...)`
|
||||
(or `NextDashboardRouteProvider.withSecurityHeadersCustomizer`) receives the headers after
|
||||
those additions, can add, replace or remove directives and headers, switch to report-only,
|
||||
and runs again on hot swap. Values are validated (no `;`, `,` or line breaks).
|
||||
- Referrer: other origins get only the application origin, never a path or query (the
|
||||
`/token?code=` URL included).
|
||||
|
||||
Tests: `NextDashboardRouteProviderTest` (exact headers, hashes computed independently,
|
||||
instance origins, customizer and hot swap, report-only/disabled, rejected values) and
|
||||
`QApplicationJavalinServerTest`; acceptance SEC-038 (every document type, hashes match the
|
||||
served document, framing refused, injected inline script and handler refused and reported),
|
||||
SEC-039 (OAUTH2 and AUTH_0 sign-in under the policy, other origins refused), SEC-040 (the
|
||||
acceptance application's override hook and the metadata-derived origins). The diagnostics
|
||||
fixture now fails any test on a `securitypolicyviolation` event, in every frame and browser,
|
||||
so the whole suite proves the policy breaks nothing.
|
||||
|
||||
### NUI-05 `sessionUUID` readable by script (follow-up QRun-IO/qqq#733)
|
||||
|
||||
The backend sets `sessionUUID` without `HttpOnly` because both dashboards read it:
|
||||
the Next UI resumes OAUTH2/AUTH_0 sessions by posting the value to `manageSession`, and the
|
||||
Material dashboard does the same. Making it `HttpOnly` needs a backend resume-from-cookie
|
||||
path and changes in both UIs, which is outside this review. Mitigations in place: the
|
||||
Content-Security-Policy blocks injected script (NUI-04), the sanitizer (NUI-03), `SameSite`
|
||||
and `Secure` (NUI-02), and server-side logout invalidation (SEC-033).
|
||||
|
||||
### NUI-06 Per-user browser data survived session expiry (fixed)
|
||||
|
||||
Logout cleared the recently viewed records and stored identity, but a session that expired
|
||||
(or was revoked) left them in localStorage; the next person to sign in on the browser saw the
|
||||
previous user's record labels on the home page, and an OAUTH2 resume without identity values
|
||||
could show the previous user's name.
|
||||
|
||||
Fix: `claimClientData()` (`src/lib/auth/auth-storage.ts`) binds that data to the signed-in
|
||||
identity; the auth provider calls it before storing a new identity (session creation, resume
|
||||
and the OAuth callback), and a different identity clears it. Tests:
|
||||
`src/lib/auth/auth-storage.test.ts`; acceptance SEC-043.
|
||||
|
||||
### NUI-07 Login page content spoofing (fixed)
|
||||
|
||||
`/login?error=<text>` rendered `Sign-in failed (<text>).` for any value, so a link could put
|
||||
arbitrary instructions ("call this number") on the sign-in page. React escaped it, so this
|
||||
was spoofing, not injection.
|
||||
|
||||
Fix: `src/lib/auth/callback-errors.ts` explains known OAuth and callback codes, shows other
|
||||
well-formed codes (`^[a-z][a-z0-9_]{0,63}$`) as a code, and shows `Sign-in failed.` for
|
||||
anything else. Tests: `src/lib/auth/callback-errors.test.ts`; acceptance SEC-041.
|
||||
|
||||
### NUI-08 Unused auth dependencies (fixed)
|
||||
|
||||
`@auth0/auth0-react`, `oidc-client-ts`, `universal-cookie` and `ts-md5` were declared but
|
||||
never imported: the flows are implemented once in `src/lib/auth/oidc.ts`. They were not in
|
||||
the bundle, but looked like a second auth stack to reviewers and dependency scanners. They
|
||||
are removed; `src/lib/auth/auth-dependencies.test.ts` keeps them out. (Other unused runtime
|
||||
dependencies, such as `lodash` and `date-fns`, are not security-relevant and are left to the
|
||||
dependency work in QRun-IO/qqq#698.)
|
||||
|
||||
### NUI-09 Standalone server headers (follow-up QRun-IO/qqq#734)
|
||||
|
||||
The container image runs the Next standalone server, which does not go through
|
||||
`NextDashboardRouteProvider`. It needs the same headers; the script hashes have to be
|
||||
computed from the prerendered pages after the build. Applications served by QQQ (javalin
|
||||
mode, the default) have them now.
|
||||
|
||||
### NUI-10 `style-src 'unsafe-inline'` (accepted)
|
||||
|
||||
The UI creates style elements at runtime: the dialog scroll lock (react-remove-scroll /
|
||||
react-style-singleton, used by every Radix dialog), sonner toasts, React's style hoisting and
|
||||
branding `customCss`, and the prerendered pages carry style attributes. Hashes cannot cover
|
||||
runtime styles and a per-response nonce does not fit a static export. Inline styles cannot
|
||||
run script; the injection paths for style elements from data are closed by the sanitizer
|
||||
(NUI-03), and image exfiltration through CSS is limited to HTTPS origins.
|
||||
|
||||
### NUI-11 `customCss` deny-list (accepted)
|
||||
|
||||
Branding `customCss` comes from the application's metadata (developer-controlled, not user
|
||||
data). The layout strips `</style>`, `expression(`, `@import`, `javascript:` and `data:` URLs
|
||||
and applies it through `textContent`. With the new policy it cannot load script, fonts or
|
||||
frames from other origins. Accepted as trusted configuration.
|
||||
|
||||
### TABLE_BASED authentication (NUI-12 to NUI-14)
|
||||
|
||||
Added to the review scope from the TABLE_BASED work (QRun-IO/qqq#700, branch
|
||||
`feature/next-1.0-auth-backend`). The fixes are on `feature/next-1.0-security-auth-backend`,
|
||||
which is based on that branch and must be merged after it.
|
||||
|
||||
**NUI-12 Inactivity timeout not enforced promptly (fixed).**
|
||||
`TableBasedAuthenticationModule.isSessionValid` trusted a validation for 30 minutes without
|
||||
looking at the session's idle time, so an `inactivityTimeoutSeconds` below about 30 minutes
|
||||
was not enforced; the table's access time was also only refreshed on revalidation, which
|
||||
could expire an active session with a short timeout. Now the last request per session is
|
||||
kept next to the last validation, a request after more than the timeout of idleness is
|
||||
checked against the table again (using the later of the table's and this server's last
|
||||
activity), and a validation is trusted for at most half the timeout (still at most 30
|
||||
minutes). Expired validations are forgotten. Tests: `TableBasedAuthenticationModuleTest`
|
||||
(`testShortInactivityTimeoutIsEnforcedPromptly`, `testActiveSessionIsNotExpiredEarly`,
|
||||
`testValidationIntervalIsAtMostHalfTheTimeout`).
|
||||
|
||||
**NUI-13 No password attempt limiting (fixed).** Password sign-in (Basic credentials on v1
|
||||
`manageSession` and every other Basic-auth path into the TABLE_BASED module) had no limit.
|
||||
A soft lockout now refuses a username for `signInLockoutSeconds` (default 900) after
|
||||
`maxFailedSignInAttempts` (default 5) failures within that window, before the password is
|
||||
checked, with "Too many failed sign-in attempts. Try again later." Unknown usernames are
|
||||
counted the same way (no enumeration; the unknown-user path already hashes). A success
|
||||
resets the count; both settings are on `TableBasedAuthenticationMetaData`, and 0 disables
|
||||
the lockout. The count is kept per server in memory; per-address limiting belongs at the
|
||||
proxy. Tests: `TableBasedAuthenticationModuleTest` (`testSignInLockout`,
|
||||
`testSignInLockoutResetAndDisable`), `TableBasedSessionSpecV1Test.testRepeatedFailuresLockTheUsernameOut`
|
||||
(over HTTP; no session row is created).
|
||||
|
||||
**NUI-14 Authentication tables readable (fixed).** With the instance's default permission
|
||||
rules (often `NOT_PROTECTED`), any signed-in session could query the user table (password
|
||||
hashes) and the session table (whose ids are bearer tokens for other users' sessions).
|
||||
`QInstanceEnricher` now protects the configured user and session tables of every
|
||||
TABLE_BASED provider when the application has not set permission rules on them:
|
||||
`READ_INSERT_EDIT_DELETE_PERMISSIONS` with `DenyBehavior.HIDDEN`, so only sessions granted
|
||||
`user.read` (and so on) see them. The password hash field is always hidden, so the API never
|
||||
returns it even to such users. The module reads the hash itself with hidden fields
|
||||
included, and works on both tables with an internal session that holds every permission.
|
||||
Applications that set their own rules keep them. Tests:
|
||||
`TableBasedAuthenticationModuleTest.testAuthenticationTablesProtectedByDefault`.
|
||||
|
||||
## Verified without findings
|
||||
|
||||
| Area | What was checked | Evidence |
|
||||
|------|------------------|----------|
|
||||
| PKCE | S256; 32-byte verifier (43 chars); 16-byte state; state removed before comparison (single use); verifier removed after use and on failure; fixed redirect URI `{origin}/token` | `src/lib/auth/oidc.ts`, `CallbackContent.tsx`; SEC-025, SEC-026 (forged state rejected before any token exchange) |
|
||||
| OAUTH2 code exchange | Redeemed by the backend with its client secret and the verifier; the browser never holds tokens | SEC-025 |
|
||||
| AUTH_0 tokens | Public client with PKCE; the access token is only posted to `manageSession` and never stored; the ID token is used for display only and the backend verifies the access token against the provider keys | `auth-provider.tsx`; SEC-029, SEC-033 |
|
||||
| Anonymous types | MOCK and FULLY_ANONYMOUS create a session through `manageSession`; a denied session is an error, never a silent anonymous fallback | SEC-020, SEC-023, SEC-031 |
|
||||
| Logout | Backend session deleted and every issued cookie expired (#674, #675); provider end-session called; client caches, identity and recent records cleared; the tab stays signed out | SEC-021, SEC-027, SEC-033 |
|
||||
| Expiry and revocation | Any 401 outside the session endpoints clears the query cache and returns to login with `returnTo`; the automatic re-authentication loop stops after three attempts a minute | `client.ts`, `auth-storage.ts`; SEC-022, SEC-028 |
|
||||
| Browser storage | localStorage: auth metadata (public configuration, 10 minute TTL), display identity, recent records, UI preferences; sessionStorage: one PKCE attempt and the signed-out marker; no tokens or secrets | `auth-storage.ts`, `api/auth.ts`, `oidc.ts` |
|
||||
| Links | React 19 replaces `javascript:` URLs; link adornments and file URLs are limited to http(s) and same-origin paths; every `target="_blank"` link has `rel="noopener noreferrer"` | `adornment-utils.ts`, `FieldValue.tsx`, `BlockSlot.tsx` |
|
||||
| Permission gating | Tables, records, processes, reports, widgets and apps are hidden or disabled from metadata permissions and capabilities; the backend refuses the same actions | `permissions.ts`; SEC-001 to SEC-015 |
|
||||
| Secrets in metadata | Widget secrets (QuickSight keys) never reach the browser | WID-015 |
|
||||
|
||||
## Evidence
|
||||
|
||||
Recorded 2026-09-25 on macOS, sample `4.1.0-SNAPSHOT` built from
|
||||
`feature/next-1.0-security-backend`, static export of `feature/next-1.0-security`, the
|
||||
policy on and the diagnostics fixture failing on any CSP violation:
|
||||
|
||||
- `QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile node scripts/acceptance.mjs`:
|
||||
chromium 337/337, webkit 337/337, mobile 15/15, firefox 336/337. The one Firefox failure
|
||||
was REC-045 (a possible-value search result not listed within 15 s under machine load; no
|
||||
diagnostics or policy violation); it passed 5 of 5 on repeat and in a rerun of the records
|
||||
area. Every new row (SEC-038 to SEC-044) passed in every project that runs it.
|
||||
- Unit: `pnpm test` 96 files / 957 tests; `pnpm typecheck`; `pnpm lint` (0 errors).
|
||||
- Backend: `mvn -pl qqq-middleware-javalin verify` 473 tests, checkstyle 0, coverage met;
|
||||
`mvn -pl qqq-backend-core verify` (TABLE_BASED branch) 1988 tests, checkstyle 0, coverage
|
||||
met; `TableBasedSessionSpecV1Test` 3/3.
|
||||
|
||||
## Residual risk and recommendations
|
||||
|
||||
- Session lifetime (ASVS 3.3.2) is owned by the backend authentication modules and the
|
||||
session store; cookies last 24 hours. TABLE_BASED now enforces its inactivity timeout
|
||||
(NUI-12); it has no absolute lifetime, and the other modules follow their provider.
|
||||
- The TABLE_BASED lockout (NUI-13) is per server and in memory; clustered deployments get
|
||||
one budget per node, so put per-address rate limiting at the proxy as well.
|
||||
- HSTS belongs at the TLS terminator in front of the application.
|
||||
- NUI-05 and NUI-09 are tracked in QRun-IO/qqq#733 and #734.
|
||||
+8
-3
@@ -1,17 +1,22 @@
|
||||
import type { NextConfig } from 'next'
|
||||
import packageJson from './package.json'
|
||||
import backendPrefixes from './standalone/backend-prefixes.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).
|
||||
* into the build (the quickstart container image). The image starts it through
|
||||
* standalone/qqq-server.mjs, which adds the dashboard security headers (QRun-IO/qqq#734).
|
||||
*/
|
||||
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']
|
||||
/**
|
||||
* Backend route prefixes the frontend calls; forwarded by the standalone server, which
|
||||
* leaves the backend's responses to them without the dashboard's headers.
|
||||
*/
|
||||
const BACKEND_PREFIXES: string[] = backendPrefixes
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
|
||||
+17
-11
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0-RC.11",
|
||||
"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",
|
||||
"build:jar": "pnpm build:export && mvn -B -q clean package",
|
||||
"start": "next start",
|
||||
"lint": "eslint src && node scripts/check-license-headers.mjs",
|
||||
"lint:jsdoc": "eslint src --ext .ts,.tsx",
|
||||
@@ -20,6 +20,7 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:acceptance": "node scripts/acceptance.mjs",
|
||||
"perf:budget": "node scripts/perf-budget.mjs",
|
||||
"tsc": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "git config core.hooksPath .githooks || true",
|
||||
@@ -27,8 +28,14 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "^2.15.0",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/lang-sql": "6.9.0",
|
||||
"@codemirror/language": "6.11.2",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -48,23 +55,22 @@
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.485.0",
|
||||
"next": "16.3.6",
|
||||
"oidc-client-ts": "^3.4.1",
|
||||
"rapidoc": "9.3.8",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "15.6.6",
|
||||
"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",
|
||||
"@playwright/test": "1.64.0-alpha-2026-10-01",
|
||||
"@storybook/addon-docs": "10.6.0",
|
||||
"@storybook/nextjs": "10.6.0",
|
||||
"@storybook/nextjs-vite": "10.6.0",
|
||||
"@storybook/react": "10.6.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -76,8 +82,8 @@
|
||||
"@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",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/ui": "^4.1.11",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "16.3.6",
|
||||
@@ -91,7 +97,7 @@
|
||||
"storybook": "10.6.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. Raise a cap only with a measured reason in the commit. SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB), so that route's cap is 355 KB.",
|
||||
"firstLoadJsGzipKB": 350,
|
||||
"routes": {
|
||||
"/": 255,
|
||||
"/404": 255,
|
||||
"/404.html": 255,
|
||||
"/_not-found": 255,
|
||||
"/callback": 255,
|
||||
"/login": 255,
|
||||
"/token": 255,
|
||||
"/app/_": 350,
|
||||
"/app/_/_": 350,
|
||||
"/app/_/_/copy": 390,
|
||||
"/app/_/_/edit": 350,
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350,
|
||||
"/app/_/_/dev": 355
|
||||
},
|
||||
"cssGzipKB": 21,
|
||||
"totalJsGzipKB": 1050,
|
||||
"largestChunkGzipKB": 100,
|
||||
"lazyChunks": {
|
||||
"rapidoc": {
|
||||
"$comment": "RapiDoc (the table developer view's API docs, QRun-IO/qqq#714) loads only when that view opens: import('rapidoc') in TableApiDocs. Measured 204.5 KB gzip at 1.0; budget about 5% above.",
|
||||
"marker": "customElements.define(\"rapi-doc\"",
|
||||
"gzipKB": 215
|
||||
},
|
||||
"sqlWorkbench": {
|
||||
"$comment": "CodeMirror 6 (state, view, language, autocomplete, lang-sql) plus the SQL Workbench widget, loaded when the SQL_WORKBENCH widget renders: import() in WidgetRenderer. Measured 127.2 KB gzip at introduction; budget about 6% above.",
|
||||
"marker": "qqq-sql-workbench-editor",
|
||||
"gzipKB": 135
|
||||
},
|
||||
"sqlFormatter": {
|
||||
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
|
||||
"marker": "logicalOperatorNewline",
|
||||
"gzipKB": 65
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
|
||||
|
||||
<!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
|
||||
+19
-7
@@ -1,15 +1,25 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
// Mocked e2e suite: every API call is answered by page.route mocks (tests/e2e/api-mocks.ts),
|
||||
// so no backend runs.
|
||||
// - QQQ_E2E_PORT: the frontend port (default 3000).
|
||||
// - QQQ_E2E_SERVER=production: serve the standalone build from `pnpm build` (what CI runs:
|
||||
// no on-demand compilation, so no first-hit timeouts); otherwise `pnpm dev`.
|
||||
const port = Number(process.env.QQQ_E2E_PORT || 3000)
|
||||
const baseURL = `http://127.0.0.1:${port}`
|
||||
const production = process.env.QQQ_E2E_SERVER === 'production'
|
||||
|
||||
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',
|
||||
// No retries: a test that needs one is flaky and must be fixed, not re-run.
|
||||
retries: 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
baseURL,
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
@@ -18,8 +28,10 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3000',
|
||||
command: production ? 'node scripts/e2e-server.mjs' : `pnpm dev --port ${port}`,
|
||||
url: baseURL,
|
||||
env: { QQQ_E2E_PORT: String(port) },
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
})
|
||||
Generated
+1354
-4038
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>0.2.0</revision>
|
||||
<revision>1.0.0-RC.11</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
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.
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
# Legacy Material Icons fallback
|
||||
|
||||
These unmodified upstream files are distributed under the adjacent Apache-2.0 LICENSE.
|
||||
Source: https://github.com/google/material-design-icons/tree/bd8cb85bd4bad964fe6918f79665bb40c3a8efef/font
|
||||
|
||||
- MaterialIcons-Regular.ttf SHA-256: ef149f08bdd2ff09a4e2c8573476b7b0f3fbb15b623954ade59899e7175bedda
|
||||
- MaterialIcons-Regular.codepoints SHA-256: 530f25bf7b2d71c8e1da9476d53f9a9bb6b7e187bff69bb7128bb679b8194894
|
||||
|
||||
The codepoint JSON in src/lib/utils/material-icon-codepoints.json maps every line of the upstream inventory to its Unicode character. Existing Lucide mappings take precedence. This font supplies only valid names that have no existing mapping; it does not replace Next UI's icon design or use Google Fonts at runtime.
|
||||
|
||||
The inventory contains 2,235 rows and 2,234 unique names: `flourescent` appears twice with identical glyph outlines. The JSON retains the last entry for that name.
|
||||
@@ -9,16 +9,16 @@
|
||||
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'
|
||||
import { ACCEPTANCE_MODE, EXPORT_CLASSPATH, resolveFixtureClasspath } from './acceptance-paths.mjs'
|
||||
|
||||
const port = process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'
|
||||
const jar = resolveSampleJar()
|
||||
const fixtureClasspath = resolveFixtureClasspath()
|
||||
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' })
|
||||
const javac = spawnSync('javac', ['-proc:none', '-sourcepath', '', '-encoding', 'UTF-8', '-cp', fixtureClasspath, '-d', classes, ...sources], { stdio: 'inherit' })
|
||||
if (javac.status !== 0) {
|
||||
console.error('Acceptance fixture compilation failed.')
|
||||
process.exit(1)
|
||||
@@ -29,9 +29,10 @@ const server = spawn('java', [
|
||||
'-Dqqq.sample.mockAuthentication=true',
|
||||
'-Dqqq.sample.sharing=true',
|
||||
`-Dqqq.sample.port=${port}`,
|
||||
`-Dqqq.sample.esb.port=${process.env.QQQ_ACCEPTANCE_ESB_PORT ?? '61616'}`,
|
||||
'-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),
|
||||
'-cp', [...(ACCEPTANCE_MODE === 'javalin' ? [EXPORT_CLASSPATH] : []), classes, fixtureClasspath].join(path.delimiter),
|
||||
...(ACCEPTANCE_MODE === 'javalin' ? ['-Dqqq.javalin.frontend=next'] : ['-Dqqq.javalin.frontend=none']),
|
||||
'AcceptanceSampleServer',
|
||||
], { stdio: 'inherit' })
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
* 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.
|
||||
// Serves the production standalone build exactly as the published image does: through
|
||||
// standalone/qqq-server.mjs, which adds the dashboard security headers (QRun-IO/qqq#734).
|
||||
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'
|
||||
import { assertExplicitCspSources } from './acceptance-standalone.mjs'
|
||||
|
||||
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
|
||||
const standalone = path.resolve('.next/standalone')
|
||||
@@ -18,12 +20,45 @@ if (!existsSync(path.join(standalone, 'server.js')) || !existsSync(BUILD_MARKER)
|
||||
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'], {
|
||||
// Use the JS copy path for owner-readable files on Docker Desktop mounts.
|
||||
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'branding-assets.mjs', 'backend-prefixes.json']) {
|
||||
cpSync(path.resolve('standalone', file), path.join(standalone, file))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the fixture's explicit owned frame/image/media overrides. Application
|
||||
* metadata sources must arrive through the production metadata contract.
|
||||
*
|
||||
* @returns {Promise<string>} The sources, once the acceptance backend is up.
|
||||
*/
|
||||
async function dashboardCspSources() {
|
||||
const deadline = Date.now() + 120_000
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${backend}/acceptance/dashboard-csp-sources`)
|
||||
if (response.ok) return (await response.text()).trim()
|
||||
} catch {
|
||||
// the backend is still starting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
throw new Error(`The acceptance backend at ${backend} did not answer /acceptance/dashboard-csp-sources.`)
|
||||
}
|
||||
|
||||
const sources = await dashboardCspSources()
|
||||
assertExplicitCspSources(sources)
|
||||
const server = spawn('node', ['qqq-server.mjs'], {
|
||||
cwd: standalone,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_ENV: 'production', HOSTNAME: '127.0.0.1', PORT: process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? '13765' },
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
HOSTNAME: '127.0.0.1',
|
||||
PORT: process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? '13765',
|
||||
QQQ_DASHBOARD_CSP_SOURCES: sources,
|
||||
},
|
||||
})
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
|
||||
server.on('exit', (code) => process.exit(code ?? 1))
|
||||
@@ -71,6 +71,27 @@ for (const row of matrix.rows) {
|
||||
} else summary.passed++
|
||||
}
|
||||
|
||||
// Phone coverage (QRun-IO/qqq#708): when a touch project ran (mobile: phone, tablet: iPad), every
|
||||
// required row passes there too, unless the row names why it is not a touch scenario (`desktopOnly`).
|
||||
const touchProjects = ['mobile', 'tablet'].filter((project) => byProject[project])
|
||||
const phone = { covered: 0, desktopOnly: 0, uncovered: 0 }
|
||||
if (touchProjects.length) for (const row of matrix.rows) {
|
||||
if (row.required === false) continue
|
||||
if (row.desktopOnly !== undefined) {
|
||||
if (typeof row.desktopOnly !== 'string' || !row.desktopOnly.trim()) problems.push(`${row.id} desktopOnly needs the reason it is not a touch scenario`)
|
||||
phone.desktopOnly++
|
||||
continue
|
||||
}
|
||||
const list = results.get(row.id) ?? []
|
||||
if (partial && !list.length) continue
|
||||
const missing = touchProjects.filter((project) => !list.some((result) => result.project === project && result.outcome === 'passed'))
|
||||
if (missing.length) {
|
||||
phone.uncovered++
|
||||
problems.push(`${row.id} ${row.feature}: no passing @mobile test in ${missing.join(', ')}`)
|
||||
} else phone.covered++
|
||||
}
|
||||
if (touchProjects.length) summary.phone = phone
|
||||
|
||||
// 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`)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
/** Sample JAR from QQQ_SAMPLE_JAR, else the newest jar-with-dependencies in a sibling qqq checkout. */
|
||||
@@ -23,9 +24,27 @@ export function resolveSampleJar() {
|
||||
return path.join(target, jars[0])
|
||||
}
|
||||
|
||||
/** Java dependencies shared by the main and authentication-variant fixtures. */
|
||||
export function resolveFixtureClasspath(jar = resolveSampleJar()) {
|
||||
const sampleVersion = path.basename(jar).match(/^qqq-sample-project-(.+)-jar-with-dependencies\.jar$/)?.[1]
|
||||
if (!sampleVersion) throw new Error(`Cannot determine QQQ version from sample JAR: ${jar}`)
|
||||
const apiJar = process.env.QQQ_MIDDLEWARE_API_JAR ?? path.join(homedir(), '.m2', 'repository', 'com', 'kingsrook', 'qqq',
|
||||
'qqq-middleware-api', sampleVersion, `qqq-middleware-api-${sampleVersion}.jar`)
|
||||
if (!existsSync(apiJar)) throw new Error(`QQQ middleware API JAR does not exist: ${apiJar}`)
|
||||
return [jar, apiJar].join(path.delimiter)
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
/** Separate backend, browser and provider origins for authentication variants. */
|
||||
export const SECURITY_BACKEND_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT ?? Number(process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? 18765) + 10)
|
||||
export const SECURITY_FRONTEND_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_FRONTEND_PORT ?? Number(process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? 13765) + 20)
|
||||
export const SECURITY_IDP_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_IDP_PORT ?? Number(process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? 13765) + 10)
|
||||
export const SECURITY_BACKEND_URL = `http://127.0.0.1:${SECURITY_BACKEND_PORT}`
|
||||
export const SECURITY_UI_URL = ACCEPTANCE_MODE === 'standalone' ? `http://127.0.0.1:${SECURITY_FRONTEND_PORT}` : SECURITY_BACKEND_URL
|
||||
export const SECURITY_STANDALONE = path.resolve('test-results/acceptance/security-standalone')
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/** @file Stages and validates an immutable standalone build for an authentication backend. */
|
||||
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
const prefixes = createRequire(path.resolve('package.json'))('./standalone/backend-prefixes.json')
|
||||
|
||||
/** Rejects a missing or differently routed build, regardless of any retained Javalin export. */
|
||||
export function assertStandaloneTarget(directory, backend) {
|
||||
if (!existsSync(path.join(directory, 'server.js')) || !existsSync(path.join(directory, 'qqq-server.mjs'))
|
||||
|| readFileSync(path.join(directory, 'acceptance-backend.txt'), 'utf8').trim() !== backend) {
|
||||
throw new Error(`No standalone security build for ${backend}; run acceptance without --skip-build.`)
|
||||
}
|
||||
const { rewrites } = JSON.parse(readFileSync(path.join(directory, '.next/routes-manifest.json'), 'utf8'))
|
||||
const routes = Array.isArray(rewrites) ? rewrites : [...rewrites.beforeFiles, ...rewrites.afterFiles, ...rewrites.fallback]
|
||||
for (const prefix of prefixes) {
|
||||
if (!routes.some(({ source, destination }) => source === `/${prefix}/:path*` && destination === `${backend}/${prefix}/:path*`)) {
|
||||
throw new Error(`Standalone security build has the wrong backend rewrite for ${prefix}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the same standalone payload as the container image, before the next clean build. */
|
||||
export function stageStandalone(directory, backend, source = process.cwd()) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
// The JS copy path preserves readable files on Docker Desktop mounts; relative links
|
||||
// must remain relative so deleting the original build cannot invalidate this stage.
|
||||
cpSync(path.join(source, '.next/standalone'), directory, { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.join(source, '.next/static'), path.join(directory, '.next/static'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.join(source, 'public'), path.join(directory, 'public'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'branding-assets.mjs', 'backend-prefixes.json']) {
|
||||
cpSync(path.join(source, 'standalone', file), path.join(directory, file))
|
||||
}
|
||||
writeFileSync(path.join(directory, 'acceptance-backend.txt'), `${backend}\n`)
|
||||
assertStandaloneTarget(directory, backend)
|
||||
}
|
||||
|
||||
/** Prevents the fixture from supplying the metadata admissions the production wrapper must derive. */
|
||||
export function assertExplicitCspSources(text) {
|
||||
for (const part of text.split(';')) {
|
||||
const [directive, ...sources] = part.trim().split(/\s+/).filter(Boolean)
|
||||
if (!directive) continue
|
||||
if (!['frame-src', 'img-src', 'media-src'].includes(directive) || sources.length !== 1) {
|
||||
throw new Error('Only explicit owned-service CSP overrides are allowed')
|
||||
}
|
||||
let origin
|
||||
try { origin = new URL(sources[0]) } catch { /* rejected below */ }
|
||||
if (!origin || !['http:', 'https:'].includes(origin.protocol) || origin.origin !== sources[0] || sources[0].includes('*')) {
|
||||
throw new Error('Only explicit owned-service CSP overrides are allowed')
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -10,7 +10,9 @@
|
||||
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'
|
||||
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar, SECURITY_BACKEND_URL, SECURITY_STANDALONE } from './acceptance-paths.mjs'
|
||||
|
||||
import { stageStandalone } from './acceptance-standalone.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const skipBuild = args.includes('--skip-build')
|
||||
@@ -23,6 +25,10 @@ function run(command, commandArgs, env = {}) {
|
||||
return result.status ?? 1
|
||||
}
|
||||
|
||||
// An acceptance build starts from a clean .next: a Turbopack build reusing its cache after a
|
||||
// merge was seen to emit stale CSS (the touch-target rules of globals.css were missing), which
|
||||
// would test something other than the source.
|
||||
if (!skipBuild) rmSync('.next', { recursive: true, force: true })
|
||||
if (!skipBuild && ACCEPTANCE_MODE === 'javalin') {
|
||||
const status = run('pnpm', ['build:export'], { NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (status !== 0) process.exit(status)
|
||||
@@ -34,7 +40,12 @@ if (ACCEPTANCE_MODE === 'javalin') {
|
||||
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' })
|
||||
// A second immutable build is required: Next bakes the rewrite destination at build time.
|
||||
const securityStatus = run('pnpm', ['build'], { QQQ_NEXT_OUTPUT: 'standalone', QQQ_BACKEND_URL: SECURITY_BACKEND_URL, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (securityStatus !== 0) process.exit(securityStatus)
|
||||
stageStandalone(SECURITY_STANDALONE, SECURITY_BACKEND_URL)
|
||||
rmSync('.next', { recursive: true, force: true })
|
||||
const status = run('pnpm', ['build'], { QQQ_NEXT_OUTPUT: 'standalone', QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (status !== 0) process.exit(status)
|
||||
writeFileSync(BUILD_MARKER, backend + '\n')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 (`pnpm build`) for the mocked e2e suite. No backend
|
||||
// runs: the specs answer every API call with page.route mocks (tests/e2e/api-mocks.ts).
|
||||
import { spawn } from 'node:child_process'
|
||||
import { cpSync, existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const standalone = path.resolve('.next/standalone')
|
||||
if (!existsSync(path.join(standalone, 'server.js'))) {
|
||||
console.error('No standalone build in .next/standalone; run `pnpm build` first.')
|
||||
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_E2E_PORT ?? '3000' },
|
||||
})
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
|
||||
server.on('exit', (code) => process.exit(code ?? 1))
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
// Bundle budget for the static export (QRun-IO/qqq#710). Run after `pnpm build:export`:
|
||||
//
|
||||
// node scripts/perf-budget.mjs # check out/ against perf-budget.json, exit 1 on a breach
|
||||
// node scripts/perf-budget.mjs --json # also print the measurement as JSON
|
||||
//
|
||||
// First-load JS of a route is every script the route's HTML loads before hydration (script src
|
||||
// and preload links), gzip-compressed the way the server sends it. Next 16 no longer prints these
|
||||
// sizes, so they are measured from out/. The report lands in test-results/performance/bundle.json.
|
||||
//
|
||||
// A library that only one screen loads on demand (RapiDoc for the table API docs, QRun-IO/qqq#714)
|
||||
// is budgeted on its own under `lazyChunks`: its chunk is found by a marker in its code, must not be
|
||||
// part of any route's first load, must stay within its own budget, and is left out of the total and
|
||||
// largest-chunk budgets, which keep measuring the rest of the application.
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const OUT = path.resolve(process.env.QQQ_EXPORT_DIR ?? 'out')
|
||||
const BUDGET_FILE = path.resolve('perf-budget.json')
|
||||
const REPORT = path.resolve('test-results/performance/bundle.json')
|
||||
|
||||
/** Recursively lists files under a directory. */
|
||||
function walk(dir) {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const file = path.join(dir, name)
|
||||
return statSync(file).isDirectory() ? walk(file) : [file]
|
||||
})
|
||||
}
|
||||
|
||||
const sizeCache = new Map()
|
||||
/** Raw and gzip size (bytes) of an exported asset. */
|
||||
function sizes(file) {
|
||||
if (!sizeCache.has(file)) {
|
||||
const bytes = readFileSync(file)
|
||||
sizeCache.set(file, { raw: bytes.length, gzip: gzipSync(bytes, { level: 9 }).length })
|
||||
}
|
||||
return sizeCache.get(file)
|
||||
}
|
||||
|
||||
/** The route an exported HTML file serves ("_" is the placeholder of a dynamic segment). */
|
||||
function routeOf(file) {
|
||||
const rel = path.relative(OUT, file).split(path.sep).join('/')
|
||||
if (rel === 'index.html') return '/'
|
||||
return '/' + rel.replace(/\/index\.html$/, '')
|
||||
}
|
||||
|
||||
const kb = (bytes) => Math.round((bytes / 1024) * 10) / 10
|
||||
|
||||
/** Measures every exported route. */
|
||||
function measure(lazyBudgets = {}) {
|
||||
const files = walk(OUT)
|
||||
const html = files.filter((f) => f.endsWith('.html'))
|
||||
const routes = html.map((file) => {
|
||||
const text = readFileSync(file, 'utf8')
|
||||
const refs = new Set()
|
||||
for (const match of text.matchAll(/(?:src|href)="(\/_next\/static\/[^"?#]+\.(?:js|css))"/g)) refs.add(match[1])
|
||||
const js = [...refs].filter((r) => r.endsWith('.js'))
|
||||
const css = [...refs].filter((r) => r.endsWith('.css'))
|
||||
const sum = (list, key) => list.reduce((total, ref) => total + sizes(path.join(OUT, decodeURIComponent(ref)))[key], 0)
|
||||
return {
|
||||
route: routeOf(file),
|
||||
scripts: js.length,
|
||||
firstLoadJsGzipKB: kb(sum(js, 'gzip')),
|
||||
firstLoadJsRawKB: kb(sum(js, 'raw')),
|
||||
cssGzipKB: kb(sum(css, 'gzip')),
|
||||
htmlGzipKB: kb(sizes(file).gzip),
|
||||
}
|
||||
}).sort((a, b) => a.route.localeCompare(b.route))
|
||||
|
||||
const chunks = files.filter((f) => f.includes(`${path.sep}_next${path.sep}static${path.sep}`) && f.endsWith('.js'))
|
||||
const firstLoad = new Set(html.flatMap((file) => [...readFileSync(file, 'utf8').matchAll(/(?:src|href)="\/(_next\/static\/[^"?#]+\.js)"/g)].map((m) => decodeURIComponent(m[1]))))
|
||||
const lazy = Object.entries(lazyBudgets).map(([name, { marker }]) => {
|
||||
const matched = chunks.filter((f) => readFileSync(f, 'utf8').includes(marker)).map((f) => path.relative(OUT, f).split(path.sep).join('/'))
|
||||
return {
|
||||
name,
|
||||
chunks: matched,
|
||||
gzipKB: kb(matched.reduce((t, c) => t + sizes(path.join(OUT, c)).gzip, 0)),
|
||||
inFirstLoad: matched.filter((c) => firstLoad.has(c)),
|
||||
}
|
||||
})
|
||||
const lazyFiles = new Set(lazy.flatMap((entry) => entry.chunks))
|
||||
const largest = chunks.map((f) => ({ file: path.relative(OUT, f).split(path.sep).join('/'), ...sizes(f) }))
|
||||
.filter((c) => !lazyFiles.has(c.file))
|
||||
.sort((a, b) => b.gzip - a.gzip)
|
||||
return {
|
||||
routes,
|
||||
lazy,
|
||||
totalJsGzipKB: kb(largest.reduce((t, c) => t + c.gzip, 0)),
|
||||
totalJsRawKB: kb(largest.reduce((t, c) => t + c.raw, 0)),
|
||||
chunkCount: largest.length,
|
||||
largestChunks: largest.slice(0, 5).map((c) => ({ file: c.file, gzipKB: kb(c.gzip), rawKB: kb(c.raw) })),
|
||||
}
|
||||
}
|
||||
|
||||
/** Compares a measurement with the budget; returns the breaches. */
|
||||
function check(result, budget) {
|
||||
const problems = []
|
||||
const over = (label, actual, limit) => {
|
||||
if (actual > limit) problems.push(`${label}: ${actual} KB > budget ${limit} KB`)
|
||||
}
|
||||
for (const route of result.routes) {
|
||||
const limit = budget.routes?.[route.route] ?? budget.firstLoadJsGzipKB
|
||||
over(`${route.route} first-load JS (gzip)`, route.firstLoadJsGzipKB, limit)
|
||||
over(`${route.route} CSS (gzip)`, route.cssGzipKB, budget.cssGzipKB)
|
||||
}
|
||||
for (const route of Object.keys(budget.routes ?? {})) {
|
||||
if (!result.routes.some((r) => r.route === route)) problems.push(`${route}: budgeted route missing from the export`)
|
||||
}
|
||||
for (const entry of result.lazy) {
|
||||
const limit = budget.lazyChunks[entry.name].gzipKB
|
||||
if (!entry.chunks.length) problems.push(`lazy chunk ${entry.name}: no chunk contains its marker (update the marker, or remove the entry)`)
|
||||
for (const chunk of entry.inFirstLoad) problems.push(`lazy chunk ${entry.name}: ${chunk} is loaded on first load of a route`)
|
||||
over(`lazy chunk ${entry.name} (gzip)`, entry.gzipKB, limit)
|
||||
}
|
||||
over('total JS (gzip)', result.totalJsGzipKB, budget.totalJsGzipKB)
|
||||
over(`largest chunk ${result.largestChunks[0]?.file} (gzip)`, result.largestChunks[0]?.gzipKB ?? 0, budget.largestChunkGzipKB)
|
||||
return problems
|
||||
}
|
||||
|
||||
const budget = JSON.parse(readFileSync(BUDGET_FILE, 'utf8'))
|
||||
const result = measure(budget.lazyChunks)
|
||||
if (!result.routes.length) {
|
||||
console.error(`No exported HTML under ${OUT}; run \`pnpm build:export\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const problems = check(result, budget)
|
||||
|
||||
console.log('Route scripts first-load JS gzip (raw) CSS gzip')
|
||||
for (const r of result.routes) {
|
||||
const limit = budget.routes?.[r.route] ?? budget.firstLoadJsGzipKB
|
||||
console.log(`${r.route.padEnd(30)} ${String(r.scripts).padStart(7)} ${`${r.firstLoadJsGzipKB} KB (${r.firstLoadJsRawKB} KB)`.padStart(20)} / ${limit} KB ${r.cssGzipKB} KB`)
|
||||
}
|
||||
console.log(`Total JS: ${result.totalJsGzipKB} KB gzip (${result.totalJsRawKB} KB raw) in ${result.chunkCount} chunks; budget ${budget.totalJsGzipKB} KB`)
|
||||
console.log(`Largest chunks: ${result.largestChunks.map((c) => `${c.file} ${c.gzipKB} KB`).join(', ')}; budget ${budget.largestChunkGzipKB} KB`)
|
||||
for (const entry of result.lazy) {
|
||||
console.log(`Lazy chunk ${entry.name}: ${entry.gzipKB} KB gzip in ${entry.chunks.join(', ') || 'no chunk'}; budget ${budget.lazyChunks[entry.name].gzipKB} KB; first load: ${entry.inFirstLoad.length ? entry.inFirstLoad.join(', ') : 'none'}`)
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(REPORT), { recursive: true })
|
||||
writeFileSync(REPORT, JSON.stringify({ budget, result, problems }, null, 2) + '\n')
|
||||
if (process.argv.includes('--json')) console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\nPerformance budget exceeded (${problems.length}):\n- ${problems.join('\n- ')}`)
|
||||
console.error('Reduce the bundle, or raise the budget in perf-budget.json with the reason in the commit.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nPerformance budget: OK')
|
||||
@@ -24,6 +24,7 @@ import React, { useEffect, useRef } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { saveProviderError } from '@/lib/auth/callback-errors'
|
||||
import { PKCE_STORAGE } from '@/lib/auth/oidc'
|
||||
import { safeReturnTo } from '@/lib/auth/return-to'
|
||||
|
||||
@@ -50,11 +51,20 @@ export default function CallbackContent() {
|
||||
const returnTo = safeReturnTo(sessionStorage.getItem(PKCE_STORAGE.returnTo))
|
||||
const toLogin = (reason: string) => {
|
||||
sessionStorage.removeItem(PKCE_STORAGE.verifier)
|
||||
sessionStorage.removeItem(PKCE_STORAGE.state)
|
||||
sessionStorage.removeItem(PKCE_STORAGE.redirectUri)
|
||||
sessionStorage.removeItem(PKCE_STORAGE.returnTo)
|
||||
router.replace(`/login?error=${encodeURIComponent(reason)}&returnTo=${encodeURIComponent(returnTo)}`)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.warn('[Callback] Identity provider error:', error, searchParams.get('error_description') ?? '')
|
||||
if (!state || state !== sessionStorage.getItem(PKCE_STORAGE.state)) {
|
||||
console.warn('[Callback] Identity provider error with an invalid state:', error)
|
||||
toLogin('callback_failed')
|
||||
return
|
||||
}
|
||||
saveProviderError(error, searchParams.get('error_description'))
|
||||
console.warn('[Callback] Identity provider error:', error)
|
||||
toLogin(error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the login page: TABLE_BASED password sign-in (QRun-IO/qqq#700) and
|
||||
// pre-sign-in branding (QRun-IO/qqq#703)
|
||||
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import type { AuthContextType } from '@/lib/auth/auth-provider'
|
||||
import type { QAuthenticationMetaData } from '@/types'
|
||||
|
||||
const authState: { value: AuthContextType } = { value: {} as AuthContextType }
|
||||
vi.mock('@/lib/auth/use-auth', () => ({ useAuth: () => authState.value }))
|
||||
|
||||
import LoginPage from './page'
|
||||
|
||||
const BRANDING = { appName: 'QQQ Sample', logo: '/samples-logo.png', icon: '/kr-icon.png', accentColor: '#1d4ed8' }
|
||||
|
||||
/**
|
||||
* Sets the auth context the page sees.
|
||||
*
|
||||
* @param metadata - Authentication metadata.
|
||||
* @param overrides - Other context values.
|
||||
*/
|
||||
function withAuth(metadata: QAuthenticationMetaData | null, overrides: Partial<AuthContextType> = {}) {
|
||||
authState.value = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
authMetadata: metadata,
|
||||
authError: null,
|
||||
isSignedOut: false,
|
||||
signIn: vi.fn(async () => {}),
|
||||
signInWithPassword: vi.fn(async () => {}),
|
||||
logout: vi.fn(async () => {}),
|
||||
handleOAuthCallback: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const tableBased: QAuthenticationMetaData = { name: 'tableBased', type: 'TABLE_BASED', branding: BRANDING }
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = '<link rel="icon" href="/favicon.ico">'
|
||||
})
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute('style')
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
it('asks for a username and password under the application branding, without signing in automatically', async () => {
|
||||
withAuth(tableBased)
|
||||
render(<LoginPage />)
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Username')).toHaveAttribute('autocomplete', 'username')
|
||||
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password')
|
||||
expect(document.querySelector('[data-qqq-id="login-logo"]')).toHaveAttribute('src', '/samples-logo.png')
|
||||
expect(screen.getByText('QQQ Sample')).toHaveAttribute('data-qqq-id', 'login-app-name')
|
||||
await waitFor(() => expect(document.documentElement.style.getPropertyValue('--primary')).toBe('#1d4ed8'))
|
||||
expect(document.querySelector<HTMLLinkElement>("link[rel='icon']")?.getAttribute('href')).toBe('/kr-icon.png')
|
||||
expect(document.title).toBe('Sign in | QQQ Sample')
|
||||
expect(authState.value.signIn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the missing fields and sends nothing', () => {
|
||||
withAuth(tableBased)
|
||||
render(<LoginPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('Enter your username.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Enter your password.')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Username')).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByLabelText('Username')).toHaveAccessibleDescription('Enter your username.')
|
||||
expect(screen.getByLabelText('Username')).toHaveFocus()
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'a:b' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'x' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(screen.getByText('A username cannot contain a colon.')).toBeInTheDocument()
|
||||
expect(authState.value.signInWithPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('signs in with the typed credentials and clears the password afterwards', async () => {
|
||||
withAuth(tableBased)
|
||||
render(<LoginPage />)
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: ' tess.table ' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'table:pass-2026' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
await waitFor(() => expect(authState.value.signInWithPassword).toHaveBeenCalledWith('tess.table', 'table:pass-2026'))
|
||||
await waitFor(() => expect(screen.getByLabelText('Password')).toHaveValue(''))
|
||||
expect(screen.getByLabelText('Username')).toHaveValue(' tess.table ')
|
||||
})
|
||||
|
||||
it('shows a refused sign-in in the form and after logout says so', () => {
|
||||
withAuth(tableBased, { authError: 'Sign-in was denied: Incorrect username or password.' })
|
||||
const { unmount } = render(<LoginPage />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Sign-in was denied: Incorrect username or password.')
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument()
|
||||
unmount()
|
||||
withAuth(tableBased, { isSignedOut: true })
|
||||
render(<LoginPage />)
|
||||
expect(screen.getByRole('heading', { name: 'You have signed out' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Username')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no accessibility violations', async () => {
|
||||
withAuth(tableBased)
|
||||
const { container } = render(<LoginPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('brands the other sign-in states too and drops an unsafe logo', () => {
|
||||
withAuth({ name: 'mock', type: 'MOCK', branding: { appName: 'QQQ Sample', logo: 'javascript:alert(1)' } }, { isSignedOut: true })
|
||||
render(<LoginPage />)
|
||||
expect(screen.getByRole('heading', { name: 'You have signed out' })).toBeInTheDocument()
|
||||
expect(screen.getByText('QQQ Sample')).toBeInTheDocument()
|
||||
expect(document.querySelector('[data-qqq-id="login-logo"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders without branding when the backend declares none', () => {
|
||||
withAuth({ name: 'mock', type: 'MOCK' }, { isSignedOut: true })
|
||||
render(<LoginPage />)
|
||||
expect(document.querySelector('[data-qqq-id="login-branding"]')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+173
-32
@@ -23,40 +23,165 @@
|
||||
// States:
|
||||
// signing in — first visit or an expired session: sign in automatically (anonymous
|
||||
// types create a session; OAUTH2 / AUTH_0 redirect to the provider).
|
||||
// password — TABLE_BASED: ask for a username and password (first visit, after an
|
||||
// expired session and after logout); a refused sign-in is shown in the form.
|
||||
// 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.
|
||||
// Every state shows the application's pre-sign-in branding (logo, app name, accent) from
|
||||
// GET /metaData/authentication (QRun-IO/qqq#703).
|
||||
// Wrapped in Suspense because it uses useSearchParams().
|
||||
|
||||
import React, { Suspense, useEffect, useRef } from 'react'
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import type { QLoginBranding } from '@/types'
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
import { recordReauthAttempt, resetReauthAttempts } from '@/lib/auth/auth-storage'
|
||||
import { safeReturnTo } from '@/lib/auth/return-to'
|
||||
import { callbackErrorMessage, consumeProviderError } from '@/lib/auth/callback-errors'
|
||||
import { useDocumentTitle } from '@/lib/hooks/use-document-title'
|
||||
import { applyBrandingTheme, isSafeImageSource } from '@/lib/theme/apply-branding'
|
||||
|
||||
/** Messages for provider callback errors passed back as `?error=`. */
|
||||
const CALLBACK_ERRORS: Record<string, string> = {
|
||||
access_denied: 'Sign-in was denied by the identity provider.',
|
||||
callback_failed: 'Sign-in could not be completed.',
|
||||
login_required: 'The identity provider requires you to sign in again.',
|
||||
const BUTTON_CLASS = 'inline-flex items-center justify-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 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
const INPUT_CLASS = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring aria-[invalid=true]:border-destructive'
|
||||
|
||||
/**
|
||||
* The application's logo and name, shown before sign-in.
|
||||
*
|
||||
* @param props - The branding.
|
||||
* @param props.branding - Pre-sign-in branding from the authentication metadata.
|
||||
* @returns The branding block, or null when the backend declares none.
|
||||
*/
|
||||
function LoginBrand({ branding }: { branding?: QLoginBranding }) {
|
||||
const appName = branding?.appName?.trim()
|
||||
const logo = isSafeImageSource(branding?.logo) ? branding?.logo : undefined
|
||||
if (!appName && !logo) return null
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2" data-qqq-id="login-branding">
|
||||
{logo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- branding logos are arbitrary backend assets
|
||||
<img src={logo} alt={appName ? '' : branding?.companyName?.trim() || 'Application logo'} className="max-h-16 max-w-full object-contain" data-qqq-id="login-logo" />
|
||||
)}
|
||||
{appName && <p className="text-lg font-semibold text-foreground" data-qqq-id="login-app-name">{appName}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Card wrapper shared by every login state.
|
||||
*
|
||||
* @param props - The card contents.
|
||||
* @param props.branding - Pre-sign-in branding shown at the top of the card.
|
||||
* @param props.children - Card body.
|
||||
* @returns The card.
|
||||
*/
|
||||
function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
function LoginCard({ branding, children }: { branding?: QLoginBranding; 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 className="flex flex-col items-center gap-4 text-center">
|
||||
<LoginBrand branding={branding} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Props for {@link PasswordSignIn}. */
|
||||
interface PasswordSignInProps {
|
||||
/** Card heading. */
|
||||
heading: string
|
||||
/** Optional line under the heading. */
|
||||
message?: string
|
||||
/** Why the last sign-in was refused, if it was. */
|
||||
error: string | null
|
||||
/** Signs in; failures come back through `error`. */
|
||||
onSubmit: (username: string, password: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Username and password form for TABLE_BASED authentication. The password is read from
|
||||
* the form on submit and cleared after every attempt; it is never kept in state.
|
||||
*
|
||||
* @param props - See {@link PasswordSignInProps}.
|
||||
* @returns The form.
|
||||
*/
|
||||
function PasswordSignIn({ heading, message, error, onSubmit }: PasswordSignInProps) {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({})
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const data = new FormData(form)
|
||||
const username = String(data.get('username') ?? '').trim()
|
||||
const password = String(data.get('password') ?? '')
|
||||
const errors = {
|
||||
username: !username ? 'Enter your username.' : username.includes(':') ? 'A username cannot contain a colon.' : undefined,
|
||||
password: !password ? 'Enter your password.' : undefined,
|
||||
}
|
||||
setFieldErrors(errors)
|
||||
if (errors.username || errors.password) {
|
||||
const first = form.elements.namedItem(errors.username ? 'username' : 'password')
|
||||
if (first instanceof HTMLInputElement) first.focus()
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onSubmit(username, password)
|
||||
} finally {
|
||||
const passwordInput = form.elements.namedItem('password')
|
||||
if (passwordInput instanceof HTMLInputElement) passwordInput.value = ''
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-xl font-semibold text-foreground">{heading}</h1>
|
||||
{message && <p className="text-sm text-muted-foreground">{message}</p>}
|
||||
<form className="flex w-full flex-col gap-4 text-left" onSubmit={handleSubmit} noValidate aria-label="Sign in" data-qqq-id="login-form">
|
||||
{error && <p role="alert" className="text-sm text-destructive" data-qqq-id="login-error">{error}</p>}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="login-username" className="text-sm font-medium text-foreground">Username</label>
|
||||
<input
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
aria-required="true"
|
||||
aria-invalid={Boolean(fieldErrors.username)}
|
||||
aria-describedby={fieldErrors.username ? 'login-username-error' : undefined}
|
||||
className={INPUT_CLASS}
|
||||
data-qqq-id="input-login-username"
|
||||
/>
|
||||
{fieldErrors.username && <p id="login-username-error" className="text-sm text-destructive">{fieldErrors.username}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="login-password" className="text-sm font-medium text-foreground">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
aria-required="true"
|
||||
aria-invalid={Boolean(fieldErrors.password)}
|
||||
aria-describedby={fieldErrors.password ? 'login-password-error' : undefined}
|
||||
className={INPUT_CLASS}
|
||||
data-qqq-id="input-login-password"
|
||||
/>
|
||||
{fieldErrors.password && <p id="login-password-error" className="text-sm text-destructive">{fieldErrors.password}</p>}
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} className={BUTTON_CLASS} data-qqq-id="button-sign-in">
|
||||
{submitting ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner login content — reacts to the auth state and the `returnTo` / `error` params.
|
||||
*
|
||||
@@ -65,25 +190,38 @@ function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
function LoginContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { isAuthenticated, isLoading, authMetadata, authError, isSignedOut, signIn } = useAuth()
|
||||
const { isAuthenticated, isLoading, authMetadata, authError, isSignedOut, signIn, signInWithPassword } = useAuth()
|
||||
const autoSignInStarted = useRef(false)
|
||||
const [loopStopped, setLoopStopped] = React.useState(false)
|
||||
const [providerError, setProviderError] = useState<string | null>(null)
|
||||
|
||||
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)
|
||||
const callbackCode = searchParams.get('error')
|
||||
const callbackError = callbackErrorMessage(callbackCode)
|
||||
const errorMessage = authError ?? providerError ?? callbackError ?? (loopStopped ? 'Your session could not be re-established. Sign in again to continue.' : null)
|
||||
const passwordSignIn = authMetadata?.type === 'TABLE_BASED'
|
||||
const branding = authMetadata?.branding
|
||||
const appName = branding?.appName?.trim()
|
||||
|
||||
useEffect(() => {
|
||||
setProviderError(consumeProviderError(callbackCode))
|
||||
}, [callbackCode])
|
||||
|
||||
// Pre-sign-in branding: accent colors and favicon, and the app name in the title.
|
||||
useEffect(() => {
|
||||
applyBrandingTheme(branding)
|
||||
}, [branding])
|
||||
useDocumentTitle(appName ? `Sign in | ${appName}` : undefined)
|
||||
|
||||
// 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.
|
||||
// Sign in automatically unless the user signed out, a failure needs attention, or
|
||||
// the user has to type credentials (TABLE_BASED).
|
||||
useEffect(() => {
|
||||
if (isLoading || isAuthenticated || isSignedOut || errorMessage || !authMetadata) return
|
||||
if (isLoading || isAuthenticated || isSignedOut || errorMessage || !authMetadata || passwordSignIn) return
|
||||
if (autoSignInStarted.current) return
|
||||
autoSignInStarted.current = true
|
||||
if (recordReauthAttempt()) {
|
||||
@@ -91,7 +229,7 @@ function LoginContent() {
|
||||
return
|
||||
}
|
||||
void signIn(returnTo)
|
||||
}, [authMetadata, errorMessage, isAuthenticated, isLoading, isSignedOut, returnTo, signIn])
|
||||
}, [authMetadata, errorMessage, isAuthenticated, isLoading, isSignedOut, passwordSignIn, returnTo, signIn])
|
||||
|
||||
const signInAgain = () => {
|
||||
resetReauthAttempts()
|
||||
@@ -99,17 +237,25 @@ function LoginContent() {
|
||||
void signIn(returnTo)
|
||||
}
|
||||
|
||||
if (!isLoading && !isAuthenticated && passwordSignIn) {
|
||||
return (
|
||||
<LoginCard branding={branding}>
|
||||
<PasswordSignIn
|
||||
heading={isSignedOut ? 'You have signed out' : 'Sign in'}
|
||||
message={isSignedOut ? 'Sign in again to continue.' : undefined}
|
||||
error={errorMessage}
|
||||
onSubmit={signInWithPassword}
|
||||
/>
|
||||
</LoginCard>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && !isAuthenticated && isSignedOut) {
|
||||
return (
|
||||
<LoginCard>
|
||||
<LoginCard branding={branding}>
|
||||
<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"
|
||||
>
|
||||
<button type="button" onClick={signInAgain} className={BUTTON_CLASS} data-qqq-id="button-sign-in">
|
||||
Sign in
|
||||
</button>
|
||||
</LoginCard>
|
||||
@@ -118,15 +264,10 @@ function LoginContent() {
|
||||
|
||||
if (!isLoading && !isAuthenticated && errorMessage) {
|
||||
return (
|
||||
<LoginCard>
|
||||
<LoginCard branding={branding}>
|
||||
<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"
|
||||
>
|
||||
<button type="button" onClick={signInAgain} className={BUTTON_CLASS} data-qqq-id="button-sign-in-retry">
|
||||
Try again
|
||||
</button>
|
||||
</LoginCard>
|
||||
@@ -135,7 +276,7 @@ function LoginContent() {
|
||||
|
||||
const redirecting = authMetadata?.type === 'OAUTH2' || authMetadata?.type === 'AUTH_0'
|
||||
return (
|
||||
<LoginCard>
|
||||
<LoginCard branding={branding}>
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Prerenders this dynamic segment once for the Javalin-served static export.
|
||||
* The server returns that page for every concrete value; see use-route-params.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
|
||||
|
||||
/**
|
||||
* Static export placeholder params for this segment.
|
||||
*
|
||||
* @returns One placeholder entry.
|
||||
*/
|
||||
export function generateStaticParams() {
|
||||
return [{ action: EXPORT_PLACEHOLDER }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through layout that exists to carry generateStaticParams.
|
||||
*
|
||||
* @param props - Layout props.
|
||||
* @param props.children - Route content.
|
||||
* @returns The children unchanged.
|
||||
*/
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Record-scoped process route — `/app/{table}/{id}/{process}`, the Material Dashboard
|
||||
* URL that runs one of the table's processes for a single record.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { processRunHref, tableProcessForSegment } from '@/lib/utils/material-links'
|
||||
import { launchTableName } from '@/lib/utils/process-utils'
|
||||
import { NotFoundState } from '@/components/layout/NotFoundState'
|
||||
import { RouteRedirect } from '@/components/layout/RouteRedirect'
|
||||
|
||||
/**
|
||||
* Opens the named process for the record, returning to the record afterwards, or shows the
|
||||
* in-shell not-found state when the segment is not one of the table's processes.
|
||||
*
|
||||
* @returns A redirect, a loading state or the not-found state.
|
||||
*/
|
||||
export default function RecordProcessPage() {
|
||||
const { slug, recordId, action } = useRouteParams<{ slug: string; recordId: string; action: string }>()
|
||||
const { data: metaData, isError } = useQuery({ queryKey: queryKeys.metadataAll(), queryFn: loadMetaData, staleTime: 1000 * 60 * 30 })
|
||||
|
||||
if (isError) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Failed to load application metadata.</div>
|
||||
}
|
||||
if (!metaData) {
|
||||
return (
|
||||
<div role="status" aria-label="Loading" className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const processName = metaData.tables?.[slug] ? tableProcessForSegment(metaData, slug, action, true) : null
|
||||
if (!processName) return <NotFoundState name={action} />
|
||||
|
||||
const search = typeof window === 'undefined' ? '' : window.location.search
|
||||
const href = processRunHref(processName, {
|
||||
recordId,
|
||||
search,
|
||||
returnTo: `/app/${encodeURIComponent(slug)}/${encodeURIComponent(recordId)}`,
|
||||
tableName: launchTableName(metaData.processes[processName], slug),
|
||||
})
|
||||
return <RouteRedirect href={href} label={metaData.processes[processName]?.label ?? processName} />
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
@@ -34,8 +34,11 @@ 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'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import type { EntityFormProps } from '@/components/forms/EntityForm'
|
||||
|
||||
const EntityForm = lazy(() => import('@/components/forms/EntityForm').then((module) => ({ default: module.EntityForm })))
|
||||
const FullCopyDraft = lazy(() => import('@/components/forms/FullCopyDraft').then((module) => ({ default: module.FullCopyDraft })))
|
||||
|
||||
/**
|
||||
* Renders the entity copy form for the record identified by `slug` and `recordId`.
|
||||
@@ -51,7 +54,7 @@ import { FullCopyDraft } from '@/components/forms/FullCopyDraft'
|
||||
* - 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
|
||||
* wrapped in a centered `max-w-6xl` container; submitting creates a new record
|
||||
*/
|
||||
export default function EntityCopyPage() {
|
||||
const params = useRouteParams<{ slug: string; recordId: string }>()
|
||||
@@ -76,15 +79,18 @@ function CopyPageContent({ slug, recordId }: { slug: string; recordId: string })
|
||||
})
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
const tableVariant = storedRecordVariantJson(tableMetaData)
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: canInsertRecords(tableMetaData) && canReadRecords(tableMetaData),
|
||||
includeAssociations: false,
|
||||
tableVariant,
|
||||
})
|
||||
|
||||
const expanded = useRecord({ tableName: slug, primaryKey: recordId, includeAssociations: true,
|
||||
tableVariant,
|
||||
enabled: mode === 'full' && !tree && canInsertRecords(tableMetaData) && canReadRecords(tableMetaData),
|
||||
})
|
||||
const sourceTables = useMemo(() => {
|
||||
@@ -175,15 +181,19 @@ function CopyPageContent({ slug, recordId }: { slug: string; recordId: string })
|
||||
<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
|
||||
<Suspense fallback={<div role="status" aria-label="Loading form" className="py-12 text-center">Loading form…</div>}><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>
|
||||
{tree && <fieldset hidden={mode !== 'full'} disabled={mode !== 'full'} className="min-w-0">
|
||||
<Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading full copy draft…</p>}>
|
||||
<FullCopyDraft tree={tree} onChange={setCopyState} />
|
||||
</Suspense>
|
||||
</fieldset>}
|
||||
</EntityForm></Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Prerenders this dynamic segment once for the Javalin-served static export.
|
||||
* The server returns that page for every concrete value; see use-route-params.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
|
||||
|
||||
/**
|
||||
* Static export placeholder params for this segment.
|
||||
*
|
||||
* @returns One placeholder entry.
|
||||
*/
|
||||
export function generateStaticParams() {
|
||||
return [{ childTable: EXPORT_PLACEHOLDER }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through layout that exists to carry generateStaticParams.
|
||||
*
|
||||
* @param props - Layout props.
|
||||
* @param props.children - Route content.
|
||||
* @returns The children unchanged.
|
||||
*/
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
*
|
||||
* 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 Create-child route — `/app/{table}/{id}/createChild/{childTable}`, the Material Dashboard
|
||||
* path that opens a create form for a child record over the parent record. It opens the record
|
||||
* view with the equivalent `#/createChild={childTable}` link, which shows that dialog.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { createChildLinkHref } from '@/lib/utils/material-links'
|
||||
import { RouteRedirect } from '@/components/layout/RouteRedirect'
|
||||
|
||||
/**
|
||||
* Replaces the path form of a create-child link with the record view and its hash form.
|
||||
*
|
||||
* @returns A redirect status while the record view opens.
|
||||
*/
|
||||
export default function CreateChildPathPage() {
|
||||
const { slug, recordId, childTable } = useRouteParams<{ slug: string; recordId: string; childTable: string }>()
|
||||
return <RouteRedirect href={createChildLinkHref(slug, recordId, childTable)} label="the create form" />
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the record developer view (raw values and associated scripts)
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import { QContextProvider, useQContext } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import RecordDeveloperViewPage from './page'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ slug: 'person', recordId: '1' }),
|
||||
usePathname: () => '/app/person/1/dev',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/tables', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/lib/api/tables')>(),
|
||||
getRecord: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/developer', () => ({
|
||||
SCRIPT_LOG_TABLE: 'scriptLog',
|
||||
scriptLogsFilter: (id: number) => ({ criteria: [{ fieldName: 'scriptRevisionId', operator: 'EQUALS', values: [id] }] }),
|
||||
loadScriptTestDetails: vi.fn(),
|
||||
queryScriptLogs: vi.fn(async () => []),
|
||||
getRecordDeveloperData: vi.fn(),
|
||||
getAssociatedScriptLogs: vi.fn(),
|
||||
queryScriptRevisionFiles: vi.fn(async () => []),
|
||||
queryScriptRevisions: vi.fn(async () => []),
|
||||
queryScriptTypeFileSchemas: vi.fn(async () => []),
|
||||
storeRecordAssociatedScript: vi.fn(),
|
||||
storeScriptRevision: vi.fn(),
|
||||
testScript: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getRecordDeveloperData } from '@/lib/api/developer'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
|
||||
const record = { tableName: 'person', recordLabel: 'Ada Lovelace', values: { id: 1, firstName: 'Ada', email: null } }
|
||||
|
||||
/**
|
||||
* Shows the page header set through the Q context.
|
||||
* @returns The header text.
|
||||
*/
|
||||
function PageHeader() {
|
||||
const { pageHeader } = useQContext()
|
||||
return <p data-testid="page-header">{String(pageHeader)}</p>
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve instance metadata with the given script processes.
|
||||
* @param processNames - Script processes the session may run.
|
||||
* @returns Reports whether the metadata was requested.
|
||||
*/
|
||||
function serveInstance(processNames: string[]): () => boolean {
|
||||
let served = false
|
||||
const processes: QInstance['processes'] = { ...qInstance.processes }
|
||||
for (const name of processNames) {
|
||||
processes[name] = { name, label: name, tableName: '', isHidden: true, iconName: '', frontendSteps: [], hasPermission: true } as unknown as QInstance['processes'][string]
|
||||
}
|
||||
server.use(http.get('/qqq/v1/metaData', () => {
|
||||
served = true
|
||||
return HttpResponse.json({ ...qInstance, processes })
|
||||
}))
|
||||
return () => served
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page with fresh query and Q contexts.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<QContextProvider>
|
||||
<PageHeader />
|
||||
<RecordDeveloperViewPage />
|
||||
</QContextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('RecordDeveloperViewPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getRecord).mockResolvedValue(record)
|
||||
vi.mocked(getRecordDeveloperData).mockResolvedValue({
|
||||
record: { ...record, values: { ...record.values, firstName: 101 } },
|
||||
associatedScripts: [{
|
||||
associatedScript: { fieldName: 'firstName', scriptTypeId: 101 },
|
||||
scriptType: { tableName: 'scriptType', values: { id: 101, fileMode: 1 } },
|
||||
script: { tableName: 'script', values: { id: 101, name: 'Alpha Greeting', currentScriptRevisionId: 102 } },
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the raw values and a card per associated script, headed by the field label', async () => {
|
||||
serveInstance(['storeScriptRevision', 'testScript'])
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' })).toBeInTheDocument()
|
||||
const card = await waitFor(() => {
|
||||
const element = document.querySelector('[data-qqq-id="associated-script-firstName"]')
|
||||
if (!element) throw new Error('no card')
|
||||
return element as HTMLElement
|
||||
})
|
||||
expect(within(card).getByRole('heading', { name: 'First Name' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).not.toBeNull())
|
||||
expect(within(card).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Code', 'Logs', 'Test', 'Docs'])
|
||||
expect(screen.getByTestId('page-header')).toHaveTextContent('Ada Lovelace Developer Mode')
|
||||
expect(getRecordDeveloperData).toHaveBeenCalledWith('person', '1')
|
||||
})
|
||||
|
||||
it('shows the raw values of the developer-mode record, not a separate record read (Material getRecordDeveloperMode)', async () => {
|
||||
serveInstance([])
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' })).toBeInTheDocument()
|
||||
const json = document.querySelector('[data-qqq-id="json-output"]') as HTMLElement
|
||||
expect(JSON.parse(json.textContent ?? '')).toEqual({ id: 1, firstName: 101, email: null })
|
||||
const table = document.querySelector('[data-qqq-id="record-dev-field-table"]') as HTMLElement
|
||||
expect(within(table).getByText('101')).toBeInTheDocument()
|
||||
expect(getRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gates editing and testing on the script processes in the instance metadata', async () => {
|
||||
const served = serveInstance([])
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(served()).toBe(true))
|
||||
// let the metadata response settle into the query cache before asserting its absence
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
const card = await waitFor(() => {
|
||||
const element = document.querySelector('[data-qqq-id="associated-script-firstName"]')
|
||||
if (!element) throw new Error('no card')
|
||||
return element as HTMLElement
|
||||
})
|
||||
await waitFor(() => expect(within(card).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Code', 'Logs', 'Docs']))
|
||||
expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a failure to load the developer-mode record', async () => {
|
||||
serveInstance([])
|
||||
vi.mocked(getRecordDeveloperData).mockRejectedValue(new Error('Permission denied.'))
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Failed to load record: Permission denied.')).toHaveAttribute('role', 'alert')
|
||||
expect(screen.queryByRole('heading', { name: 'Record Raw Values as JSON' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -15,20 +15,25 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RecordDeveloperView page — shows raw record data and table metadata as formatted JSON for debugging.
|
||||
* @file RecordDeveloperView page — raw record values and table metadata as JSON, plus the
|
||||
* record's associated scripts (versions, code, editing, logs, testing, docs).
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import { SCRIPT_LOG_TABLE, getRecordDeveloperData } from '@/lib/api/developer'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { AssociatedScriptViewer } from '@/components/records/AssociatedScriptViewer'
|
||||
import { scriptLogsQueryHref } from '@/components/scripts/script-utils'
|
||||
|
||||
/**
|
||||
* Renders a developer debug view for a single record, showing all field values
|
||||
@@ -36,7 +41,8 @@ import { queryKeys } from '@/lib/query-client'
|
||||
*
|
||||
* @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 raw record values (expanded by default)
|
||||
* - An `<AssociatedScriptViewer>` card per associated script declared on the table
|
||||
* - 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
|
||||
*/
|
||||
@@ -44,16 +50,7 @@ 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 queryClient = useQueryClient()
|
||||
|
||||
const { data: metaData, isLoading: metaLoading } = useQuery({
|
||||
queryKey: queryKeys.tableMetadata(slug),
|
||||
@@ -61,7 +58,32 @@ export default function RecordDeveloperViewPage() {
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { data: instance } = useMetaData()
|
||||
// Material's RecordDeveloperView reads the record from the developer-mode route: the same
|
||||
// response carries the raw values and the associated scripts.
|
||||
const { data: developerData, isLoading: recordLoading, error: recordError } = useQuery({
|
||||
queryKey: queryKeys.recordDeveloper(slug, recordId),
|
||||
queryFn: () => getRecordDeveloperData(slug, recordId),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
retry: false,
|
||||
})
|
||||
const record = developerData?.record
|
||||
// "View All" logs link, when the session can read the script log table
|
||||
const logsViewAllHref = instance?.tables?.[SCRIPT_LOG_TABLE] ? scriptLogsQueryHref : undefined
|
||||
|
||||
const headerLabel = record?.recordLabel || (metaData ? `${metaData.label} ${recordId}` : `${slug} #${recordId}`)
|
||||
useEffect(() => {
|
||||
setPageHeader(`${headerLabel} Developer Mode`)
|
||||
}, [headerLabel, setPageHeader])
|
||||
|
||||
const reloadRecord = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(slug, recordId) }),
|
||||
[queryClient, slug, recordId]
|
||||
)
|
||||
|
||||
const isLoading = recordLoading || metaLoading
|
||||
const canEditScripts = Boolean(instance?.processes?.storeScriptRevision)
|
||||
const canTestScripts = Boolean(instance?.processes?.testScript)
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`record-dev-${slug}-${recordId}`}>
|
||||
@@ -91,7 +113,7 @@ export default function RecordDeveloperViewPage() {
|
||||
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'}
|
||||
Failed to load record: {getErrorMessage(recordError)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -141,7 +163,28 @@ export default function RecordDeveloperViewPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JsonBlock label="Raw Record" value={record} />
|
||||
<JsonBlock label="Record Raw Values as JSON" value={record.values} />
|
||||
|
||||
{metaData && developerData?.associatedScripts.map((associated) => {
|
||||
const fieldName = associated.associatedScript.fieldName
|
||||
const scriptId = record.values[fieldName]
|
||||
return (
|
||||
<AssociatedScriptViewer
|
||||
key={fieldName}
|
||||
tableName={slug}
|
||||
recordId={recordId}
|
||||
fieldLabel={metaData.fields[fieldName]?.label ?? fieldName}
|
||||
data={associated}
|
||||
scriptId={typeof scriptId === 'string' || typeof scriptId === 'number' ? scriptId : null}
|
||||
canCreate={Boolean(metaData.editPermission)}
|
||||
canEdit={canEditScripts}
|
||||
canTest={canTestScripts}
|
||||
logsViewAllHref={logsViewAllHref}
|
||||
onChanged={reloadRecord}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{metaData && <JsonBlock label="Table Metadata" value={metaData} defaultOpen={false} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -171,23 +214,25 @@ function JsonBlock({
|
||||
|
||||
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>
|
||||
<h3 className="m-0">
|
||||
<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>
|
||||
</h3>
|
||||
{open && (
|
||||
<pre
|
||||
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
* - `recordId` — the primary-key value of the record to edit.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { lazy, Suspense, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
@@ -38,9 +38,11 @@ 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'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
|
||||
const EntityForm = lazy(() => import('@/components/forms/EntityForm').then((module) => ({ default: module.EntityForm })))
|
||||
|
||||
/**
|
||||
* Renders the record-edit form for the record identified by `slug` and `recordId`.
|
||||
@@ -56,7 +58,7 @@ import { canEditRecords, hasCapability } from '@/lib/auth/permissions'
|
||||
* - 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
|
||||
* wrapped in a centered `max-w-6xl` container
|
||||
*/
|
||||
export default function EntityEditPage() {
|
||||
const params = useRouteParams<{ slug: string; recordId: string }>()
|
||||
@@ -70,12 +72,14 @@ export default function EntityEditPage() {
|
||||
})
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
const tableVariant = storedRecordVariantJson(tableMetaData)
|
||||
|
||||
const { record, isLoading, isError, error } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: canEditRecords(tableMetaData),
|
||||
includeAssociations: false,
|
||||
tableVariant,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,8 +131,8 @@ export default function EntityEditPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
|
||||
<EntityForm tableMetaData={tableMetaData} record={record} widgets={metaData?.widgets} />
|
||||
<div className="mx-auto max-w-6xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
|
||||
<Suspense fallback={<div role="status" aria-label="Loading form" className="py-12 text-center">Loading form…</div>}><EntityForm tableMetaData={tableMetaData} record={record} widgets={metaData?.widgets} /></Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import apiClient from '@/lib/api/client'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import { recordGet } from '@/mocks/v1-record'
|
||||
import RecordViewPage from './page'
|
||||
|
||||
const navigation = vi.hoisted(() => ({ search: 'tab=related' }))
|
||||
@@ -65,12 +66,12 @@ describe('RecordViewPage independent association loading', () => {
|
||||
it('keeps the base and child Add, and requests no expansion, when the child table is listed without read permission (QRun-IO/qqq#671)', async () => {
|
||||
fixture()
|
||||
const requests: string[] = []
|
||||
server.use(http.get('/data/person/1', ({ request }) => {
|
||||
server.use(recordGet('/table/person/1', ({ request }) => {
|
||||
requests.push(new URL(request.url).searchParams.get('includeAssociations') ?? '')
|
||||
return HttpResponse.json(baseRecord)
|
||||
}))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
expect(await screen.findByText('Avery Sample', {}, { timeout: 5000 })).toBeVisible()
|
||||
expect(await screen.findByText('Related records are unavailable.')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
|
||||
@@ -82,7 +83,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
pet.readPermission = false
|
||||
const before = structuredClone(metadata)
|
||||
const requests: string[] = []
|
||||
server.use(http.get('/data/person/1', ({ request }) => {
|
||||
server.use(recordGet('/table/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
requests.push(mode)
|
||||
return mode === 'true'
|
||||
@@ -110,7 +111,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData', () => HttpResponse.json({ ...registry, tables: visibleTables })),
|
||||
http.get('/qqq/v1/metaData/table/pet', () => { requests.push('pet metadata'); return HttpResponse.json({ error: 'not found' }, { status: 404 }) }),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
recordGet('/table/person/1', ({ request }) => {
|
||||
requests.push(`includeAssociations=${new URL(request.url).searchParams.get('includeAssociations')}`)
|
||||
return HttpResponse.json(baseRecord)
|
||||
}),
|
||||
@@ -125,7 +126,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
|
||||
it('does not rename association keys or present an unmatched association as empty', async () => {
|
||||
fixture(true)
|
||||
server.use(http.get('/data/person/1', ({ request }) => HttpResponse.json({
|
||||
server.use(recordGet('/table/person/1', ({ request }) => HttpResponse.json({
|
||||
...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
|
||||
associatedRecords: { pet: [{ tableName: 'pet', values: { id: 2, name: 'Actual pet' }, recordLabel: 'Actual pet' }] },
|
||||
@@ -140,7 +141,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
})
|
||||
it('retains explicitly keyed related records when the response binding is available', async () => {
|
||||
fixture(true)
|
||||
server.use(http.get('/data/person/1', ({ request }) => HttpResponse.json({
|
||||
server.use(recordGet('/table/person/1', ({ request }) => HttpResponse.json({
|
||||
...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
|
||||
associatedRecords: { pets: [{ tableName: 'pet', values: { id: 2, name: 'Mapped pet' }, recordLabel: 'Mapped pet' }] },
|
||||
@@ -160,7 +161,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
const ready = new Promise<void>((resolve) => { releaseMetadata = resolve })
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/pet', async () => { metadataCalls.push('pet'); await ready; return HttpResponse.json(pet) }),
|
||||
http.get('/data/person/1', ({ request }) => HttpResponse.json({ ...baseRecord,
|
||||
recordGet('/table/person/1', ({ request }) => HttpResponse.json({ ...baseRecord,
|
||||
...(new URL(request.url).searchParams.get('includeAssociations') === 'true'
|
||||
? { associatedRecords: { 'care / primary': [], 'scheduled reviews': [{ tableName: 'pet', values: { id: 4, name: 'Review pet' } }] } } : {}),
|
||||
})),
|
||||
@@ -186,7 +187,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
]
|
||||
registry.widgets.companionPanel = { name: 'companionPanel', label: 'Different widget title', type: 'childRecordList', hasPermission: true, defaultValues: { manageAssociationName: 'care / primary' } }
|
||||
registry.widgets.reviewEditor = { name: 'reviewEditor', label: 'Another title', type: 'rowBuilder', hasPermission: true, defaultValues: { associationName: 'scheduled reviews' } }
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { 'care / primary': [], 'scheduled reviews': [] } })))
|
||||
server.use(recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { 'care / primary': [], 'scheduled reviews': [] } })))
|
||||
const { container } = renderPage()
|
||||
const byId = (id: string) => Array.from(container.querySelectorAll('[data-qqq-id]')).find((element) => element.getAttribute('data-qqq-id') === id)
|
||||
await waitFor(() => expect(byId('button-create-association-care%20%2F%20primary')).toBeEnabled())
|
||||
@@ -199,7 +200,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
fixture(true)
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json({ error: 'Unavailable' }, { status: 500 })),
|
||||
http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })),
|
||||
recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })),
|
||||
)
|
||||
renderPage()
|
||||
expect(await screen.findByText('Avery Sample')).toBeVisible()
|
||||
@@ -215,10 +216,10 @@ describe('RecordViewPage independent association loading', () => {
|
||||
pet.fields.birthDate = { ...person.fields.visitDate, name: 'birthDate', isEditable: true }
|
||||
pet.fields.personId.possibleValueSourceName = undefined
|
||||
person.associations![0].join.joinOns = [{ leftField: 'schedule', rightField: 'personId' }, { leftField: 'visitDate', rightField: 'birthDate' }]
|
||||
server.use(http.get('/data/person/1', ({ request }) => new URL(request.url).searchParams.get('includeAssociations') === 'true'
|
||||
server.use(recordGet('/table/person/1', ({ request }) => new URL(request.url).searchParams.get('includeAssociations') === 'true'
|
||||
? HttpResponse.json({ error: 'Denied' }, { status: 403 })
|
||||
: HttpResponse.json({ ...baseRecord, values: { ...baseRecord.values, schedule: 2, visitDate: '1990-01-15' } })))
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'pet', values: { id: 8, personId: 2, birthDate: '1990-01-15' } }] })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'pet', values: { id: 8, personId: 2, birthDate: '1990-01-15' } } })
|
||||
const before = structuredClone({ person, pet })
|
||||
renderPage()
|
||||
fireEvent.click(await screen.findByRole('button', { name: '+ Add Pet' }))
|
||||
@@ -228,7 +229,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
fireEvent.change(within(dialog).getByLabelText(/Name/), { target: { value: 'Created child' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalled())
|
||||
expect(post.mock.calls[0][0]).toBe('/data/pet')
|
||||
expect(post.mock.calls[0][0]).toBe('/table/pet')
|
||||
const values = post.mock.calls[0][1] as FormData
|
||||
expect(values.get('personId')).toBe('2')
|
||||
expect(values.get('birthDate')).toBe('1990-01-15')
|
||||
@@ -243,7 +244,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
person.associations![0].join.joinOns = [{ leftField: 'firstName', rightField: 'personId' }]
|
||||
pet.fields.personId.type = 'STRING'
|
||||
const value = '>>>漢'
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, values: { id: 1, firstName: value }, associatedRecords: { pets: [] } })))
|
||||
server.use(recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, values: { id: 1, firstName: value }, associatedRecords: { pets: [] } })))
|
||||
renderPage()
|
||||
const link = await screen.findByRole('link', { name: 'View All' })
|
||||
const url = new URL(link.getAttribute('href')!, 'http://localhost')
|
||||
@@ -254,7 +255,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
it('allows correctly oriented direct Add for reverse joins without claiming expanded absence', async () => {
|
||||
const { person } = fixture(true)
|
||||
person.associations![0].join = { ...person.associations![0].join, leftTable: 'pet', rightTable: 'person', type: 'MANY_TO_ONE', joinOns: [{ leftField: 'personId', rightField: 'id' }] }
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })))
|
||||
server.use(recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Related record loading for reverse associations is not supported.')).toBeVisible()
|
||||
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
|
||||
@@ -265,7 +266,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
const { person, pet } = fixture(false)
|
||||
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
|
||||
delete person.fields.id
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json(baseRecord)))
|
||||
server.use(recordGet('/table/person/1', () => HttpResponse.json(baseRecord)))
|
||||
renderPage()
|
||||
expect(await screen.findByText('Relationship values are unavailable; this record cannot be linked.')).toBeVisible()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
@@ -275,7 +276,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
it('keeps permitted Add available when there are no visible child columns', async () => {
|
||||
const { pet } = fixture(false)
|
||||
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json(baseRecord)))
|
||||
server.use(recordGet('/table/person/1', () => HttpResponse.json(baseRecord)))
|
||||
renderPage()
|
||||
expect(await screen.findByRole('button', { name: '+ Add Pet' })).toBeEnabled()
|
||||
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
|
||||
@@ -284,7 +285,7 @@ describe('RecordViewPage independent association loading', () => {
|
||||
it('keeps readable data and View All when INSERT alone is denied', async () => {
|
||||
const { pet } = fixture(true)
|
||||
pet.insertPermission = false
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [{ tableName: 'pet', values: { id: 'one/two?', name: 'Readable pet' } }] } })))
|
||||
server.use(recordGet('/table/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [{ tableName: 'pet', values: { id: 'one/two?', name: 'Readable pet' } }] } })))
|
||||
renderPage()
|
||||
const recordLinks = await screen.findAllByRole('link', { name: 'View Pet record one/two?' })
|
||||
for (const link of recordLinks) expect(link.getAttribute('href')).toContain('/app/pet/one%2Ftwo%3F?')
|
||||
|
||||
@@ -29,12 +29,14 @@
|
||||
* - `recordId` — the primary-key value of the record to display.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { lazy, Suspense, useEffect } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { addRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { addRecentRecord, removeRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { recordAnalytics } from '@/lib/analytics'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import { getProcessesForRecordView } from '@/lib/utils/process-utils'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { auditSource } from '@/lib/api/audits'
|
||||
@@ -43,7 +45,12 @@ import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import type { AssociationTableState } from '@/lib/utils/association-utils'
|
||||
import { canReadRecords } from '@/lib/auth/permissions'
|
||||
import { RecordView } from '@/components/records/RecordView'
|
||||
import { processRunHref, tableProcessForSegment, tableReportForSegment } from '@/lib/utils/material-links'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { recordSectionQueryJoins } from '@/lib/utils/record-layout-utils'
|
||||
import { RouteRedirect } from '@/components/layout/RouteRedirect'
|
||||
|
||||
const RecordView = lazy(() => import('@/components/records/RecordView').then((module) => ({ default: module.RecordView })))
|
||||
|
||||
/**
|
||||
* Renders the detail view for a single record identified by `slug` (table name)
|
||||
@@ -72,12 +79,21 @@ export default function RecordViewPage() {
|
||||
})
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
const tableVariant = storedRecordVariantJson(tableMetaData)
|
||||
const joins = tableMetaData && metaData?.tables ? recordSectionQueryJoins(tableMetaData, metaData.tables) : []
|
||||
const queryJoins = joins.length ? JSON.stringify(joins) : undefined
|
||||
|
||||
// Material URL shapes: /app/{table}/{process} runs the process, /app/{table}/{report} runs the report.
|
||||
const scopedProcess = metaData?.tables?.[slug] ? tableProcessForSegment(metaData, slug, recordId) : null
|
||||
const scopedReport = metaData?.tables?.[slug] && !scopedProcess ? tableReportForSegment(metaData, slug, recordId) : null
|
||||
|
||||
const { record, isLoading, isError, error, refetch } = useRecord({
|
||||
tableName: slug,
|
||||
primaryKey: recordId,
|
||||
enabled: canReadRecords(tableMetaData),
|
||||
enabled: canReadRecords(tableMetaData) && Boolean(metaData) && !scopedProcess && !scopedReport,
|
||||
includeAssociations: false,
|
||||
tableVariant,
|
||||
queryJoins,
|
||||
})
|
||||
|
||||
const targetNames = [...new Set(tableMetaData?.associations?.map((association) => association.associatedTableName) ?? [])]
|
||||
@@ -107,6 +123,7 @@ export default function RecordViewPage() {
|
||||
enabled: Boolean(record) && Boolean(tableMetaData?.associations?.length) && Boolean(metaData)
|
||||
&& deniedTargets.size === 0 && unreadableTargets.length === 0,
|
||||
includeAssociations: true,
|
||||
tableVariant,
|
||||
})
|
||||
const displayRecord = record && associations.record && !associations.isError
|
||||
? { ...record, associatedRecords: associations.record.associatedRecords }
|
||||
@@ -126,16 +143,34 @@ export default function RecordViewPage() {
|
||||
// Track recently viewed records for global search
|
||||
useEffect(() => {
|
||||
if (record && tableMetaData) {
|
||||
// the record label is sent only when the application allows record data (QRun-IO/qqq#730)
|
||||
recordAnalytics({ category: 'tableEvents', action: 'view', label: tableMetaData.label, recordLabel: record.recordLabel })
|
||||
addRecentRecord({
|
||||
tableName: tableMetaData.name,
|
||||
tableLabel: tableMetaData.label,
|
||||
recordId: String(record.values[tableMetaData.primaryKeyField]),
|
||||
recordLabel: record.recordLabel || `${tableMetaData.label} #${record.values[tableMetaData.primaryKeyField]}`,
|
||||
path: `/app/${tableMetaData.name}/${record.values[tableMetaData.primaryKeyField]}`,
|
||||
tableIcon: tableMetaData.icon,
|
||||
})
|
||||
}
|
||||
}, [record, tableMetaData])
|
||||
|
||||
// A record that no longer exists (404) or may no longer be viewed (403) leaves the recents (Material HistoryUtils)
|
||||
const recordStatus = isError ? getErrorStatusCode(error) : undefined
|
||||
useEffect(() => {
|
||||
if (recordStatus === 404 || recordStatus === 403) removeRecentRecord(slug, recordId)
|
||||
}, [recordStatus, slug, recordId])
|
||||
|
||||
if (metaData && scopedProcess) {
|
||||
const search = typeof window === 'undefined' ? '' : window.location.search
|
||||
const href = processRunHref(scopedProcess, { search, returnTo: `/app/${encodeURIComponent(slug)}` })
|
||||
return <RouteRedirect href={href} label={metaData.processes[scopedProcess]?.label ?? scopedProcess} />
|
||||
}
|
||||
if (metaData && scopedReport) {
|
||||
return <RouteRedirect href={`/app/${encodeURIComponent(scopedReport)}`} label={metaData.reports[scopedReport]?.label ?? scopedReport} />
|
||||
}
|
||||
|
||||
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
@@ -156,7 +191,7 @@ export default function RecordViewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
|
||||
const tableProcesses = metaData ? getProcessesForRecordView(metaData, slug) : []
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -164,7 +199,8 @@ export default function RecordViewPage() {
|
||||
{record && associations.isError && (
|
||||
<p role="alert" className="mb-4 text-destructive">Related records could not be loaded.</p>
|
||||
)}
|
||||
<RecordView
|
||||
<Suspense fallback={<div role="status" aria-label="Loading record view" className="py-12 text-center">Loading record…</div>}><RecordView
|
||||
instance={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
record={displayRecord}
|
||||
isLoading={isLoading}
|
||||
@@ -175,11 +211,12 @@ export default function RecordViewPage() {
|
||||
if (tableMetaData.associations?.length) associations.refetch()
|
||||
}}
|
||||
processes={tableProcesses}
|
||||
allProcesses={metaData?.processes}
|
||||
allTables={metaData?.tables}
|
||||
widgetMetaDataMap={metaData?.widgets}
|
||||
associationTables={associationTables}
|
||||
auditSource={auditSource(metaData)}
|
||||
/>
|
||||
/></Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -28,16 +28,18 @@
|
||||
* - `slug` — the QQQ table name for which a new record should be created.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { lazy, Suspense, useEffect, useState } 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 { EntityForm } from '@/components/forms/EntityForm'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { canInsertRecords, hasCapability } from '@/lib/auth/permissions'
|
||||
import { formPresetsFromHash, lockedPresetValues, type HashFormPresets } from '@/lib/utils/material-links'
|
||||
|
||||
const EntityForm = lazy(() => import('@/components/forms/EntityForm').then((module) => ({ default: module.EntityForm })))
|
||||
|
||||
/**
|
||||
* Renders the record-creation form for the table identified by `slug`.
|
||||
@@ -50,7 +52,7 @@ import { canInsertRecords, hasCapability } from '@/lib/auth/permissions'
|
||||
* @returns A composed page that renders one of:
|
||||
* - A full-screen spinner while metadata resolves
|
||||
* - A permission-error banner when the user lacks `insertPermission`
|
||||
* - `<EntityForm>` in create mode (no `record` prop) wrapped in a centered `max-w-4xl` container
|
||||
* - `<EntityForm>` in create mode (no `record` prop) wrapped in a centered `max-w-6xl` container
|
||||
*/
|
||||
export default function EntityCreatePage() {
|
||||
const params = useRouteParams<{ slug: string }>()
|
||||
@@ -65,6 +67,12 @@ export default function EntityCreatePage() {
|
||||
|
||||
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
|
||||
|
||||
// Material links preset fields: /create#/defaultValues={json}/disabledFields={json}
|
||||
const [presets, setPresets] = useState<HashFormPresets | null>(null)
|
||||
useEffect(() => {
|
||||
setPresets(formPresetsFromHash(window.location.hash))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Create ${tableMetaData?.label ?? slug}`)
|
||||
if (tableMetaData) {
|
||||
@@ -76,7 +84,7 @@ export default function EntityCreatePage() {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
|
||||
}
|
||||
|
||||
if (!tableMetaData) {
|
||||
if (!tableMetaData || !presets) {
|
||||
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" />
|
||||
@@ -101,8 +109,14 @@ export default function EntityCreatePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-create-${slug}`}>
|
||||
<EntityForm tableMetaData={tableMetaData} widgets={metaData?.widgets} />
|
||||
<div className="mx-auto max-w-6xl" data-qqq-id={`entity-create-${slug}`}>
|
||||
<Suspense fallback={<div role="status" aria-label="Loading form" className="py-12 text-center">Loading form…</div>}><EntityForm
|
||||
tableMetaData={tableMetaData}
|
||||
widgets={metaData?.widgets}
|
||||
defaultValues={presets.defaultValues}
|
||||
disabledFieldNames={presets.disabledFields}
|
||||
fixedValues={lockedPresetValues(tableMetaData, presets)}
|
||||
/></Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the table Developer view's ESB section wiring
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { toast } from 'sonner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { queryClient, queryKeys } from '@/lib/query-client'
|
||||
import { server } from '@/mocks/node'
|
||||
import TableDeveloperViewPage from './page'
|
||||
|
||||
const { params } = vi.hoisted(() => ({ params: { slug: 'order' } }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => params,
|
||||
usePathname: () => `/app/${params.slug}/dev`,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
vi.mock('sonner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('sonner')>()),
|
||||
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
// The page's API Docs section is covered by page.test.tsx; keep it empty here.
|
||||
vi.mock('@/lib/api/developer', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/developer')>()),
|
||||
getTableApis: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Renders the page with a query client.
|
||||
*
|
||||
* @param client - The query client; a fresh one without retries by default.
|
||||
* @returns The query client, for inspecting query state.
|
||||
*/
|
||||
function renderPage(
|
||||
client: QueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
): QueryClient {
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<QContextProvider>
|
||||
<TableDeveloperViewPage />
|
||||
</QContextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the table's metadata has rendered and its ESB request has settled.
|
||||
*
|
||||
* @param client - The page's query client.
|
||||
* @param tableName - The table shown.
|
||||
*/
|
||||
async function settled(client: QueryClient, tableName: string): Promise<void> {
|
||||
await screen.findByText('Fields')
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryState(queryKeys.esbTable(tableName))?.status).toBe('success')
|
||||
)
|
||||
}
|
||||
|
||||
describe('TableDeveloperViewPage ESB section', () => {
|
||||
beforeEach(() => {
|
||||
params.slug = 'order'
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('shows the ESB section returned for the table', async () => {
|
||||
renderPage()
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Enterprise Service Bus' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('table', { name: 'Publications' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('table', { name: 'Subscribers' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the section when the user may not read the table', async () => {
|
||||
server.use(
|
||||
http.get('/qqq/v1/esb/table/order', () =>
|
||||
HttpResponse.json({ error: 'Permission denied.' }, { status: 403 })
|
||||
)
|
||||
)
|
||||
const client = renderPage()
|
||||
await settled(client, 'order')
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the section when the table has no ESB metadata', async () => {
|
||||
params.slug = 'person'
|
||||
const client = renderPage()
|
||||
await settled(client, 'person')
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the process Developer view with its ESB triggers for a process slug', async () => {
|
||||
params.slug = 'fulfillOrder'
|
||||
renderPage()
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Process Developer View: fulfillOrder' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Enterprise Service Bus' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('table', { name: 'Triggers' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Fields')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the table Developer view for a table slug', async () => {
|
||||
renderPage()
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Table Developer View: Orders' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the section without a toast when a backend without the ESB module answers with its SPA page', async () => {
|
||||
server.use(
|
||||
http.get('/qqq/v1/esb/table/order', () =>
|
||||
HttpResponse.html('<!doctype html><html><body>SPA</body></html>')
|
||||
)
|
||||
)
|
||||
const client = renderPage(queryClient)
|
||||
await settled(client, 'order')
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the table developer view's API Docs & Playground
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QContextProvider, useQContext } from '@/lib/context/q-context'
|
||||
import TableDeveloperViewPage from './page'
|
||||
|
||||
const { rapidocLoaded } = vi.hoisted(() => ({ rapidocLoaded: vi.fn() }))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ slug: 'person' }),
|
||||
usePathname: () => '/app/person/dev',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('rapidoc', () => {
|
||||
rapidocLoaded()
|
||||
return {}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/api/developer', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/lib/api/developer')>(),
|
||||
getTableApis: vi.fn(),
|
||||
getApiVersions: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getApiVersions, getTableApis } from '@/lib/api/developer'
|
||||
|
||||
const getTableApisMock = vi.mocked(getTableApis)
|
||||
const getApiVersionsMock = vi.mocked(getApiVersions)
|
||||
|
||||
const apis = [
|
||||
{ name: 'person-api', path: '/person-api/', label: 'Person API' },
|
||||
{ name: 'admin-api', path: '/admin-api/', label: 'Admin API' },
|
||||
]
|
||||
const versions: Record<string, { supportedVersions: string[]; currentVersion: string }> = {
|
||||
'/person-api/': { supportedVersions: ['2026.Q1', '2026.Q2'], currentVersion: '2026.Q2' },
|
||||
'/admin-api/': { supportedVersions: ['v1', 'v2'], currentVersion: 'v1' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the page header set through the Q context.
|
||||
* @returns The header text.
|
||||
*/
|
||||
function PageHeader() {
|
||||
const { pageHeader } = useQContext()
|
||||
return <p data-testid="page-header">{String(pageHeader)}</p>
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page with fresh query and Q contexts.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<QContextProvider>
|
||||
<PageHeader />
|
||||
<TableDeveloperViewPage />
|
||||
</QContextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('TableDeveloperViewPage API Docs & Playground', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
getTableApisMock.mockReset()
|
||||
getApiVersionsMock.mockReset()
|
||||
getApiVersionsMock.mockImplementation(async (path: string) => versions[path])
|
||||
})
|
||||
|
||||
it('selects the first API and its current version and embeds RapiDoc for the table spec', async () => {
|
||||
getTableApisMock.mockResolvedValue(apis)
|
||||
const { container } = renderPage()
|
||||
|
||||
const section = await screen.findByRole('region', { name: 'API Docs & Playground' })
|
||||
expect(section).toHaveAttribute('data-qqq-id', 'table-dev-api-docs')
|
||||
const apiSelect = await within(section).findByLabelText('API')
|
||||
expect(apiSelect).toHaveAttribute('id', 'table-dev-api-select')
|
||||
expect(apiSelect).toHaveAttribute('data-qqq-id', 'select-api')
|
||||
expect(apiSelect).toHaveValue('person-api')
|
||||
expect(within(apiSelect).getAllByRole('option').map((option) => option.textContent)).toEqual(['Person API', 'Admin API'])
|
||||
const versionSelect = await within(section).findByLabelText('Version')
|
||||
expect(versionSelect).toHaveAttribute('data-qqq-id', 'select-api-version')
|
||||
expect(versionSelect).toHaveValue('2026.Q2')
|
||||
|
||||
await waitFor(() => expect(container.querySelector('rapi-doc')).not.toBeNull())
|
||||
const rapiDoc = container.querySelector('[data-qqq-id="table-dev-api-reference"] rapi-doc')!
|
||||
expect(rapiDoc).toHaveAttribute('spec-url', '/person-api/2026.Q2/person/openapi.json')
|
||||
expect(rapiDoc).toHaveAttribute('primary-color', '#6366f1')
|
||||
expect(rapiDoc).toHaveAttribute('show-header', 'false')
|
||||
expect(rapiDoc).toHaveAttribute('render-style', 'view')
|
||||
expect(rapiDoc).toHaveAttribute('allow-server-selection', 'false')
|
||||
expect(rapidocLoaded).toHaveBeenCalled()
|
||||
expect(getTableApisMock).toHaveBeenCalledWith('person')
|
||||
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiName')).toBe('person-api')
|
||||
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiVersion')).toBe('2026.Q2')
|
||||
expect(screen.getByTestId('page-header')).toHaveTextContent('People Developer Mode')
|
||||
})
|
||||
|
||||
it('restores the remembered API and version when they are still offered', async () => {
|
||||
window.localStorage.setItem('qqq.tableDeveloperView.lastApiName', 'admin-api')
|
||||
window.localStorage.setItem('qqq.tableDeveloperView.lastApiVersion', 'v2')
|
||||
getTableApisMock.mockResolvedValue(apis)
|
||||
const { container } = renderPage()
|
||||
|
||||
expect(await screen.findByLabelText('API')).toHaveValue('admin-api')
|
||||
expect(await screen.findByLabelText('Version')).toHaveValue('v2')
|
||||
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/admin-api/v2/person/openapi.json'))
|
||||
})
|
||||
|
||||
it('falls back to the first API and the current version when the remembered ones are gone', async () => {
|
||||
window.localStorage.setItem('qqq.tableDeveloperView.lastApiName', 'retired-api')
|
||||
window.localStorage.setItem('qqq.tableDeveloperView.lastApiVersion', 'v9')
|
||||
getTableApisMock.mockResolvedValue(apis)
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByLabelText('API')).toHaveValue('person-api')
|
||||
expect(await screen.findByLabelText('Version')).toHaveValue('2026.Q2')
|
||||
await waitFor(() => expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiName')).toBe('person-api'))
|
||||
})
|
||||
|
||||
it('switches API and version from the keyboard-operable native selects and remembers them', async () => {
|
||||
const user = userEvent.setup()
|
||||
getTableApisMock.mockResolvedValue(apis)
|
||||
const { container } = renderPage()
|
||||
|
||||
await user.selectOptions(await screen.findByLabelText('API'), 'admin-api')
|
||||
expect(await screen.findByLabelText('Version')).toHaveValue('v1')
|
||||
await user.selectOptions(screen.getByLabelText('Version'), 'v2')
|
||||
|
||||
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/admin-api/v2/person/openapi.json'))
|
||||
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiName')).toBe('admin-api')
|
||||
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiVersion')).toBe('v2')
|
||||
})
|
||||
|
||||
it('shows the empty state without selectors when the table is in no API', async () => {
|
||||
getTableApisMock.mockResolvedValue([])
|
||||
const { container } = renderPage()
|
||||
|
||||
const empty = await screen.findByText('This table is not available in any APIs.')
|
||||
expect(empty).toHaveAttribute('data-qqq-id', 'table-dev-no-apis')
|
||||
expect(screen.queryByLabelText('API')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Version')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[data-qqq-id="table-dev-api-reference"]')).toBeNull()
|
||||
expect(getApiVersionsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps working when localStorage throws', async () => {
|
||||
const storage = window.localStorage
|
||||
const blocked = () => { throw new Error('blocked') }
|
||||
// The test setup installs a writable localStorage stand-in; swap it for one that refuses access.
|
||||
;(window as { localStorage: unknown }).localStorage = { getItem: blocked, setItem: blocked, removeItem: blocked, clear: blocked }
|
||||
try {
|
||||
getTableApisMock.mockResolvedValue(apis)
|
||||
renderPage()
|
||||
expect(await screen.findByLabelText('Version')).toHaveValue('2026.Q2')
|
||||
} finally {
|
||||
;(window as { localStorage: unknown }).localStorage = storage
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a failure to list APIs', async () => {
|
||||
getTableApisMock.mockRejectedValue(new Error('Server exploded'))
|
||||
renderPage()
|
||||
|
||||
const section = await screen.findByRole('region', { name: 'API Docs & Playground' })
|
||||
expect(await within(section).findByRole('alert')).toHaveTextContent('Failed to load APIs: Server exploded')
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file TableDeveloperView page — shows raw table metadata as formatted JSON for debugging field definitions and permissions.
|
||||
* @file Developer view page — resolves a slug to a table or process. It shows metadata and ESB
|
||||
* details for either, plus API Docs & Playground for tables exposed by application APIs.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -26,61 +27,102 @@ import { Code, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useMetaData, useProcessMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { getEsbForProcess, getEsbForTable } from '@/lib/api/esb'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { TableApiDocs } from '@/components/records/TableApiDocs'
|
||||
import { EsbSection } from '@/components/esb/EsbSection'
|
||||
import { ProcessDeveloperView } from '@/components/esb/ProcessDeveloperView'
|
||||
|
||||
/**
|
||||
* Renders the Developer view for the table or process named by the slug. A slug naming both
|
||||
* resolves to the table, as it does on the slug page; a slug naming neither (or instance metadata
|
||||
* that fails to load) falls through to the table view, which reports the load failure.
|
||||
*
|
||||
* @returns A spinner while instance metadata loads, then `<ProcessDeveloperPage>` for a process
|
||||
* slug or `<TableDeveloperView>` otherwise.
|
||||
*/
|
||||
export default function DeveloperViewPage() {
|
||||
const params = useRouteParams<{ slug: string }>()
|
||||
const slug = params.slug
|
||||
const { data: metaData, isError: metaDataError } = useMetaData()
|
||||
|
||||
if (!metaData && !metaDataError) return <LoadingSpinner />
|
||||
const isProcess = Boolean(metaData && !metaData.tables?.[slug] && metaData.processes?.[slug])
|
||||
return isProcess ? <ProcessDeveloperPage processName={slug} /> : <TableDeveloperView tableName={slug} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a process's metadata and ESB data and renders its Developer view.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.processName - The process to show.
|
||||
* @returns A spinner while metadata loads, a destructive error panel on failure, then the
|
||||
* `<ProcessDeveloperView>` (without an ESB section on 403, 404, or a backend without the ESB module).
|
||||
*/
|
||||
function ProcessDeveloperPage({ processName }: { processName: string }) {
|
||||
const { data, error } = useProcessMetaData(processName)
|
||||
const { setPageHeader } = useQContext()
|
||||
const processLabel = data?.label || processName
|
||||
useEffect(() => {
|
||||
setPageHeader(`${processLabel} Developer Mode`)
|
||||
}, [processLabel, setPageHeader])
|
||||
const { data: esb } = useQuery({
|
||||
queryKey: queryKeys.esbProcess(processName),
|
||||
queryFn: () => getEsbForProcess(processName),
|
||||
})
|
||||
|
||||
if (error) return <LoadError error={error} />
|
||||
if (!data) return <LoadingSpinner />
|
||||
return <ProcessDeveloperView process={data} esb={esb ?? null} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a developer debug view for a QQQ table, showing summary statistics
|
||||
* and the full table metadata as a collapsible JSON block.
|
||||
*
|
||||
* @returns A composed page that assembles:
|
||||
* @param props - Component props.
|
||||
* @param props.tableName - The table to show.
|
||||
* @returns A composed view that assembles:
|
||||
* - A 4-column stats grid (`<MetaStat>` cards: field count, section count, permissions, primary key)
|
||||
* - An `<EsbSection>` with the table's ESB publications and subscribers, omitted when the
|
||||
* table has none to show (403, 404, or a backend without the ESB module)
|
||||
* - The `<TableApiDocs>` section (API and version selectors with the embedded RapiDoc reference)
|
||||
* - A collapsible `<JsonBlock>` panel rendering the full table metadata as formatted JSON
|
||||
* - A loading spinner while metadata is fetching, and a destructive error panel on failure
|
||||
*/
|
||||
export default function TableDeveloperViewPage() {
|
||||
const params = useRouteParams<{ slug: string }>()
|
||||
function TableDeveloperView({ tableName }: { tableName: string }) {
|
||||
const { setPageHeader } = useQContext()
|
||||
const slug = params.slug
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.tableMetadata(tableName),
|
||||
queryFn: () => loadTableMetaData(tableName),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
const { data: instance } = useMetaData()
|
||||
const tableLabel = data?.label || tableName
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader(`Developer View: ${slug}`)
|
||||
}, [slug, setPageHeader])
|
||||
setPageHeader(`${tableLabel} Developer Mode`)
|
||||
}, [tableLabel, setPageHeader])
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.tableMetadata(slug),
|
||||
queryFn: () => loadTableMetaData(slug),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
const { data: esb } = useQuery({
|
||||
queryKey: queryKeys.esbTable(tableName),
|
||||
queryFn: () => getEsbForTable(tableName),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`table-dev-${slug}`}>
|
||||
<div className="space-y-6" data-qqq-id={`table-dev-${tableName}`}>
|
||||
<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">
|
||||
Table Developer View: <span className="font-mono text-primary">{slug}</span>
|
||||
Table Developer View: <span className="text-primary">{tableLabel}</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>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Failed to load metadata: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
{error && <LoadError error={error} />}
|
||||
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
@@ -101,6 +143,10 @@ export default function TableDeveloperViewPage() {
|
||||
<MetaStat label="Primary Key" value={data.primaryKeyField ?? '—'} />
|
||||
</div>
|
||||
|
||||
<EsbSection data={esb ?? null} />
|
||||
|
||||
<TableApiDocs tableName={data.name || tableName} primaryColor={instance?.branding?.accentColor} />
|
||||
|
||||
{/* Raw JSON */}
|
||||
<JsonBlock label="Full Table Metadata" value={data} defaultOpen={false} />
|
||||
</div>
|
||||
@@ -109,6 +155,42 @@ export default function TableDeveloperViewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A centered spinner announced as busy.
|
||||
*
|
||||
* @returns The loading indicator.
|
||||
*/
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A destructive panel reporting a metadata load failure.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.error - The load error.
|
||||
* @returns The error panel.
|
||||
*/
|
||||
function LoadError({ error }: { error: unknown }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Failed to load metadata: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single labeled statistic card.
|
||||
*
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
@@ -36,8 +36,8 @@ vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(location.search),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/components/query', () => ({ RecordQuery: recordQuery }))
|
||||
vi.mock('@/components/widgets', () => ({ AppHome: () => <div>App home</div> }))
|
||||
vi.mock('@/components/query/RecordQuery', () => ({ RecordQuery: recordQuery }))
|
||||
vi.mock('@/components/widgets/AppHome', () => ({ AppHome: () => <div>App home</div> }))
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
@@ -124,6 +124,11 @@ const greeting = {
|
||||
}
|
||||
|
||||
describe('SlugPage process initialization', () => {
|
||||
beforeAll(async () => {
|
||||
// Keep cold screen compilation outside the metadata-readiness assertion deadline.
|
||||
await import('@/components/process/ProcessRun')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
params.slug = greeting.name
|
||||
@@ -145,9 +150,9 @@ describe('SlugPage process initialization', () => {
|
||||
type: 'COMPLETE', processUUID: 'selected-run', nextStep: 'setup', values: {},
|
||||
})
|
||||
server.use(
|
||||
http.get('/metaData/process/greetInteractive', async () => {
|
||||
http.get('/qqq/v1/metaData/process/greetInteractive', async () => {
|
||||
await metadataReady
|
||||
return HttpResponse.json({ process: greeting })
|
||||
return HttpResponse.json(greeting)
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
@@ -163,9 +168,9 @@ describe('SlugPage process initialization', () => {
|
||||
it('does not initialize when full process metadata is denied', async () => {
|
||||
let initialized = false
|
||||
server.use(
|
||||
http.get('/metaData/process/greetInteractive', () =>
|
||||
http.get('/qqq/v1/metaData/process/greetInteractive', () =>
|
||||
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })),
|
||||
http.post('/processes/greetInteractive/init', () => {
|
||||
http.post('/qqq/v1/processes/greetInteractive/init', () => {
|
||||
initialized = true
|
||||
return HttpResponse.json({ processUUID: 'unexpected', values: {} })
|
||||
}),
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
* - `slug` — a QQQ app name, table name, process name, or report name.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { lazy, Suspense, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { QInstance } from '@/types'
|
||||
@@ -41,12 +41,17 @@ import type { ProcessInitRequest } from '@/lib/api/processes'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { canAccessProcess, canReadRecords } from '@/lib/auth/permissions'
|
||||
import { RecordQuery } from '@/components/query'
|
||||
import { ProcessRun } from '@/components/process'
|
||||
import { AppHome } from '@/components/widgets'
|
||||
import { ReportRun } from '@/components/reports'
|
||||
import { safeReturnTo } from '@/lib/auth/return-to'
|
||||
import { NotFoundState } from '@/components/layout/NotFoundState'
|
||||
|
||||
// Metadata selects one screen. Keep the other three screens out of this route's first load.
|
||||
const RecordQuery = lazy(() => import('@/components/query/RecordQuery').then((module) => ({ default: module.RecordQuery })))
|
||||
const ProcessRun = lazy(() => import('@/components/process/ProcessRun').then((module) => ({ default: module.ProcessRun })))
|
||||
const AppHome = lazy(() => import('@/components/widgets/AppHome').then((module) => ({ default: module.AppHome })))
|
||||
const ReportRun = lazy(() => import('@/components/reports/ReportRun').then((module) => ({ default: module.ReportRun })))
|
||||
|
||||
const screenFallback = <div role="status" aria-label="Loading screen" className="flex items-center justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>
|
||||
|
||||
/**
|
||||
* Resolves a URL slug to its QQQ resource type and name.
|
||||
*
|
||||
@@ -62,7 +67,7 @@ import { NotFoundState } from '@/components/layout/NotFoundState'
|
||||
* @param metaData - The full QQQ instance metadata fetched from the backend.
|
||||
* @returns A `{ type, name }` object describing the resolved resource, or `null`.
|
||||
*/
|
||||
export function resolveSlugTarget(
|
||||
function resolveSlugTarget(
|
||||
slug: string,
|
||||
metaData: QInstance
|
||||
): { type: 'app' | 'table' | 'process' | 'report'; name: string } | null {
|
||||
@@ -173,18 +178,18 @@ export default function SlugPage() {
|
||||
// App dashboard — Package 5 implementation
|
||||
if (isApp && app) {
|
||||
return (
|
||||
<AppHome
|
||||
<Suspense fallback={screenFallback}><AppHome
|
||||
appMetaData={app}
|
||||
instance={metaData}
|
||||
widgetRegistry={metaData.widgets ?? {}}
|
||||
/>
|
||||
/></Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// Table record query — Package 2 implementation
|
||||
if (isTable && table) {
|
||||
const tableProcesses = getProcessesForTable(metaData, slug)
|
||||
return <RecordQuery key={slug} tableName={slug} tableMetaData={table} allTables={metaData.tables} processes={tableProcesses} metaData={metaData} />
|
||||
return <Suspense fallback={screenFallback}><RecordQuery key={slug} tableName={slug} tableMetaData={table} allTables={metaData.tables} processes={tableProcesses} metaData={metaData} /></Suspense>
|
||||
}
|
||||
|
||||
// Table loading state (table found but metadata not yet available)
|
||||
@@ -207,6 +212,9 @@ export default function SlugPage() {
|
||||
initialRequest.recordsParam = 'filterJSON'
|
||||
initialRequest.filterJSON = searchParams.get('filterJSON') ?? ''
|
||||
}
|
||||
// a process added to every screen (no table of its own) runs over the launching table
|
||||
const launchTable = searchParams.get('tableName')
|
||||
if (!process.tableName && launchTable && metaData.tables?.[launchTable]) initialRequest.tableName = launchTable
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// links may preset process inputs, as in the Material dashboard: //
|
||||
// ?defaultProcessValues={"name":"value"} //
|
||||
@@ -218,7 +226,10 @@ export default function SlugPage() {
|
||||
} catch {
|
||||
initialValues = undefined
|
||||
}
|
||||
return <ProcessRun key={`${slug}?${searchParams}`} processName={slug} processMetaData={process} initialRequest={initialRequest} initialValues={initialValues} />
|
||||
// a launch from a record or query screen returns there (Material closes its modal over that screen)
|
||||
const rawReturnTo = searchParams.get('returnTo')
|
||||
const returnTo = rawReturnTo ? safeReturnTo(rawReturnTo, undefined, '') || undefined : undefined
|
||||
return <Suspense fallback={screenFallback}><ProcessRun key={`${slug}?${searchParams}`} processName={slug} processMetaData={process} initialRequest={initialRequest} initialValues={initialValues} returnTo={returnTo} /></Suspense>
|
||||
}
|
||||
|
||||
// Process loading state (process found but metadata not yet available)
|
||||
@@ -232,7 +243,7 @@ export default function SlugPage() {
|
||||
|
||||
// Report run
|
||||
if (isReport && report) {
|
||||
return <ReportRun reportName={slug} reportMetaData={report} />
|
||||
return <Suspense fallback={screenFallback}><ReportRun reportName={slug} reportMetaData={report} /></Suspense>
|
||||
}
|
||||
|
||||
// Report loading state (report found but metadata not yet available)
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { lazy, Suspense, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
@@ -30,7 +30,8 @@ import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { RecordQuery } from '@/components/query'
|
||||
|
||||
const RecordQuery = lazy(() => import('@/components/query/RecordQuery').then((module) => ({ default: module.RecordQuery })))
|
||||
|
||||
/**
|
||||
* Saved view route: resolves the table and renders its query screen with the view applied.
|
||||
@@ -71,7 +72,7 @@ export default function SavedViewPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<RecordQuery
|
||||
<Suspense fallback={<div role="status" aria-label="Loading saved view" className="py-12 text-center">Loading saved view…</div>}><RecordQuery
|
||||
key={`${slug}-${id}`}
|
||||
tableName={slug}
|
||||
tableMetaData={table}
|
||||
@@ -79,6 +80,6 @@ export default function SavedViewPage() {
|
||||
processes={getProcessesForTable(metaData, slug)}
|
||||
metaData={metaData}
|
||||
savedViewId={id}
|
||||
/>
|
||||
/></Suspense>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import apiClient from '@/lib/api/client'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import { recordGet } from '@/mocks/v1-record'
|
||||
import EntityCreatePage from './create/page'
|
||||
import EntityEditPage from './[recordId]/edit/page'
|
||||
import EntityCopyPage from './[recordId]/copy/page'
|
||||
@@ -58,7 +59,7 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
it('edits the base without requesting denied children or replacing associations', async () => {
|
||||
const reads: string[] = []
|
||||
server.use(
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
recordGet('/table/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
reads.push(mode)
|
||||
return mode === 'false' ? HttpResponse.json({ tableName: 'person', values: {
|
||||
@@ -66,15 +67,15 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
} }) : HttpResponse.json({ error: 'Children denied' }, { status: 403 })
|
||||
}),
|
||||
)
|
||||
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 1, firstName: 'Updated' } }] })
|
||||
const put = vi.spyOn(apiClient, 'patch').mockResolvedValue({ record: { tableName: 'person', values: { id: 1, firstName: 'Updated' } } })
|
||||
renderPage(<EntityEditPage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Updated' } })
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/, {}, { timeout: 5000 }), { target: { value: 'Updated' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Email/ }), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/1'))
|
||||
expect(reads.length).toBeGreaterThan(0)
|
||||
expect(new Set(reads)).toEqual(new Set(['false']))
|
||||
expect(put.mock.calls[0][0]).toBe('/data/person/1')
|
||||
expect(put.mock.calls[0][0]).toBe('/table/person/1')
|
||||
const submitted = put.mock.calls[0][1] as FormData
|
||||
expect(submitted?.get('firstName')).toBe('Updated')
|
||||
expect(submitted?.get('email')).toBe('')
|
||||
@@ -83,13 +84,13 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
})
|
||||
|
||||
it('creates from full fields and unwraps the legacy envelope before navigating', async () => {
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 7 } }] })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: { id: 7 } } })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Created' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/7'))
|
||||
expect(post.mock.calls[0][0]).toBe('/data/person')
|
||||
expect(post.mock.calls[0][0]).toBe('/table/person')
|
||||
const submitted = post.mock.calls[0][1] as FormData
|
||||
expect(submitted?.get('firstName')).toBe('Created')
|
||||
expect(submitted?.has('associations')).toBe(false)
|
||||
@@ -111,13 +112,13 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
await pendingMetadata
|
||||
return HttpResponse.json(person)
|
||||
}),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
recordGet('/table/person/1', ({ request }) => {
|
||||
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
|
||||
reads.push(mode)
|
||||
return mode === 'false' ? HttpResponse.json(source) : HttpResponse.json({ error: 'Children denied' }, { status: 403 })
|
||||
}),
|
||||
)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 8 } }] })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: { id: 8 } } })
|
||||
renderPage(<EntityCopyPage />)
|
||||
await waitFor(() => expect(requestedMetadata).toBe(true))
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
@@ -153,7 +154,7 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
person.fields[name] = { ...person.fields.firstName, name, label: name, type, isRequired: false }
|
||||
person.sections[0].fieldNames.push(name)
|
||||
}
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: { id: 9 } } })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Numeric' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
@@ -181,7 +182,7 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
|
||||
fireEvent.click(saving)
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { finish({ records: [{ tableName: 'person', values: { id: 8 } }] }) })
|
||||
await act(async () => { finish({ record: { tableName: 'person', values: { id: 8 } } }) })
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/8'))
|
||||
})
|
||||
|
||||
@@ -189,7 +190,7 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
const person = fixture()
|
||||
person.primaryKeyField = 'recordCode'
|
||||
person.fields.recordCode = { ...person.fields.firstName, name: 'recordCode', label: 'Code', isEditable: false }
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { recordCode: 'A/B#1' } }] })
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: { recordCode: 'A/B#1' } } })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Natural' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Key' } })
|
||||
@@ -198,7 +199,7 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
})
|
||||
|
||||
it('does not announce a saved record when the response has no usable primary key', async () => {
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: {} }] })
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: {} } })
|
||||
renderPage(<EntityCreatePage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Keep input' } })
|
||||
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
|
||||
@@ -226,7 +227,7 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
const person = fixture()
|
||||
person[permission] = false
|
||||
const read = vi.fn()
|
||||
server.use(http.get('/data/person/1', () => {
|
||||
server.use(recordGet('/table/person/1', () => {
|
||||
read()
|
||||
return HttpResponse.json({ tableName: 'person', values: { id: 1, firstName: 'Must not load' } })
|
||||
}))
|
||||
@@ -238,7 +239,7 @@ describe('Create and base edit use full metadata and actual write contracts', ()
|
||||
})
|
||||
|
||||
it('shows a failed base-copy source read without offering a blank create form', async () => {
|
||||
server.use(http.get('/data/person/1', () => HttpResponse.json({ error: 'Source denied' }, { status: 403 })))
|
||||
server.use(recordGet('/table/person/1', () => HttpResponse.json({ error: 'Source denied' }, { status: 403 })))
|
||||
renderPage(<EntityCopyPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('You do not have permission to view People records')
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
@@ -264,12 +265,12 @@ describe('Explicit full copy', () => {
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(parent)),
|
||||
http.get('/qqq/v1/metaData/table/child', () => HttpResponse.json(child)),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
recordGet('/table/person/1', ({ request }) => {
|
||||
const expanded = new URL(request.url).searchParams.get('includeAssociations') === 'true'; reads.push(String(expanded))
|
||||
return HttpResponse.json({ tableName: 'person', values, ...(expanded ? { associatedRecords: { 'care / primary': [originalChild] } } : {}) })
|
||||
}),
|
||||
)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 }, associatedRecords: { 'care / primary': [{ tableName: 'child', values: { manualKey: 'fresh-child', owner: 9 } }] } }] })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: { id: 9 }, associatedRecords: { 'care / primary': [{ tableName: 'child', values: { manualKey: 'fresh-child', owner: 9 } }] } } })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Edited parent' } })
|
||||
expect(reads).toEqual(['false'])
|
||||
@@ -306,7 +307,7 @@ describe('Full copy failures stay visible and cannot save a base-only fallback',
|
||||
server.use(
|
||||
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(parent)),
|
||||
http.get('/qqq/v1/metaData/table/child', () => problem.startsWith('metadata') ? HttpResponse.json({ error: 'Metadata unavailable' }, { status: problem === 'metadata-server' ? 500 : 403 }) : HttpResponse.json(child)),
|
||||
http.get('/data/person/1', ({ request }) => {
|
||||
recordGet('/table/person/1', ({ request }) => {
|
||||
if (new URL(request.url).searchParams.get('includeAssociations') !== 'true') return HttpResponse.json({ tableName: 'person', values })
|
||||
if (problem.startsWith('read')) return HttpResponse.json({ error: 'Read unavailable' }, { status: problem === 'read-server' ? 500 : 403 })
|
||||
return HttpResponse.json({ tableName: 'person', values, ...(problem === 'group' ? {} : { associatedRecords: { children: [{ tableName: 'child', values: { id: 2, owner: 1, ...(problem === 'value' ? {} : { firstName: problem === 'numeric' ? 5 : 'Child' }) } }] } }) })
|
||||
@@ -327,7 +328,7 @@ describe('Full copy failures stay visible and cannot save a base-only fallback',
|
||||
})
|
||||
it('does not announce success when a saved response omits a nonempty requested group', async () => {
|
||||
sources('none')
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: { id: 9 } } })
|
||||
renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
@@ -339,7 +340,7 @@ describe('Full copy failures stay visible and cannot save a base-only fallback',
|
||||
})
|
||||
it('ignores a disabled hidden numeric child draft in Base mode while Full still checks it', async () => {
|
||||
sources('numeric')
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: { id: 9 } } })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
@@ -362,7 +363,7 @@ describe('Full copy failures stay visible and cannot save a base-only fallback',
|
||||
|
||||
it.each(['read', 'metadata'])('explains %s denial and lets the user explicitly save a Base Copy', async problem => {
|
||||
sources(problem)
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'person', values: { id: 9 } } })
|
||||
const view = renderPage(<EntityCopyPage />)
|
||||
await screen.findByLabelText(/Preferred given name/)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Search page — every page (app, table, process, report) and recently viewed record matching `?q=`.
|
||||
* @file Search page — every page (app, table, process, report) and recently viewed record matching
|
||||
* `?q=`, plus the records a backend record search finds when the metadata advertises searchable tables.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -30,20 +31,26 @@ import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { useRecordSearch } from '@/lib/hooks/use-record-search'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
|
||||
import { searchableTables } from '@/lib/utils/record-search'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/** Records listed per table on the search page. */
|
||||
const PAGE_RECORDS_PER_TABLE = 25
|
||||
|
||||
/**
|
||||
* Renders the search page at `/app/search`.
|
||||
*
|
||||
* Matching is local (labels of navigable app-tree nodes and recently viewed
|
||||
* records); QQQ has no global record-search endpoint. The `q` parameter is kept
|
||||
* in the URL so results are shareable and survive refresh.
|
||||
* Pages and recently viewed records match locally by label. When the metadata
|
||||
* lists searchable tables, the records a backend record search finds are listed
|
||||
* too; otherwise no search request is made. The `q` parameter is kept in the URL
|
||||
* so results are shareable and survive refresh.
|
||||
*
|
||||
* @returns The search input and matching pages and records.
|
||||
*/
|
||||
@@ -74,12 +81,16 @@ export default function SearchResultsPage() {
|
||||
setPageHeader('Search')
|
||||
}, [setPageHeader])
|
||||
|
||||
const searchTables = useMemo(() => searchableTables(metaData), [metaData])
|
||||
const recordSearch = useRecordSearch(term, searchTables, PAGE_RECORDS_PER_TABLE)
|
||||
const items = useMemo(
|
||||
() => (term.trim() ? buildNavigationSearchItems(navTargets, recentRecords, term, 50) : []),
|
||||
[navTargets, recentRecords, term]
|
||||
() => (term.trim() ? buildNavigationSearchItems(navTargets, recentRecords, term, 50, recordSearch.results) : []),
|
||||
[navTargets, recentRecords, term, recordSearch.results]
|
||||
)
|
||||
const pages = items.filter((item) => item.kind === 'page')
|
||||
const found = items.filter((item) => item.kind === 'result')
|
||||
const records = items.filter((item) => item.kind === 'record')
|
||||
const searching = recordSearch.available && recordSearch.isSearching
|
||||
|
||||
/**
|
||||
* Writes the input value to the `q` URL parameter.
|
||||
@@ -104,8 +115,8 @@ export default function SearchResultsPage() {
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
placeholder="Find an app, table, process or recent record..."
|
||||
aria-label="Search pages and recent records"
|
||||
placeholder={recordSearch.available ? 'Find an app, table, process or record...' : 'Find an app, table, process or recent record...'}
|
||||
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
className="w-full rounded-xl border border-input bg-card py-3 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id="search-input"
|
||||
/>
|
||||
@@ -117,9 +128,14 @@ export default function SearchResultsPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{term && items.length === 0 && (
|
||||
{/* Record search progress and errors, announced politely */}
|
||||
<p role="status" aria-live="polite" className={searching || (recordSearch.available && recordSearch.isError) ? 'text-sm text-muted-foreground' : 'sr-only'} data-qqq-id="search-records-status">
|
||||
{searching ? 'Searching records…' : recordSearch.available && recordSearch.isError ? 'Record search failed. Try again.' : ''}
|
||||
</p>
|
||||
|
||||
{term && items.length === 0 && !searching && (
|
||||
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-empty-state">
|
||||
No pages or recent records match “{term}”.
|
||||
{recordSearch.available ? 'No pages or records match' : 'No pages or recent records match'} “{term}”.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -140,13 +156,30 @@ export default function SearchResultsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{found.length > 0 && (
|
||||
<section aria-label="Records" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-found-records">
|
||||
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Records</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{found.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-record-${item.path.replace(/\//g, '-')}`}>
|
||||
<span className="font-medium text-foreground">{item.label}</span>
|
||||
{item.context && <span className="text-xs text-muted-foreground">{item.context}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{records.length > 0 && (
|
||||
<section aria-label="Recently viewed" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-records">
|
||||
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Recently viewed</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{records.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent">
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-recent-${item.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={item.icon} kind="table" className="text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.context}</span>
|
||||
</Link>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
/** Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts. */
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
@@ -31,10 +31,19 @@ import { useQContext, QContextProvider } from '@/lib/context/q-context'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { useDocumentTitle } from '@/lib/hooks/use-document-title'
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { applyBrandingTheme } from '@/lib/theme/apply-branding'
|
||||
import { readMaterialTheme } from '@/lib/theme/material-theme'
|
||||
import { useTheme } from '@/lib/theme/theme-provider'
|
||||
import { recordAnalytics } from '@/lib/analytics'
|
||||
import { useAnalytics } from '@/lib/analytics/use-analytics'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { searchableTables } from '@/lib/utils/record-search'
|
||||
import { scrollPageToTop } from '@/lib/utils/scroll-to-top'
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import Header from '@/components/layout/Header'
|
||||
import BannerComponent from '@/components/layout/Banner'
|
||||
import BrandedHeaderBar from '@/components/layout/BrandedHeaderBar'
|
||||
import CompanyFooter from '@/components/layout/CompanyFooter'
|
||||
import { buildBreadcrumbs, buildDocumentTitle } from '@/components/layout/Breadcrumbs'
|
||||
import { CommandMenu } from '@/components/feedback/CommandMenu'
|
||||
import { SearchDialog } from '@/components/feedback/SearchDialog'
|
||||
@@ -48,7 +57,8 @@ import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcuts
|
||||
* - Fetches full application metadata via TanStack Query (30-minute stale time).
|
||||
* - Generates sidebar routes and path-to-label map from the app tree.
|
||||
* - Syncs branding, accent color, favicon, and document title to the DOM.
|
||||
* - Sanitizes and injects `customCss` from branding metadata.
|
||||
* - Applies the application theme (tokens, rules, customCss) and the branded header bar.
|
||||
* - Configures analytics and records a page view per screen.
|
||||
* - Redirects unauthenticated users to `/login`.
|
||||
* - Registers global keyboard shortcuts: Cmd+K (command palette), `/` (search), `?` (help).
|
||||
* - Renders the desktop sidebar, mobile sidebar drawer, banners, header, and main content slot.
|
||||
@@ -64,6 +74,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Mobile sidebar drawer state
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const helpButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Command palette state
|
||||
const [commandOpen, setCommandOpen] = useState(false)
|
||||
@@ -89,6 +101,14 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
// Generate sidebar routes and path map from app tree
|
||||
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets } = useAppTreeRoutes(metaData)
|
||||
|
||||
// Every route change starts at the top of the page (Material App scrolls to 0 on each pathname)
|
||||
useEffect(() => {
|
||||
scrollPageToTop()
|
||||
}, [pathname])
|
||||
|
||||
// Tables the backend record search covers for this user (none: search stays local)
|
||||
const searchTables = useMemo(() => searchableTables(metaData), [metaData])
|
||||
|
||||
// Sync pathToLabelMap to QContext
|
||||
useEffect(() => {
|
||||
if (Object.keys(pathToLabelMap).length > 0) {
|
||||
@@ -100,46 +120,9 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (metaData?.branding) {
|
||||
setBranding(metaData.branding)
|
||||
|
||||
// MED-3: validate color format before applying to CSS custom properties
|
||||
const ACCENT_COLOR_RE = /^#[0-9a-fA-F]{3,8}$|^rgb\(|^rgba\(|^hsl\(|^hsla\(/
|
||||
if (metaData.branding.accentColor && ACCENT_COLOR_RE.test(metaData.branding.accentColor)) {
|
||||
setAccentColor(metaData.branding.accentColor)
|
||||
document.documentElement.style.setProperty(
|
||||
'--qqq-accent-color',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--color-primary',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--ring',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--qqq-sidebar-active-bg',
|
||||
metaData.branding.accentColor
|
||||
)
|
||||
}
|
||||
|
||||
if (metaData.branding.accentColorLight && ACCENT_COLOR_RE.test(metaData.branding.accentColorLight)) {
|
||||
document.documentElement.style.setProperty('--qqq-accent-color-light', metaData.branding.accentColorLight)
|
||||
}
|
||||
|
||||
// Favicon and touch icon from branding (as Material Dashboard does)
|
||||
if (metaData.branding.icon) {
|
||||
for (const selector of ["link[rel~='icon']", "link[rel~='apple-touch-icon']"]) {
|
||||
const linkEl = document.querySelector(selector)
|
||||
if (linkEl instanceof HTMLLinkElement) {
|
||||
linkEl.href = metaData.branding.icon
|
||||
}
|
||||
}
|
||||
}
|
||||
// accent colors (validated, MED-3) and the favicon
|
||||
const accentColor = applyBrandingTheme(metaData.branding)
|
||||
if (accentColor) setAccentColor(accentColor)
|
||||
}
|
||||
}, [metaData?.branding, setBranding, setAccentColor])
|
||||
|
||||
@@ -153,30 +136,15 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
: undefined
|
||||
useDocumentTitle(documentTitle)
|
||||
|
||||
// Inject customCss from branding metadata
|
||||
// MED-2: strip known CSS injection vectors before applying
|
||||
useEffect(() => {
|
||||
if (metaData?.branding?.customCss) {
|
||||
const safe = metaData.branding.customCss
|
||||
.replace(/<\/?\s*style[^>]*>/gi, '') // no embedded style tags
|
||||
.replace(/expression\s*\(/gi, '') // no IE CSS expressions
|
||||
.replace(/@import\b/gi, '') // no @import directives
|
||||
.replace(/javascript\s*:/gi, '') // no javascript: scheme
|
||||
.replace(/url\s*\(\s*["']?\s*data:/gi, '') // no data: URLs
|
||||
// Application theme (MaterialDashboardThemeMetaData, QRun-IO/qqq#719): tokens, rules and its
|
||||
// customCss; removed again when the dashboard unmounts (sign-out)
|
||||
const { theme, setTheme } = useTheme()
|
||||
const applicationTheme = useMemo(() => readMaterialTheme(metaData), [metaData])
|
||||
useEffect(() => { setTheme(applicationTheme) }, [applicationTheme, setTheme])
|
||||
useEffect(() => () => setTheme(null), [setTheme])
|
||||
|
||||
const styleId = 'qqq-custom-css'
|
||||
const existingStyleEl = document.getElementById(styleId)
|
||||
let styleTag: HTMLStyleElement
|
||||
if (existingStyleEl instanceof HTMLStyleElement) {
|
||||
styleTag = existingStyleEl
|
||||
} else {
|
||||
styleTag = document.createElement('style')
|
||||
styleTag.id = styleId
|
||||
document.head.appendChild(styleTag)
|
||||
}
|
||||
styleTag.textContent = safe
|
||||
}
|
||||
}, [metaData?.branding?.customCss])
|
||||
// Analytics (QRun-IO/qqq#730): off unless configured; one page view per screen
|
||||
useAnalytics(metaData, pathname, user?.email ?? user?.name)
|
||||
|
||||
// Sync user ID to QContext
|
||||
useEffect(() => {
|
||||
@@ -228,13 +196,14 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
setCommandOpen(false)
|
||||
setSearchOpen(false)
|
||||
setHelpOpen(false)
|
||||
setSidebarOpen(false)
|
||||
// The navigation drawer is a modal dialog and handles its own Escape
|
||||
}
|
||||
|
||||
// Single-key shortcuts — only when not focused in a text field
|
||||
if (!isInputFocused() && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
if (e.key === '.') {
|
||||
e.preventDefault()
|
||||
recordAnalytics({ category: 'globalEvents', action: 'dotMenuKeyboardShortcut' })
|
||||
setCommandOpen(true)
|
||||
}
|
||||
if (e.key === '/') {
|
||||
@@ -289,11 +258,12 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background">
|
||||
{/* Top-of-site banner spans the whole window, above the sidebar */}
|
||||
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_SITE" className="border-x-0 border-t-0" />
|
||||
<BrandedHeaderBar theme={theme} />
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{/* Skip to main content — accessibility */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:inline-flex focus:items-center pointer-coarse:focus:min-h-11 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
data-qqq-id="skip-to-content"
|
||||
>
|
||||
Skip to main content
|
||||
@@ -318,6 +288,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
userEmail={user?.email}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
returnFocusRef={menuButtonRef}
|
||||
data-qqq-id="sidebar-mobile"
|
||||
/>
|
||||
|
||||
@@ -327,11 +298,16 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<Header
|
||||
appName={metaData?.branding?.appName}
|
||||
onMenuOpen={() => setSidebarOpen(true)}
|
||||
menuOpen={sidebarOpen}
|
||||
menuButtonRef={menuButtonRef}
|
||||
onSearchOpen={() => setSearchOpen(true)}
|
||||
onCommandOpen={() => setCommandOpen(true)}
|
||||
onHelpOpen={() => setHelpOpen(true)}
|
||||
helpButtonRef={helpButtonRef}
|
||||
pathToLabelMap={pathToLabelMap}
|
||||
ancestorAppMap={ancestorAppMap}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
@@ -349,6 +325,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<>
|
||||
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_BODY" className="mb-4 rounded-lg" />
|
||||
{children}
|
||||
<CompanyFooter branding={metaData?.branding} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
@@ -356,13 +333,13 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} navTargets={navTargets} />
|
||||
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} navTargets={navTargets} metaData={metaData} />
|
||||
|
||||
{/* Search Dialog */}
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} navTargets={navTargets} />
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} navTargets={navTargets} searchTables={searchTables} />
|
||||
|
||||
{/* Keyboard Shortcuts Help Dialog */}
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} returnFocusRef={helpButtonRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+22
-17
@@ -29,6 +29,8 @@
|
||||
import Link from 'next/link'
|
||||
import { SearchX } from 'lucide-react'
|
||||
|
||||
import { LegacyPathRedirect } from '@/components/layout/LegacyPathRedirect'
|
||||
|
||||
/**
|
||||
* Full-screen 404 page shown for unknown routes or when `notFound()` is called.
|
||||
*
|
||||
@@ -36,24 +38,27 @@ import { SearchX } from 'lucide-react'
|
||||
*/
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<SearchX className="h-16 w-16 text-muted-foreground" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-6xl font-bold text-foreground">404</h1>
|
||||
<p className="mt-2 text-xl text-muted-foreground">Page not found</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
// A Material dashboard URL (nested under its apps) opens at its path here instead
|
||||
<LegacyPathRedirect>
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<SearchX className="h-16 w-16 text-muted-foreground" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-6xl font-bold text-foreground">404</h1>
|
||||
<p className="mt-2 text-xl text-muted-foreground">Page not found</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 pointer-coarse:min-h-11 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="link-go-home"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 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="link-go-home"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</LegacyPathRedirect>
|
||||
)
|
||||
}
|
||||
+21
-6
@@ -15,13 +15,15 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Root page — redirects the application root to the dashboard.
|
||||
* @file Root page — handles the Material Auth0 callback or opens the dashboard.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Suspense, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import CallbackContent from './(auth)/callback/CallbackContent'
|
||||
|
||||
/**
|
||||
* Root page that immediately redirects to `/app`.
|
||||
@@ -32,10 +34,23 @@ import { useRouter } from 'next/navigation'
|
||||
*
|
||||
* @returns Nothing; navigation replaces this page.
|
||||
*/
|
||||
export default function RootPage() {
|
||||
function RootContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const isCallback = searchParams.has('code') || searchParams.has('error')
|
||||
useEffect(() => {
|
||||
router.replace('/app')
|
||||
}, [router])
|
||||
if (!isCallback) router.replace('/app')
|
||||
}, [isCallback, router])
|
||||
if (isCallback) {
|
||||
return <main className="flex min-h-screen items-center justify-center bg-muted p-4"><CallbackContent /></main>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the root route in the search parameter boundary required by static export.
|
||||
* @returns The root route.
|
||||
*/
|
||||
export default function RootPage() {
|
||||
return <Suspense fallback={null}><RootContent /></Suspense>
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import { queryClient } from '@/lib/query-client'
|
||||
import { AuthProvider } from '@/lib/auth/auth-provider'
|
||||
import { ThemeProvider } from '@/lib/theme/theme-provider'
|
||||
import { injectThemeTokens } from '@/lib/theme/tokens'
|
||||
import { Toaster } from '@/components/feedback/Toast'
|
||||
import { AppToaster } from '@/components/feedback/Toast'
|
||||
|
||||
// Inject default theme tokens on first render
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -81,14 +81,7 @@ export default function Providers({ children }: { children: ReactNode }) {
|
||||
<ThemeProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{ duration: 4000 }}
|
||||
aria-live="polite"
|
||||
data-qqq-id="toast-container"
|
||||
/>
|
||||
<AppToaster />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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 DataBagEditorDialog — Material's `DataBagDataEditor`: edit a data bag's JSON with a
|
||||
* live preview tree, and save it as a new, current version (`storeDataBagVersion`). Invalid
|
||||
* JSON is never sent. Like Material, the editor does not close on Esc or a click outside it.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
|
||||
import { storeDataBagVersion } from '@/lib/api/developer'
|
||||
import { toast } from '@/lib/hooks/use-toast'
|
||||
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
|
||||
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { CodeEditor } from '@/components/scripts/CodeEditor'
|
||||
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS } from '@/components/scripts/ScriptPanels'
|
||||
import { domId } from '@/components/scripts/script-utils'
|
||||
import { JsonPreview } from './JsonPreview'
|
||||
|
||||
/** Props for {@link DataBagEditorDialog}. */
|
||||
export interface DataBagEditorDialogProps {
|
||||
/** Suffix of the dialog's DOM ids and `data-qqq-id`s (the widget name). */
|
||||
idKey: string
|
||||
/** Dialog title. */
|
||||
title: string
|
||||
/** Data bag to add a version to. */
|
||||
dataBagId: string | number
|
||||
/** Contents to start from (the selected version), empty for a data bag without versions. */
|
||||
initialData: string
|
||||
/** Closes the dialog without saving. */
|
||||
onClose: () => void
|
||||
/** Called after the new version was stored. */
|
||||
onSaved: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The data bag JSON editor dialog.
|
||||
*
|
||||
* @param props - {@link DataBagEditorDialogProps}
|
||||
* @returns The dialog.
|
||||
*/
|
||||
export function DataBagEditorDialog({ idKey, title, dataBagId, initialData, onClose, onSaved }: DataBagEditorDialogProps) {
|
||||
const [data, setData] = useState(initialData)
|
||||
const [commitMessage, setCommitMessage] = useState('')
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const restoreFocus = useRestoreFocus(true)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => storeDataBagVersion({ dataBagId, data, commitMessage }),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
onSuccess: () => onSaved(),
|
||||
onError: (saveError) => {
|
||||
const message = getErrorMessage(saveError)
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
},
|
||||
})
|
||||
|
||||
/** Blocks invalid JSON, then stores the new version. */
|
||||
function handleSave() {
|
||||
try {
|
||||
JSON.parse(data)
|
||||
} catch (parseError) {
|
||||
setError(`Cannot save Data Bag Contents. Invalid json: ${parseError instanceof Error ? parseError.message : String(parseError)}`)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
const commitId = `data-bag-commit-message-${domId(idKey)}`
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 dark:bg-black/70" />
|
||||
<DialogPrimitive.Content
|
||||
onCloseAutoFocus={restoreFocus}
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 flex max-h-[92vh] w-[calc(100%-2rem)] max-w-5xl -translate-x-1/2 -translate-y-1/2 flex-col',
|
||||
'rounded-lg border border-border bg-card shadow-lg focus:outline-none'
|
||||
)}
|
||||
data-qqq-id={`dialog-data-bag-editor-${idKey}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border p-4">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold text-foreground">{title}</DialogPrimitive.Title>
|
||||
<div className="flex items-center gap-2">
|
||||
<div role="group" aria-label="Tools" className="flex items-center gap-1">
|
||||
<span className="text-sm text-muted-foreground" aria-hidden="true">Tools:</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={preview}
|
||||
className={cn(BUTTON_CLASS, preview && 'border-primary bg-accent')}
|
||||
onClick={() => setPreview((current) => !current)}
|
||||
data-qqq-id={`button-data-bag-preview-${idKey}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close dialog"
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-auto p-4">
|
||||
<CodeEditor
|
||||
id={`data-bag-editor-${domId(idKey)}`}
|
||||
ariaLabel="Data bag contents"
|
||||
value={data}
|
||||
onChange={setData}
|
||||
language="json"
|
||||
rows={preview ? 10 : 18}
|
||||
dataQqqId={`data-bag-editor-code-${idKey}`}
|
||||
/>
|
||||
{preview && (
|
||||
<section aria-label="Preview" className="max-h-72 overflow-auto rounded-md border border-border p-2">
|
||||
<JsonPreview json={data} idKey={`${idKey}-editor`} />
|
||||
</section>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={commitId} className="block text-sm font-medium text-foreground">Commit Message</label>
|
||||
<input
|
||||
id={commitId}
|
||||
type="text"
|
||||
maxLength={250}
|
||||
className={INPUT_CLASS}
|
||||
value={commitMessage}
|
||||
onChange={(event) => setCommitMessage(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && <p role="alert" className={ALERT_CLASS} data-qqq-id={`data-bag-editor-error-${idKey}`}>{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-lg border-t border-border bg-muted px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className={BUTTON_CLASS}
|
||||
onClick={onClose}
|
||||
disabled={save.isPending}
|
||||
data-qqq-id={`button-cancel-data-bag-${idKey}`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
disabled={save.isPending}
|
||||
onClick={handleSave}
|
||||
data-qqq-id={`button-save-data-bag-${idKey}`}
|
||||
>
|
||||
{save.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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 JsonPreview — Material's `DataBagPreview`: a JSON value as an expandable tree. Objects
|
||||
* and arrays are disclosure buttons (keyboard operable, `aria-expanded`), collapsed at first;
|
||||
* other values show as `key: value`. Text that is not valid JSON shows the parse error.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/** Props for {@link JsonPreview}. */
|
||||
export interface JsonPreviewProps {
|
||||
/** JSON text to parse and show. */
|
||||
json: string
|
||||
/** Suffix of the toggles' `data-qqq-id`s. */
|
||||
idKey: string
|
||||
/** Extra classes for the wrapper. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value opens as a branch of the tree.
|
||||
* @param value - A parsed JSON value.
|
||||
* @returns True for objects and arrays (not null).
|
||||
*/
|
||||
function isBranch(value: unknown): value is Record<string, unknown> | unknown[] {
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Display text of a JSON leaf value.
|
||||
* @param value - A parsed JSON value (string, number, boolean or null).
|
||||
* @returns The text Material shows (`String(value)`).
|
||||
*/
|
||||
function leafText(value: unknown): string {
|
||||
return value === null ? 'null' : String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The expandable tree of a JSON document.
|
||||
*
|
||||
* @param props - {@link JsonPreviewProps}
|
||||
* @returns The tree, or the parse error.
|
||||
*/
|
||||
export function JsonPreview({ json, idKey, className }: JsonPreviewProps) {
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set())
|
||||
const parsed = useMemo((): { value?: unknown; error?: string } => {
|
||||
try {
|
||||
return { value: JSON.parse(json) }
|
||||
} catch (error) {
|
||||
return { error: `Error parsing JSON: ${error instanceof Error ? error.message : String(error)}` }
|
||||
}
|
||||
}, [json])
|
||||
|
||||
if (parsed.error) {
|
||||
return <p role="alert" className={cn('p-2 text-sm text-destructive', className)} data-qqq-id={`json-preview-error-${idKey}`}>{parsed.error}</p>
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens or closes a branch.
|
||||
* @param path - The branch's path.
|
||||
*/
|
||||
function toggle(path: string) {
|
||||
setOpen((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(path)) next.delete(path)
|
||||
else next.add(path)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One level of the tree.
|
||||
* @param value - An object or array.
|
||||
* @param path - Path of this level (`""` at the root).
|
||||
* @returns The list of entries.
|
||||
*/
|
||||
function renderLevel(value: Record<string, unknown> | unknown[], path: string): React.ReactNode {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length === 0) return <p className="ml-6 text-sm text-muted-foreground">{Array.isArray(value) ? '(empty list)' : '(empty)'}</p>
|
||||
return (
|
||||
<ul className="ml-3 space-y-0.5">
|
||||
{entries.map(([key, child]) => {
|
||||
const childPath = `${path}.${key}`
|
||||
const expanded = open.has(childPath)
|
||||
return (
|
||||
<li key={key} className="text-sm">
|
||||
{isBranch(child) ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggle(childPath)}
|
||||
className="inline-flex items-center gap-1 rounded text-left font-medium text-foreground hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`json-preview-toggle-${idKey}-${childPath.slice(1)}`}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" aria-hidden="true" /> : <ChevronRight className="h-4 w-4" aria-hidden="true" />}
|
||||
{key}:
|
||||
</button>
|
||||
{expanded && renderLevel(child, childPath)}
|
||||
</>
|
||||
) : (
|
||||
<span data-qqq-id={`json-preview-value-${idKey}-${childPath.slice(1)}`}>
|
||||
<span className="inline-block w-5 text-center text-muted-foreground" aria-hidden="true">•</span>
|
||||
<span className="font-medium text-foreground">{key}:</span>
|
||||
{' '}
|
||||
<span className="break-all text-foreground">{leafText(child)}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('font-mono', className)} data-qqq-id={`json-preview-${idKey}`}>
|
||||
{isBranch(parsed.value)
|
||||
? renderLevel(parsed.value, '')
|
||||
: <p className="text-sm text-foreground">{leafText(parsed.value)}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the ESB management actions
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EsbCapabilities, EsbPermissions } from '@/types'
|
||||
import { processInit, processStatus } from '@/lib/api/processes'
|
||||
import { orderEsb } from '@/mocks/fixtures/esb'
|
||||
import {
|
||||
EsbQueueActions,
|
||||
EsbTriggerActions,
|
||||
destinationQueue,
|
||||
type EsbQueueRef,
|
||||
} from './EsbActions'
|
||||
|
||||
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
|
||||
vi.mock('sonner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('sonner')>()),
|
||||
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
|
||||
const [fulfillTrigger, cancelTrigger] = orderEsb.subscribers
|
||||
const orderFulfillment = orderEsb.publications[1].destination
|
||||
|
||||
const NONE: EsbPermissions = { canOperate: false, canDelete: false }
|
||||
const ALL: EsbPermissions = { canOperate: true, canDelete: true }
|
||||
const NO_CAPABILITIES: EsbCapabilities = {
|
||||
browse: false,
|
||||
pauseQueue: false,
|
||||
purge: false,
|
||||
deleteSelected: false,
|
||||
deleteOlderThan: false,
|
||||
move: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a component inside a fresh query client.
|
||||
*
|
||||
* @param ui - The element to render.
|
||||
*/
|
||||
function renderWithClient(ui: React.ReactElement): void {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker queue behind the `orderFulfillment` destination.
|
||||
*
|
||||
* @returns Its queue reference.
|
||||
*/
|
||||
function fulfillmentQueue(): EsbQueueRef {
|
||||
const queue = destinationQueue(orderFulfillment)
|
||||
if (!queue) throw new Error('orderFulfillment is a queue')
|
||||
return queue
|
||||
}
|
||||
|
||||
describe('EsbActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'COMPLETE',
|
||||
processUUID: 'uuid',
|
||||
values: { count: 1, message: 'Done.' },
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no actions without esbOperate or esbDelete', () => {
|
||||
renderWithClient(
|
||||
<>
|
||||
<EsbTriggerActions trigger={fulfillTrigger} permissions={NONE} />
|
||||
<EsbQueueActions queue={fulfillmentQueue()} permissions={NONE} />
|
||||
</>
|
||||
)
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows no queue actions the broker does not support', () => {
|
||||
renderWithClient(
|
||||
<EsbQueueActions
|
||||
queue={{ ...fulfillmentQueue(), capabilities: NO_CAPABILITIES }}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('needs esbOperate for pausing a queue and esbDelete for purging and deleting', () => {
|
||||
const { unmount } = render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<EsbQueueActions
|
||||
queue={fulfillmentQueue()}
|
||||
permissions={{ canOperate: true, canDelete: false }}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Pause queue orderFulfillment' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Purge orderFulfillment' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete older messages in orderFulfillment' })
|
||||
).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderWithClient(
|
||||
<EsbQueueActions
|
||||
queue={fulfillmentQueue()}
|
||||
permissions={{ canOperate: false, canDelete: true }}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Purge orderFulfillment' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers resume instead of pause for a paused queue', () => {
|
||||
renderWithClient(
|
||||
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: true }} permissions={ALL} />
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Resume queue orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers pause for a running trigger and resume for a paused one', () => {
|
||||
renderWithClient(
|
||||
<>
|
||||
<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />
|
||||
<EsbTriggerActions trigger={cancelTrigger} permissions={ALL} />
|
||||
</>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Pause Fulfill Order' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /^Restart/ })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('pauses a trigger with esbPauseTrigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Pause Fulfill Order' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbPauseTrigger', {
|
||||
values: { triggerName: 'fulfillOrder.orderFulfillment' },
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Done.'))
|
||||
})
|
||||
|
||||
it.each([1, null])('confirms replay-all with a safe count of %s', async (count) => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbTriggerActions
|
||||
trigger={{
|
||||
...fulfillTrigger,
|
||||
deadLetter: { ...fulfillTrigger.deadLetter, messageCount: count },
|
||||
}}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Replay dead letters for Fulfill Order' }))
|
||||
let dialog = await screen.findByRole('alertdialog')
|
||||
expect(dialog).toHaveTextContent('orderFulfillment.dlq')
|
||||
expect(dialog).toHaveTextContent(
|
||||
count === null ? 'all dead letters (count unknown)' : '1 message'
|
||||
)
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Replay dead letters for Fulfill Order' }))
|
||||
dialog = await screen.findByRole('alertdialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Replay dead letters' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbReplayDeadLetters', {
|
||||
values: { triggerName: 'fulfillOrder.orderFulfillment', all: true },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not purge until the dialog naming the queue and message count is confirmed', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbQueueActions queue={fulfillmentQueue()} permissions={ALL} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Purge orderFulfillment' }))
|
||||
let dialog = await screen.findByRole('alertdialog')
|
||||
expect(dialog).toHaveTextContent('orderFulfillment')
|
||||
expect(dialog).toHaveTextContent('7 messages')
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Purge orderFulfillment' }))
|
||||
dialog = await screen.findByRole('alertdialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Purge queue' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbPurgeQueue', {
|
||||
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes messages older than the chosen time after confirmation', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbQueueActions queue={fulfillmentQueue()} permissions={ALL} />)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })
|
||||
)
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
expect(dialog).toHaveTextContent('orderFulfillment')
|
||||
expect(dialog).toHaveTextContent('7 messages')
|
||||
const confirm = within(dialog).getByRole('button', { name: 'Delete messages' })
|
||||
expect(confirm).toBeDisabled()
|
||||
|
||||
await user.type(within(dialog).getByLabelText('Older than'), '2026-09-25T10:00')
|
||||
await user.click(confirm)
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbDeleteMessages', {
|
||||
values: {
|
||||
providerName: 'artemis',
|
||||
brokerQueueName: 'orderFulfillment',
|
||||
olderThan: new Date('2026-09-25T10:00').toISOString(),
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('pauses a broker queue with esbPauseQueue', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbQueueActions queue={fulfillmentQueue()} permissions={ALL} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Pause queue orderFulfillment' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbPauseQueue', {
|
||||
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the process error when an action fails', async () => {
|
||||
vi.mocked(processInit).mockResolvedValue({ type: 'ERROR', error: 'Broker unavailable' })
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Restart Fulfill Order' }))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Broker unavailable'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Resume Cancel Order', 'esbResumeTrigger', 'cancelOrder.orderEvents'],
|
||||
['Restart Fulfill Order', 'esbRestartTrigger', 'fulfillOrder.orderFulfillment'],
|
||||
])('sends the exact trigger input for %s', async (label, processName, triggerName) => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbTriggerActions
|
||||
trigger={triggerName === cancelTrigger.name ? cancelTrigger : fulfillTrigger}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith(processName, { values: { triggerName } })
|
||||
)
|
||||
})
|
||||
|
||||
it('sends provider and broker queue when resuming delivery', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: true }} permissions={ALL} />
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Resume queue orderFulfillment' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbResumeQueue', {
|
||||
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('hides replay for an empty dead-letter queue and pause when queue state is unknown', () => {
|
||||
renderWithClient(
|
||||
<>
|
||||
<EsbTriggerActions
|
||||
trigger={{
|
||||
...fulfillTrigger,
|
||||
deadLetter: { ...fulfillTrigger.deadLetter, messageCount: 0 },
|
||||
}}
|
||||
permissions={ALL}
|
||||
/>
|
||||
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: null }} permissions={ALL} />
|
||||
</>
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /Replay/ })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Pause queue|Resume queue/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['COMPLETE', 'ERROR'] as const)(
|
||||
'keeps controls pending and reports only the final async %s outcome',
|
||||
async (type) => {
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'JOB_STARTED',
|
||||
processUUID: 'uuid',
|
||||
jobUUID: 'job',
|
||||
})
|
||||
vi.mocked(processStatus).mockResolvedValue(
|
||||
type === 'COMPLETE'
|
||||
? { type, processUUID: 'uuid', values: { message: 'Restarted 2 consumers.', count: 2 } }
|
||||
: { type, error: 'Restart failed.' }
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Restart Fulfill Order' }))
|
||||
expect(screen.getByRole('button', { name: 'Restart Fulfill Order' })).toBeDisabled()
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(type === 'COMPLETE' ? toast.success : toast.error).toHaveBeenCalledWith(
|
||||
type === 'COMPLETE' ? 'Restarted 2 consumers.' : 'Restart failed.'
|
||||
),
|
||||
{ timeout: 2500 }
|
||||
)
|
||||
expect(type === 'COMPLETE' ? toast.error : toast.success).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Restart Fulfill Order' })).toBeEnabled()
|
||||
}
|
||||
)
|
||||
|
||||
it('treats a topic as having no broker queue', () => {
|
||||
expect(destinationQueue(orderEsb.publications[0].destination)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,694 @@
|
||||
/*
|
||||
* 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 EsbActions — management actions for ESB triggers and broker queues, run as the backend's
|
||||
* ESB operate processes. Each action shows only when the user holds its permission and, for broker
|
||||
* queues, when the broker adapter supports it. Purge and delete ask for confirmation first.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useId, useState } from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { EsbCapabilities, EsbDestination, EsbPermissions, EsbTrigger } from '@/types'
|
||||
import { runEsbAction } from '@/lib/api/esb'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/** A queue on the broker that management actions address. */
|
||||
export interface EsbQueueRef {
|
||||
/** ESB provider the queue lives on. */
|
||||
providerName: string
|
||||
/** Broker-side queue name. */
|
||||
brokerQueueName: string
|
||||
/** What the provider's broker adapter supports. */
|
||||
capabilities: EsbCapabilities
|
||||
/** Messages in the queue, or `null` when the broker cannot report it. */
|
||||
messageCount: number | null
|
||||
/** Whether delivery is paused, or `null` when unknown; pause and resume show only when known. */
|
||||
paused: boolean | null
|
||||
}
|
||||
|
||||
/** Values an operate process takes. */
|
||||
type EsbActionValues = Record<string, string | boolean>
|
||||
|
||||
/**
|
||||
* The broker queue behind a destination.
|
||||
*
|
||||
* @param destination - A queue or topic.
|
||||
* @returns The queue, or `null` for a topic (its subscriptions are the queues).
|
||||
*/
|
||||
export function destinationQueue(destination: EsbDestination): EsbQueueRef | null {
|
||||
if (destination.type !== 'QUEUE') return null
|
||||
return {
|
||||
providerName: destination.provider,
|
||||
brokerQueueName: destination.brokerName,
|
||||
capabilities: destination.capabilities,
|
||||
messageCount: destination.queueInfo?.messageCount ?? null,
|
||||
paused: destination.queueInfo?.paused ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A trigger's dead-letter queue, which lives on the same provider as its destination.
|
||||
*
|
||||
* @param trigger - The trigger.
|
||||
* @returns Its dead-letter queue.
|
||||
*/
|
||||
export function deadLetterQueue(trigger: EsbTrigger): EsbQueueRef {
|
||||
return {
|
||||
providerName: trigger.destination.provider,
|
||||
brokerQueueName: trigger.deadLetter.brokerName,
|
||||
capabilities: trigger.destination.capabilities,
|
||||
messageCount: trigger.deadLetter.messageCount,
|
||||
paused: trigger.deadLetter.paused ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts messages in words.
|
||||
*
|
||||
* @param count - Number of messages.
|
||||
* @param adjective - Optional word before "message", e.g. `selected`.
|
||||
* @returns For example `7 messages` or `1 selected message`.
|
||||
*/
|
||||
export function messageCountText(count: number, adjective?: string): string {
|
||||
return [count, adjective, count === 1 ? 'message' : 'messages'].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs ESB operate processes, reporting the outcome in a toast and refreshing every ESB query.
|
||||
*
|
||||
* @param onDone - Called after an action succeeds.
|
||||
* @returns The mutation; call `mutate({ processName, values })`.
|
||||
*/
|
||||
export function useEsbAction(onDone?: () => void) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ processName, values }: { processName: string; values: EsbActionValues }) =>
|
||||
runEsbAction(processName, values),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
onSuccess: (result) => {
|
||||
toast.success(result.message)
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.esb() })
|
||||
onDone?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'The action failed.')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause or resume, restart, and replay-dead-letter buttons for a trigger. These act on QQQ's own
|
||||
* consumers on every node, so they need `esbOperate` but no broker management API.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.trigger - The trigger.
|
||||
* @param props.permissions - The current user's ESB permissions.
|
||||
* @returns The buttons, or `null` without `esbOperate`.
|
||||
*/
|
||||
export function EsbTriggerActions({
|
||||
trigger,
|
||||
permissions,
|
||||
}: {
|
||||
trigger: EsbTrigger
|
||||
permissions: EsbPermissions
|
||||
}) {
|
||||
if (!permissions.canOperate) return null
|
||||
return <TriggerButtons trigger={trigger} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger buttons, once the user is known to hold `esbOperate`.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.trigger - The trigger.
|
||||
* @returns A group of buttons.
|
||||
*/
|
||||
function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
|
||||
const [confirmReplay, setConfirmReplay] = useState(false)
|
||||
const action = useEsbAction(() => setConfirmReplay(false))
|
||||
const run = (processName: string, values: EsbActionValues = {}) =>
|
||||
action.mutate({ processName, values: { triggerName: trigger.name, ...values } })
|
||||
const paused = trigger.state === 'PAUSED'
|
||||
const id = trigger.name
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-1"
|
||||
role="group"
|
||||
aria-label={`Actions for ${trigger.processLabel}`}
|
||||
>
|
||||
{paused ? (
|
||||
<ActionButton
|
||||
label="Resume"
|
||||
ariaLabel={`Resume ${trigger.processLabel}`}
|
||||
qqqId={`button-esb-resume-trigger-${id}`}
|
||||
disabled={action.isPending}
|
||||
onClick={() => run('esbResumeTrigger')}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Pause"
|
||||
ariaLabel={`Pause ${trigger.processLabel}`}
|
||||
qqqId={`button-esb-pause-trigger-${id}`}
|
||||
disabled={action.isPending}
|
||||
onClick={() => run('esbPauseTrigger')}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
ariaLabel={`Restart ${trigger.processLabel}`}
|
||||
qqqId={`button-esb-restart-trigger-${id}`}
|
||||
disabled={action.isPending}
|
||||
onClick={() => run('esbRestartTrigger')}
|
||||
/>
|
||||
{trigger.deadLetter.messageCount !== 0 && (
|
||||
<ActionButton
|
||||
label="Replay dead letters"
|
||||
ariaLabel={`Replay dead letters for ${trigger.processLabel}`}
|
||||
qqqId={`button-esb-replay-dead-letters-${id}`}
|
||||
disabled={action.isPending}
|
||||
onClick={() => setConfirmReplay(true)}
|
||||
/>
|
||||
)}
|
||||
<EsbConfirmDialog
|
||||
open={confirmReplay}
|
||||
title="Replay dead letters"
|
||||
confirmLabel="Replay dead letters"
|
||||
destructive={false}
|
||||
pending={action.isPending}
|
||||
onCancel={() => setConfirmReplay(false)}
|
||||
onConfirm={() => run('esbReplayDeadLetters', { all: true })}
|
||||
>
|
||||
Replay{' '}
|
||||
{trigger.deadLetter.messageCount === null
|
||||
? 'all dead letters (count unknown)'
|
||||
: `all ${messageCountText(trigger.deadLetter.messageCount)}`}{' '}
|
||||
from queue {trigger.deadLetter.brokerName} through {trigger.processLabel}? Successful
|
||||
messages will be removed.
|
||||
</EsbConfirmDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause or resume, purge, and delete-older-than buttons for a broker queue. Pause and resume need
|
||||
* `esbOperate`; purge and delete need `esbDelete`; each also needs the broker to support it.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.queue - The broker queue.
|
||||
* @param props.permissions - The current user's ESB permissions.
|
||||
* @param props.onDone - Called after success to clear any browsed selection.
|
||||
* @returns The buttons, or `null` when none is available.
|
||||
*/
|
||||
export function EsbQueueActions({
|
||||
queue,
|
||||
permissions,
|
||||
onDone,
|
||||
}: {
|
||||
queue: EsbQueueRef
|
||||
permissions: EsbPermissions
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const canPause = permissions.canOperate && queue.capabilities.pauseQueue && queue.paused !== null
|
||||
const canPurge = permissions.canDelete && queue.capabilities.purge
|
||||
const canDeleteOlder = permissions.canDelete && queue.capabilities.deleteOlderThan
|
||||
if (!canPause && !canPurge && !canDeleteOlder) return null
|
||||
return (
|
||||
<QueueButtons
|
||||
queue={queue}
|
||||
canPause={canPause}
|
||||
canPurge={canPurge}
|
||||
canDeleteOlder={canDeleteOlder}
|
||||
onDone={onDone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The queue buttons and their confirmation dialogs.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.queue - The broker queue.
|
||||
* @param props.canPause - Whether to offer pause or resume.
|
||||
* @param props.canPurge - Whether to offer purge.
|
||||
* @param props.canDeleteOlder - Whether to offer deleting messages older than a time.
|
||||
* @param props.onDone - Called after an action succeeds.
|
||||
* @returns A group of buttons.
|
||||
*/
|
||||
function QueueButtons({
|
||||
queue,
|
||||
canPause,
|
||||
canPurge,
|
||||
canDeleteOlder,
|
||||
onDone,
|
||||
}: {
|
||||
queue: EsbQueueRef
|
||||
canPause: boolean
|
||||
canPurge: boolean
|
||||
canDeleteOlder: boolean
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<'purge' | 'deleteOlder' | null>(null)
|
||||
const [olderThan, setOlderThan] = useState('')
|
||||
const olderThanId = useId()
|
||||
const close = () => {
|
||||
setDialog(null)
|
||||
setOlderThan('')
|
||||
}
|
||||
const action = useEsbAction(() => {
|
||||
close()
|
||||
onDone?.()
|
||||
})
|
||||
const target = { providerName: queue.providerName, brokerQueueName: queue.brokerQueueName }
|
||||
const name = queue.brokerQueueName
|
||||
const inQueue =
|
||||
queue.messageCount === null ? '' : ` (${messageCountText(queue.messageCount)} in the queue)`
|
||||
const cutoff = olderThan ? new Date(olderThan) : null
|
||||
const cutoffValid = cutoff !== null && !Number.isNaN(cutoff.getTime())
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1" role="group" aria-label={`Actions for queue ${name}`}>
|
||||
{canPause && (
|
||||
<ActionButton
|
||||
label={queue.paused ? 'Resume queue' : 'Pause queue'}
|
||||
ariaLabel={`${queue.paused ? 'Resume' : 'Pause'} queue ${name}`}
|
||||
qqqId={`button-esb-${queue.paused ? 'resume' : 'pause'}-queue-${name}`}
|
||||
disabled={action.isPending}
|
||||
onClick={() =>
|
||||
action.mutate({
|
||||
processName: queue.paused ? 'esbResumeQueue' : 'esbPauseQueue',
|
||||
values: target,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{canPurge && (
|
||||
<ActionButton
|
||||
label="Purge"
|
||||
ariaLabel={`Purge ${name}`}
|
||||
qqqId={`button-esb-purge-${name}`}
|
||||
destructive
|
||||
disabled={action.isPending}
|
||||
onClick={() => setDialog('purge')}
|
||||
/>
|
||||
)}
|
||||
{canDeleteOlder && (
|
||||
<ActionButton
|
||||
label="Delete older"
|
||||
ariaLabel={`Delete older messages in ${name}`}
|
||||
qqqId={`button-esb-delete-older-${name}`}
|
||||
destructive
|
||||
disabled={action.isPending}
|
||||
onClick={() => setDialog('deleteOlder')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EsbConfirmDialog
|
||||
open={dialog === 'purge'}
|
||||
title="Purge queue"
|
||||
confirmLabel="Purge queue"
|
||||
pending={action.isPending}
|
||||
onCancel={close}
|
||||
onConfirm={() => action.mutate({ processName: 'esbPurgeQueue', values: target })}
|
||||
>
|
||||
{queue.messageCount === null
|
||||
? `Delete every message in queue ${name} on ${queue.providerName}?`
|
||||
: `Delete all ${messageCountText(queue.messageCount)} in queue ${name} on ${queue.providerName}?`}{' '}
|
||||
This cannot be undone.
|
||||
</EsbConfirmDialog>
|
||||
|
||||
<EsbConfirmDialog
|
||||
open={dialog === 'deleteOlder'}
|
||||
title="Delete older messages"
|
||||
confirmLabel="Delete messages"
|
||||
pending={action.isPending}
|
||||
confirmDisabled={!cutoffValid}
|
||||
onCancel={close}
|
||||
onConfirm={() => {
|
||||
if (!cutoffValid) return
|
||||
action.mutate({
|
||||
processName: 'esbDeleteMessages',
|
||||
values: { ...target, olderThan: cutoff.toISOString() },
|
||||
})
|
||||
}}
|
||||
body={
|
||||
<div className="mt-4 space-y-1">
|
||||
<label htmlFor={olderThanId} className="text-sm font-medium text-foreground">
|
||||
Older than
|
||||
</label>
|
||||
<input
|
||||
id={olderThanId}
|
||||
type="datetime-local"
|
||||
value={olderThan}
|
||||
onChange={(event) => setOlderThan(event.target.value)}
|
||||
aria-required="true"
|
||||
className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`input-esb-older-than-${name}`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Delete the messages older than the chosen time from queue {name} on {queue.providerName}
|
||||
{inQueue}? This cannot be undone.
|
||||
</EsbConfirmDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay, move and delete buttons for messages selected in a browsed queue. Replay needs
|
||||
* `esbOperate` and a trigger (dead letters only); move needs `esbOperate`; delete needs
|
||||
* `esbDelete`; move and delete also need the broker to support them.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.queue - The broker queue the messages are in.
|
||||
* @param props.permissions - The current user's ESB permissions.
|
||||
* @param props.messageIds - The selected messages.
|
||||
* @param props.replayTriggerName - For dead letters, the trigger whose process replays them.
|
||||
* @param props.onDone - Called after an action succeeds, e.g. to clear the selection.
|
||||
* @returns The buttons, or `null` when none is available.
|
||||
*/
|
||||
export function EsbSelectionActions({
|
||||
queue,
|
||||
permissions,
|
||||
messageIds,
|
||||
replayTriggerName,
|
||||
onDone,
|
||||
}: {
|
||||
queue: EsbQueueRef
|
||||
permissions: EsbPermissions
|
||||
messageIds: string[]
|
||||
replayTriggerName?: string
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const available = selectionActions(queue, permissions, replayTriggerName)
|
||||
if (!available.replay && !available.move && !available.delete) return null
|
||||
return (
|
||||
<SelectionButtons
|
||||
queue={queue}
|
||||
messageIds={messageIds}
|
||||
replayTriggerName={replayTriggerName}
|
||||
available={available}
|
||||
onDone={onDone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which actions on selected messages the user may take.
|
||||
*
|
||||
* @param queue - The broker queue the messages are in.
|
||||
* @param permissions - The current user's ESB permissions.
|
||||
* @param replayTriggerName - For dead letters, the trigger whose process replays them.
|
||||
* @returns A flag per action.
|
||||
*/
|
||||
export function selectionActions(
|
||||
queue: EsbQueueRef,
|
||||
permissions: EsbPermissions,
|
||||
replayTriggerName?: string
|
||||
): { replay: boolean; move: boolean; delete: boolean } {
|
||||
return {
|
||||
replay: permissions.canOperate && Boolean(replayTriggerName),
|
||||
move: permissions.canOperate && queue.capabilities.move,
|
||||
delete: permissions.canDelete && queue.capabilities.deleteSelected,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection buttons and their dialogs.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.queue - The broker queue the messages are in.
|
||||
* @param props.messageIds - The selected messages.
|
||||
* @param props.replayTriggerName - For dead letters, the trigger whose process replays them.
|
||||
* @param props.available - Which actions to offer.
|
||||
* @param props.onDone - Called after an action succeeds.
|
||||
* @returns A group of buttons.
|
||||
*/
|
||||
function SelectionButtons({
|
||||
queue,
|
||||
messageIds,
|
||||
replayTriggerName,
|
||||
available,
|
||||
onDone,
|
||||
}: {
|
||||
queue: EsbQueueRef
|
||||
messageIds: string[]
|
||||
replayTriggerName?: string
|
||||
available: { replay: boolean; move: boolean; delete: boolean }
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<'move' | 'delete' | null>(null)
|
||||
const [toQueue, setToQueue] = useState('')
|
||||
const toQueueId = useId()
|
||||
const close = () => {
|
||||
setDialog(null)
|
||||
setToQueue('')
|
||||
}
|
||||
const action = useEsbAction(() => {
|
||||
close()
|
||||
onDone?.()
|
||||
})
|
||||
const name = queue.brokerQueueName
|
||||
const ids = messageIds.join(',')
|
||||
const selected = messageCountText(messageIds.length, 'selected')
|
||||
const target = { providerName: queue.providerName, brokerQueueName: name, messageIds: ids }
|
||||
const none = messageIds.length === 0 || action.isPending
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1" role="group" aria-label="Actions for selected messages">
|
||||
{available.replay && replayTriggerName && (
|
||||
<ActionButton
|
||||
label="Replay selected"
|
||||
qqqId={`button-esb-replay-selected-${name}`}
|
||||
disabled={none}
|
||||
onClick={() =>
|
||||
action.mutate({
|
||||
processName: 'esbReplayDeadLetters',
|
||||
values: { triggerName: replayTriggerName, messageIds: ids },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{available.move && (
|
||||
<ActionButton
|
||||
label="Move selected"
|
||||
qqqId={`button-esb-move-selected-${name}`}
|
||||
disabled={none}
|
||||
onClick={() => setDialog('move')}
|
||||
/>
|
||||
)}
|
||||
{available.delete && (
|
||||
<ActionButton
|
||||
label="Delete selected"
|
||||
qqqId={`button-esb-delete-selected-${name}`}
|
||||
destructive
|
||||
disabled={none}
|
||||
onClick={() => setDialog('delete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EsbConfirmDialog
|
||||
open={dialog === 'move'}
|
||||
title="Move messages"
|
||||
confirmLabel="Move messages"
|
||||
destructive={false}
|
||||
pending={action.isPending}
|
||||
confirmDisabled={!toQueue.trim()}
|
||||
onCancel={close}
|
||||
onConfirm={() =>
|
||||
action.mutate({
|
||||
processName: 'esbMoveMessages',
|
||||
values: { ...target, toBrokerQueueName: toQueue.trim() },
|
||||
})
|
||||
}
|
||||
body={
|
||||
<div className="mt-4 space-y-1">
|
||||
<label htmlFor={toQueueId} className="text-sm font-medium text-foreground">
|
||||
Target queue
|
||||
</label>
|
||||
<input
|
||||
id={toQueueId}
|
||||
type="text"
|
||||
value={toQueue}
|
||||
onChange={(event) => setToQueue(event.target.value)}
|
||||
aria-required="true"
|
||||
className="block w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`input-esb-move-target-${name}`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Move {selected} from queue {name} on {queue.providerName} to another queue on the same
|
||||
broker.
|
||||
</EsbConfirmDialog>
|
||||
|
||||
<EsbConfirmDialog
|
||||
open={dialog === 'delete'}
|
||||
title="Delete messages"
|
||||
confirmLabel="Delete messages"
|
||||
pending={action.isPending}
|
||||
onCancel={close}
|
||||
onConfirm={() => action.mutate({ processName: 'esbDeleteMessages', values: target })}
|
||||
>
|
||||
Delete {selected} from queue {name} on {queue.providerName}? This cannot be undone.
|
||||
</EsbConfirmDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A small outlined action button.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - Visible text.
|
||||
* @param props.ariaLabel - Accessible name when the visible text needs context; it starts with `label`.
|
||||
* @param props.qqqId - `data-qqq-id` value.
|
||||
* @param props.destructive - Whether to style it as destructive.
|
||||
* @param props.disabled - Whether it is disabled.
|
||||
* @param props.onClick - Click handler.
|
||||
* @returns The button.
|
||||
*/
|
||||
function ActionButton({
|
||||
label,
|
||||
ariaLabel,
|
||||
qqqId,
|
||||
destructive = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
ariaLabel?: string
|
||||
qqqId: string
|
||||
destructive?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
data-qqq-id={qqqId}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2 py-1 text-xs font-medium transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
destructive
|
||||
? 'border-destructive/40 text-destructive hover:bg-destructive/10'
|
||||
: 'border-input bg-background text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation dialog: nothing runs until the confirm button is pressed.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.open - Whether the dialog is shown.
|
||||
* @param props.title - Heading, also the dialog's accessible name.
|
||||
* @param props.children - The question, naming what the action affects.
|
||||
* @param props.body - Optional inputs shown under the question.
|
||||
* @param props.confirmLabel - Text of the confirm button.
|
||||
* @param props.destructive - Whether to style the confirm button as destructive (default true).
|
||||
* @param props.confirmDisabled - Whether the confirm button is disabled, e.g. until an input is valid.
|
||||
* @param props.pending - Whether the action is running.
|
||||
* @param props.onConfirm - Runs the action.
|
||||
* @param props.onCancel - Closes the dialog without running anything.
|
||||
* @returns The dialog.
|
||||
*/
|
||||
export function EsbConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
body,
|
||||
confirmLabel,
|
||||
destructive = true,
|
||||
confirmDisabled = false,
|
||||
pending,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
body?: React.ReactNode
|
||||
confirmLabel: string
|
||||
destructive?: boolean
|
||||
confirmDisabled?: boolean
|
||||
pending: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Root
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onCancel()
|
||||
}}
|
||||
>
|
||||
<AlertDialogPrimitive.Portal>
|
||||
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-qqq-id="esb-confirm-dialog"
|
||||
className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-lg focus:outline-none"
|
||||
>
|
||||
<div className="p-6">
|
||||
<AlertDialogPrimitive.Title className="text-lg font-semibold text-foreground">
|
||||
{title}
|
||||
</AlertDialogPrimitive.Title>
|
||||
<AlertDialogPrimitive.Description className="mt-2 text-sm text-muted-foreground">
|
||||
{children}
|
||||
</AlertDialogPrimitive.Description>
|
||||
{body}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-xl bg-muted px-6 py-4">
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-qqq-id="button-esb-confirm-cancel"
|
||||
className="inline-flex items-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogPrimitive.Cancel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={confirmDisabled || pending}
|
||||
data-qqq-id="button-esb-confirm"
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
destructive
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring'
|
||||
)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPrimitive.Portal>
|
||||
</AlertDialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 EsbCounters — compact list of a destination's or trigger's per-node ESB counters.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { EsbCounter } from '@/types'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
|
||||
/** Which side of a destination the counters describe. */
|
||||
export type EsbCountersKind = 'publish' | 'consume'
|
||||
|
||||
/** Numeric counts shown for each kind, with their labels. */
|
||||
const COUNTS: Record<EsbCountersKind, ReadonlyArray<readonly [keyof EsbCounter, string]>> = {
|
||||
publish: [
|
||||
['published', 'Published'],
|
||||
['publishFailures', 'Publish failures'],
|
||||
],
|
||||
consume: [
|
||||
['consumed', 'Consumed'],
|
||||
['succeeded', 'Succeeded'],
|
||||
['failed', 'Failed'],
|
||||
['retried', 'Retried'],
|
||||
['deadLettered', 'Dead-lettered'],
|
||||
['inFlight', 'In flight'],
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders counters as a wrapping description list: publish counts for a publication,
|
||||
* consume counts and processing times for a trigger, then last activity and last error.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.counters - The counter snapshot.
|
||||
* @param props.kind - `publish` for a publication's destination, `consume` for a trigger.
|
||||
* @returns The counters as `<dl>` term/value pairs.
|
||||
*/
|
||||
export function EsbCounters({ counters, kind }: { counters: EsbCounter; kind: EsbCountersKind }) {
|
||||
const items = COUNTS[kind].map(([key, label]) => [label, String(counters[key])])
|
||||
if (kind === 'consume') {
|
||||
items.push(
|
||||
['Avg time', `${Math.round(counters.avgMs)} ms`],
|
||||
['Max time', `${Math.round(counters.maxMs)} ms`]
|
||||
)
|
||||
}
|
||||
items.push(['Last activity', formatDateTime(counters.lastActivity) ?? '—'])
|
||||
|
||||
return (
|
||||
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
{items.map(([label, value]) => (
|
||||
<div key={label} className="flex gap-1">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="font-medium tabular-nums text-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
{counters.lastError && (
|
||||
<div className="flex basis-full gap-1">
|
||||
<dt className="text-muted-foreground">Last error</dt>
|
||||
<dd className="break-words text-destructive">{counters.lastError}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for browsing ESB messages and acting on selected ones
|
||||
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EsbPermissions } from '@/types'
|
||||
import { processInit } from '@/lib/api/processes'
|
||||
import { orderEsb, orderFulfillmentMessages } from '@/mocks/fixtures/esb'
|
||||
import { server } from '@/mocks/node'
|
||||
import { deadLetterQueue, destinationQueue, type EsbQueueRef } from './EsbActions'
|
||||
import { EsbBrowseButton, EsbMessageList, type EsbMessageSource } from './EsbMessageList'
|
||||
|
||||
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn() }))
|
||||
|
||||
const fulfillTrigger = orderEsb.subscribers[0]
|
||||
const ALL: EsbPermissions = { canOperate: true, canDelete: true }
|
||||
const NONE: EsbPermissions = { canOperate: false, canDelete: false }
|
||||
const QUEUE_SOURCE: EsbMessageSource = { kind: 'destination', name: 'orderFulfillment' }
|
||||
const DEAD_LETTER_SOURCE: EsbMessageSource = {
|
||||
kind: 'deadLetters',
|
||||
triggerName: 'fulfillOrder.orderFulfillment',
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker queue behind the `orderFulfillment` destination.
|
||||
*
|
||||
* @returns Its queue reference.
|
||||
*/
|
||||
function fulfillmentQueue(): EsbQueueRef {
|
||||
const queue = destinationQueue(orderEsb.publications[1].destination)
|
||||
if (!queue) throw new Error('orderFulfillment is a queue')
|
||||
return queue
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a component inside a fresh query client.
|
||||
*
|
||||
* @param ui - The element to render.
|
||||
*/
|
||||
function renderWithClient(ui: React.ReactElement): void {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('EsbMessageList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'COMPLETE',
|
||||
processUUID: 'uuid',
|
||||
values: { count: 1, message: 'Done.' },
|
||||
})
|
||||
})
|
||||
|
||||
it("lists a queue's messages with their event type and subject", async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
|
||||
)
|
||||
const table = await screen.findByRole('table', { name: 'Messages' })
|
||||
const rows = within(table).getAllByRole('row').slice(1)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveTextContent('ID:msg-1')
|
||||
expect(rows[0]).toHaveTextContent('qqq.table.order.inserted')
|
||||
expect(rows[0]).toHaveTextContent('1001')
|
||||
expect(rows[1]).toHaveTextContent('ID:msg-2')
|
||||
})
|
||||
|
||||
it('browses CloudEvents with the backend event type and source in both parsed and raw bodies', async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
|
||||
)
|
||||
const table = await screen.findByRole('table', { name: 'Messages' })
|
||||
expect(table).toHaveTextContent('qqq.table.order.inserted')
|
||||
for (const message of orderFulfillmentMessages.messages) {
|
||||
expect(message.event?.source).toBe('qqq://demo/table/order')
|
||||
expect(JSON.parse(message.rawBody)).toEqual(message.event)
|
||||
}
|
||||
})
|
||||
|
||||
it("shows a dead letter's error and raw body when it is not a CloudEvent", async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={DEAD_LETTER_SOURCE}
|
||||
queue={deadLetterQueue(fulfillTrigger)}
|
||||
permissions={NONE}
|
||||
/>
|
||||
)
|
||||
const table = await screen.findByRole('table', { name: 'Messages' })
|
||||
expect(table).toHaveTextContent('unparseable message')
|
||||
expect(table).toHaveTextContent('Not a CloudEvent')
|
||||
expect(table).toHaveTextContent('not json')
|
||||
})
|
||||
|
||||
it('pages forward while the backend has more messages', async () => {
|
||||
const offsets: string[] = []
|
||||
server.use(
|
||||
http.get('/qqq/v1/esb/messages/orderFulfillment', ({ request }) => {
|
||||
const offset = new URL(request.url).searchParams.get('offset') ?? ''
|
||||
offsets.push(offset)
|
||||
return HttpResponse.json(
|
||||
offset === '0' ? orderFulfillmentMessages : { messages: [], hasMore: false }
|
||||
)
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
|
||||
)
|
||||
await screen.findByRole('table', { name: 'Messages' })
|
||||
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Next page' }))
|
||||
await screen.findByText('No messages.')
|
||||
expect(offsets).toEqual(['0', '50'])
|
||||
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('offers no selection without permission', async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
|
||||
)
|
||||
await screen.findByRole('table', { name: 'Messages' })
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /selected/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes the selected messages after a confirmation naming the queue and count', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={ALL} />
|
||||
)
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Select ID:msg-1' }))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'Select ID:msg-2' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Delete selected' }))
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
expect(dialog).toHaveTextContent('2 selected messages')
|
||||
expect(dialog).toHaveTextContent('orderFulfillment')
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Delete messages' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbDeleteMessages', {
|
||||
values: {
|
||||
providerName: 'artemis',
|
||||
brokerQueueName: 'orderFulfillment',
|
||||
messageIds: 'ID:msg-1,ID:msg-2',
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('moves the selected messages to the named queue', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={ALL} />
|
||||
)
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Select ID:msg-2' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Move selected' }))
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
const confirm = within(dialog).getByRole('button', { name: 'Move messages' })
|
||||
expect(confirm).toBeDisabled()
|
||||
await user.type(within(dialog).getByLabelText('Target queue'), ' ')
|
||||
expect(confirm).toBeDisabled()
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
await user.clear(within(dialog).getByLabelText('Target queue'))
|
||||
await user.type(within(dialog).getByLabelText('Target queue'), ' orderFulfillment.hold ')
|
||||
await user.click(confirm)
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbMoveMessages', {
|
||||
values: {
|
||||
providerName: 'artemis',
|
||||
brokerQueueName: 'orderFulfillment',
|
||||
messageIds: 'ID:msg-2',
|
||||
toBrokerQueueName: 'orderFulfillment.hold',
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('replays the selected dead letters through the trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={DEAD_LETTER_SOURCE}
|
||||
queue={deadLetterQueue(fulfillTrigger)}
|
||||
permissions={{ canOperate: true, canDelete: false }}
|
||||
/>
|
||||
)
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' }))
|
||||
expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Replay selected' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbReplayDeadLetters', {
|
||||
values: { triggerName: 'fulfillOrder.orderFulfillment', messageIds: 'ID:dlq-1' },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['purge', 'deleteOlder'])('clears the selection after %s succeeds', async (operation) => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={DEAD_LETTER_SOURCE}
|
||||
queue={deadLetterQueue(fulfillTrigger)}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name:
|
||||
operation === 'purge'
|
||||
? 'Purge orderFulfillment.dlq'
|
||||
: 'Delete older messages in orderFulfillment.dlq',
|
||||
})
|
||||
)
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
if (operation === 'deleteOlder') {
|
||||
fireEvent.change(within(dialog).getByLabelText('Older than'), {
|
||||
target: { value: '2026-09-25T10:00' },
|
||||
})
|
||||
}
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', {
|
||||
name: operation === 'purge' ? 'Purge queue' : 'Delete messages',
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('checkbox', { name: 'Select ID:dlq-1' })).not.toBeChecked()
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Replay selected' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('offers no selection when move and delete are unsupported even with permissions', async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={QUEUE_SOURCE}
|
||||
queue={{
|
||||
...fulfillmentQueue(),
|
||||
capabilities: { ...fulfillmentQueue().capabilities, move: false, deleteSelected: false },
|
||||
}}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
await screen.findByRole('table', { name: 'Messages' })
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /selected/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retains selection and confirmation after a failed purge', async () => {
|
||||
vi.mocked(processInit).mockResolvedValue({ type: 'ERROR', error: 'Purge refused.' })
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={DEAD_LETTER_SOURCE}
|
||||
queue={deadLetterQueue(fulfillTrigger)}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
const checkbox = await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' })
|
||||
await user.click(checkbox)
|
||||
await user.click(screen.getByRole('button', { name: 'Purge orderFulfillment.dlq' }))
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Purge queue' }))
|
||||
await waitFor(() => expect(processInit).toHaveBeenCalled())
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(checkbox).toBeChecked()
|
||||
})
|
||||
|
||||
it('opens the list in a dialog from the browse button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbBrowseButton
|
||||
label="Browse"
|
||||
title="Messages in orderFulfillment"
|
||||
source={QUEUE_SOURCE}
|
||||
queue={fulfillmentQueue()}
|
||||
permissions={NONE}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('table', { name: 'Messages' })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Browse' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Messages in orderFulfillment' })
|
||||
expect(await within(dialog).findByRole('table', { name: 'Messages' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* 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 EsbMessageList — pages through the messages waiting on a queue or in a trigger's
|
||||
* dead-letter queue, with replay, move and delete for selected messages when permitted, and
|
||||
* purge and delete-older-than for the dead-letter queue itself.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import type { EsbMessage, EsbPermissions } from '@/types'
|
||||
import { ESB_PAGE_SIZE, getEsbDeadLetters, getEsbMessages } from '@/lib/api/esb'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
import {
|
||||
EsbQueueActions,
|
||||
EsbSelectionActions,
|
||||
selectionActions,
|
||||
type EsbQueueRef,
|
||||
} from './EsbActions'
|
||||
|
||||
/** What to browse: the queue behind a destination, or a trigger's dead letters. */
|
||||
export type EsbMessageSource =
|
||||
| { kind: 'destination'; name: string }
|
||||
| { kind: 'subscription'; name: string; triggerName: string }
|
||||
| { kind: 'deadLetters'; triggerName: string }
|
||||
|
||||
/** Props for {@link EsbMessageList}. */
|
||||
interface EsbMessageListProps {
|
||||
source: EsbMessageSource
|
||||
/** The broker queue holding the messages, for actions on selected ones. */
|
||||
queue: EsbQueueRef
|
||||
permissions: EsbPermissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists one page of browsed messages at a time: id, time, delivery count, CloudEvent type and
|
||||
* subject (or the raw body when it is not a CloudEvent), and the `qqqError` a dead letter carries.
|
||||
* Each row can be selected when an action on selected messages is available. Dead letters also
|
||||
* get the queue actions, since their queue is shown nowhere else.
|
||||
*
|
||||
* @param props - See {@link EsbMessageListProps}.
|
||||
* @returns The action bars, the message table with previous and next buttons, or a loading,
|
||||
* error or empty state.
|
||||
*/
|
||||
export function EsbMessageList({ source, queue, permissions }: EsbMessageListProps) {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const name = source.kind === 'destination' ? source.name : source.triggerName
|
||||
const replayTriggerName = source.kind === 'deadLetters' ? source.triggerName : undefined
|
||||
const available = selectionActions(queue, permissions, replayTriggerName)
|
||||
const selectable = available.replay || available.move || available.delete
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey:
|
||||
source.kind === 'subscription'
|
||||
? [...queryKeys.esbMessages('destination', source.name, offset), source.triggerName]
|
||||
: queryKeys.esbMessages(source.kind, name, offset),
|
||||
queryFn: () =>
|
||||
source.kind === 'deadLetters'
|
||||
? getEsbDeadLetters(source.triggerName, offset)
|
||||
: getEsbMessages(
|
||||
source.name,
|
||||
offset,
|
||||
ESB_PAGE_SIZE,
|
||||
source.kind === 'subscription' ? source.triggerName : undefined
|
||||
),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const goTo = (nextOffset: number) => {
|
||||
setOffset(Math.max(0, nextOffset))
|
||||
setSelected([])
|
||||
}
|
||||
const toggle = (messageId: string) =>
|
||||
setSelected((current) =>
|
||||
current.includes(messageId)
|
||||
? current.filter((id) => id !== messageId)
|
||||
: [...current, messageId]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-qqq-id={`esb-message-list-${name}`}>
|
||||
{source.kind !== 'destination' && (
|
||||
<EsbQueueActions queue={queue} permissions={permissions} onDone={() => setSelected([])} />
|
||||
)}
|
||||
{selectable && (
|
||||
<EsbSelectionActions
|
||||
queue={queue}
|
||||
permissions={permissions}
|
||||
messageIds={selected}
|
||||
replayTriggerName={replayTriggerName}
|
||||
onDone={() => setSelected([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
Loading messages…
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
Failed to load messages: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
)}
|
||||
{data && data.messages.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No messages.</p>
|
||||
)}
|
||||
{data && data.messages.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" aria-label="Messages">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
{selectable && (
|
||||
<th scope="col" className="px-2 py-1.5">
|
||||
<span className="sr-only">Select</span>
|
||||
</th>
|
||||
)}
|
||||
{['Message', 'Time', 'Deliveries', 'Event'].map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
scope="col"
|
||||
className="whitespace-nowrap px-2 py-1.5 font-medium text-muted-foreground"
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.messages.map((message) => (
|
||||
<MessageRow
|
||||
key={message.messageId}
|
||||
message={message}
|
||||
selectable={selectable}
|
||||
selected={selected.includes(message.messageId)}
|
||||
onToggle={() => toggle(message.messageId)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<PageButton
|
||||
label="Previous page"
|
||||
disabled={offset === 0}
|
||||
onClick={() => goTo(offset - ESB_PAGE_SIZE)}
|
||||
>
|
||||
Previous
|
||||
</PageButton>
|
||||
<PageButton
|
||||
label="Next page"
|
||||
disabled={!data?.hasMore}
|
||||
onClick={() => goTo(offset + ESB_PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</PageButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One browsed message as a table row.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.message - The message.
|
||||
* @param props.selectable - Whether to show a selection checkbox.
|
||||
* @param props.selected - Whether it is selected.
|
||||
* @param props.onToggle - Toggles its selection.
|
||||
* @returns The row.
|
||||
*/
|
||||
function MessageRow({
|
||||
message,
|
||||
selectable,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
message: EsbMessage
|
||||
selectable: boolean
|
||||
selected: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const error = message.properties.qqqError
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-border/50 align-top last:border-0"
|
||||
data-qqq-id={`esb-message-${message.messageId}`}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
aria-label={`Select ${message.messageId}`}
|
||||
className="h-4 w-4 rounded border-input focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`checkbox-esb-message-${message.messageId}`}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-2 py-2 font-mono text-xs text-foreground">{message.messageId}</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-foreground">
|
||||
{formatDateTime(message.timestamp) ?? message.timestamp}
|
||||
</td>
|
||||
<td className="px-2 py-2 tabular-nums text-foreground">{message.deliveryCount}</td>
|
||||
<td className="space-y-1 px-2 py-2">
|
||||
{message.event ? (
|
||||
<p className="text-foreground">
|
||||
<span className="font-mono text-xs">{message.event.type}</span>
|
||||
{message.event.subject && (
|
||||
<span className="text-muted-foreground"> · {message.event.subject}</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Not a CloudEvent</p>
|
||||
)}
|
||||
{error != null && <p className="break-words text-destructive">{String(error)}</p>}
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-primary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
Body
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-2 text-xs text-foreground">
|
||||
{message.rawBody}
|
||||
</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A pager button.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - Accessible name.
|
||||
* @param props.disabled - Whether it is disabled.
|
||||
* @param props.onClick - Click handler.
|
||||
* @param props.children - Visible text.
|
||||
* @returns The button.
|
||||
*/
|
||||
function PageButton({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
data-qqq-id={`button-esb-messages-${label.toLowerCase().replace(' ', '-')}`}
|
||||
className="inline-flex items-center rounded-md border border-input bg-background px-3 py-1 text-xs font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Props for {@link EsbBrowseButton}. */
|
||||
interface EsbBrowseButtonProps extends EsbMessageListProps {
|
||||
/** Visible button text. */
|
||||
label: string
|
||||
/** Accessible name when the visible text needs context; it starts with `label`. */
|
||||
ariaLabel?: string
|
||||
/** Dialog heading. */
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A button that opens an {@link EsbMessageList} in a dialog. The list, and its requests, exist
|
||||
* only while the dialog is open.
|
||||
*
|
||||
* @param props - See {@link EsbBrowseButtonProps}.
|
||||
* @returns The button and its dialog.
|
||||
*/
|
||||
export function EsbBrowseButton({ label, ariaLabel, title, ...listProps }: EsbBrowseButtonProps) {
|
||||
const name =
|
||||
listProps.source.kind === 'destination' ? listProps.source.name : listProps.source.triggerName
|
||||
return (
|
||||
<DialogPrimitive.Root>
|
||||
<DialogPrimitive.Trigger
|
||||
aria-label={ariaLabel}
|
||||
data-qqq-id={`button-esb-browse-${listProps.source.kind}-${name}`}
|
||||
className="inline-flex items-center rounded-md border border-input bg-background px-2 py-1 text-xs font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{label}
|
||||
</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
data-qqq-id={`esb-browse-dialog-${name}`}
|
||||
className="fixed left-1/2 top-1/2 z-50 max-h-[85vh] w-[calc(100%-2rem)] max-w-4xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-xl border border-border bg-background p-6 shadow-lg focus:outline-none"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold text-foreground">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close
|
||||
aria-label="Close"
|
||||
data-qqq-id="button-esb-browse-close"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<EsbMessageList {...listProps} />
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the table Developer-view ESB section
|
||||
|
||||
import React from 'react'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '@/mocks/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { axe } from 'jest-axe'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { processInit } from '@/lib/api/processes'
|
||||
|
||||
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
|
||||
|
||||
import { fulfillOrderEsb, orderEsb, orderFulfillmentMessages } from '@/mocks/fixtures/esb'
|
||||
import { EsbSection } from './EsbSection'
|
||||
|
||||
/**
|
||||
* Reads the value of one counter inside a row.
|
||||
*
|
||||
* @param scope - The row (or cell) holding the counters.
|
||||
* @param label - The counter's label.
|
||||
* @returns The counter's displayed value.
|
||||
*/
|
||||
function counter(scope: HTMLElement, label: string): string | null | undefined {
|
||||
return within(scope).getByText(label, { selector: 'dt' }).nextElementSibling?.textContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the body rows of the table with the given accessible name.
|
||||
*
|
||||
* @param name - The table's accessible name.
|
||||
* @returns Its rows, excluding the header row.
|
||||
*/
|
||||
function bodyRows(name: string): HTMLElement[] {
|
||||
return within(screen.getByRole('table', { name })).getAllByRole('row').slice(1)
|
||||
}
|
||||
|
||||
describe('EsbSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(processInit).mockReset()
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'COMPLETE',
|
||||
processUUID: 'queue-run',
|
||||
values: { message: 'Done.' },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['subscription', false],
|
||||
['subscription', true],
|
||||
['subscription', null],
|
||||
['subscription', undefined],
|
||||
['deadLetter', false],
|
||||
['deadLetter', true],
|
||||
['deadLetter', null],
|
||||
['deadLetter', undefined],
|
||||
] as const)('uses %s paused=%s for broker queue controls', async (kind, paused) => {
|
||||
const user = userEvent.setup()
|
||||
const original = orderEsb.subscribers[kind === 'subscription' ? 1 : 0]
|
||||
const brokerName = 'serialized.custom.queue'
|
||||
const brokerQueue = { brokerName, messageCount: 9, ...(paused === undefined ? {} : { paused }) }
|
||||
const trigger = { ...original, [kind]: brokerQueue }
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<EsbSection
|
||||
data={{
|
||||
...orderEsb,
|
||||
publications: [],
|
||||
subscribers: [trigger],
|
||||
permissions: { canOperate: true, canDelete: true },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
if (kind === 'deadLetter') {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: `Browse dead letters for ${trigger.processLabel}` })
|
||||
)
|
||||
await screen.findByRole('dialog')
|
||||
}
|
||||
if (paused == null) {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `Pause queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `Resume queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
} else {
|
||||
const action = paused ? 'Resume' : 'Pause'
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `${paused ? 'Pause' : 'Resume'} queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: `${action} queue ${brokerName}` }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith(paused ? 'esbResumeQueue' : 'esbPauseQueue', {
|
||||
values: { providerName: 'artemis', brokerQueueName: brokerName },
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['subscription', 'permission'],
|
||||
['subscription', 'capability'],
|
||||
['deadLetter', 'permission'],
|
||||
['deadLetter', 'capability'],
|
||||
] as const)(
|
||||
'hides %s pause/resume without %s even when state is known',
|
||||
async (kind, missing) => {
|
||||
const user = userEvent.setup()
|
||||
const original = orderEsb.subscribers[kind === 'subscription' ? 1 : 0]
|
||||
const brokerName = 'restricted.queue'
|
||||
const trigger = {
|
||||
...original,
|
||||
[kind]: { brokerName, messageCount: 9, paused: false },
|
||||
destination: {
|
||||
...original.destination,
|
||||
capabilities: {
|
||||
...original.destination.capabilities,
|
||||
pauseQueue: missing !== 'capability',
|
||||
},
|
||||
},
|
||||
}
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<EsbSection
|
||||
data={{
|
||||
...orderEsb,
|
||||
publications: [],
|
||||
subscribers: [trigger],
|
||||
permissions: { canOperate: missing !== 'permission', canDelete: true },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
if (kind === 'deadLetter') {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: `Browse dead letters for ${trigger.processLabel}` })
|
||||
)
|
||||
await screen.findByRole('dialog')
|
||||
}
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `Pause queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `Resume queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('uses the serialized subscription name for controls and trigger for browsing', async () => {
|
||||
let requestedTrigger: string | null = null
|
||||
server.use(
|
||||
http.get('/qqq/v1/esb/messages/orderEvents', ({ request }) => {
|
||||
requestedTrigger = new URL(request.url).searchParams.get('trigger')
|
||||
return HttpResponse.json(orderFulfillmentMessages)
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<EsbSection
|
||||
data={{
|
||||
...orderEsb,
|
||||
subscribers: [
|
||||
{
|
||||
...orderEsb.subscribers[1],
|
||||
subscription: { brokerName: 'custom.subscription.queue', messageCount: 9 },
|
||||
},
|
||||
],
|
||||
permissions: { canOperate: true, canDelete: true },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.getByText('custom.subscription.queue')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Purge custom.subscription.queue' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Pause queue custom.subscription.queue' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Browse subscription for Cancel Order' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'Messages in custom.subscription.queue',
|
||||
})
|
||||
await within(dialog).findByRole('table', { name: 'Messages' })
|
||||
await waitFor(() => expect(requestedTrigger).toBe('cancelOrder.orderEvents'))
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: 'Replay selected' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides browsing and broker controls when the trigger destination has no capabilities', () => {
|
||||
const trigger = orderEsb.subscribers[1]
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<EsbSection
|
||||
data={{
|
||||
...orderEsb,
|
||||
publications: [],
|
||||
subscribers: [
|
||||
{
|
||||
...trigger,
|
||||
destination: {
|
||||
...trigger.destination,
|
||||
capabilities: {
|
||||
browse: false,
|
||||
pauseQueue: false,
|
||||
purge: false,
|
||||
deleteSelected: false,
|
||||
deleteOlderThan: false,
|
||||
move: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
permissions: { canOperate: true, canDelete: true },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /Browse/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Purge/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when the table has no ESB section', () => {
|
||||
const { container } = render(<EsbSection data={null} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('lists publications with destination, type, events, counters and queue depth', () => {
|
||||
render(<EsbSection data={orderEsb} />)
|
||||
expect(screen.getByRole('heading', { name: 'Enterprise Service Bus' })).toBeInTheDocument()
|
||||
const [topic, queue] = bodyRows('Publications')
|
||||
expect(bodyRows('Publications')).toHaveLength(2)
|
||||
|
||||
const topicCells = within(topic).getAllByRole('cell')
|
||||
expect(topicCells[0]).toHaveTextContent('orderEvents')
|
||||
expect(topicCells[1]).toHaveTextContent('Topic')
|
||||
expect(topicCells[2]).toHaveTextContent('INSERT, UPDATE, DELETE')
|
||||
expect(counter(topic, 'Published')).toBe('128')
|
||||
expect(counter(topic, 'Publish failures')).toBe('2')
|
||||
|
||||
const queueCells = within(queue).getAllByRole('cell')
|
||||
expect(queueCells[0]).toHaveTextContent('orderFulfillment')
|
||||
expect(queueCells[1]).toHaveTextContent('Queue')
|
||||
expect(queueCells[2]).toHaveTextContent('INSERT')
|
||||
expect(counter(queue, 'Published')).toBe('40')
|
||||
expect(queueCells[4]).toHaveTextContent('7')
|
||||
})
|
||||
|
||||
it('shows "—" for queue depth when the broker reports no queue info', () => {
|
||||
render(<EsbSection data={orderEsb} />)
|
||||
const [topic, queue] = bodyRows('Publications')
|
||||
expect(within(topic).getAllByRole('cell')[4]).toHaveTextContent(/^—$/)
|
||||
expect(within(queue).getAllByRole('cell')[4]).toHaveTextContent(/^7$/)
|
||||
})
|
||||
|
||||
it('lists subscribers with process link, state, counters and dead-letter count', () => {
|
||||
render(<EsbSection data={orderEsb} />)
|
||||
const [fulfill, cancel] = bodyRows('Subscribers')
|
||||
expect(bodyRows('Subscribers')).toHaveLength(2)
|
||||
|
||||
const fulfillCells = within(fulfill).getAllByRole('cell')
|
||||
expect(within(fulfillCells[0]).getByRole('link', { name: 'Fulfill Order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/fulfillOrder/dev'
|
||||
)
|
||||
expect(fulfillCells[0]).toHaveTextContent('Single, concurrency 2, 3 attempts')
|
||||
expect(fulfillCells[1]).toHaveTextContent('orderFulfillment')
|
||||
expect(fulfillCells[2]).toHaveTextContent('Running')
|
||||
expect(counter(fulfill, 'Consumed')).toBe('33')
|
||||
expect(counter(fulfill, 'Succeeded')).toBe('30')
|
||||
expect(counter(fulfill, 'Failed')).toBe('3')
|
||||
expect(counter(fulfill, 'Retried')).toBe('4')
|
||||
expect(counter(fulfill, 'Dead-lettered')).toBe('1')
|
||||
expect(counter(fulfill, 'In flight')).toBe('1')
|
||||
expect(counter(fulfill, 'Avg time')).toBe('120 ms')
|
||||
expect(counter(fulfill, 'Max time')).toBe('950 ms')
|
||||
expect(counter(fulfill, 'Last error')).toBe('Warehouse API timed out')
|
||||
expect(fulfillCells[4]).toHaveTextContent(/^1$/)
|
||||
|
||||
const cancelCells = within(cancel).getAllByRole('cell')
|
||||
expect(within(cancelCells[0]).getByRole('link', { name: 'Cancel Order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/cancelOrder/dev'
|
||||
)
|
||||
expect(cancelCells[1]).toHaveTextContent('orderEvents')
|
||||
expect(cancelCells[2]).toHaveTextContent('Paused')
|
||||
expect(within(cancel).queryByText('Last error')).not.toBeInTheDocument()
|
||||
expect(cancelCells[4]).toHaveTextContent(/^—$/)
|
||||
})
|
||||
|
||||
it('says so when there are no publications or subscribers', () => {
|
||||
render(<EsbSection data={{ ...orderEsb, publications: [], subscribers: [] }} />)
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('This table does not publish to any destination.')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('No processes you can access are triggered by these destinations.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("offers browsing a queue and a trigger's dead letters to anyone who sees them", () => {
|
||||
render(<EsbSection data={orderEsb} />)
|
||||
const [topic, queue] = bodyRows('Publications')
|
||||
expect(within(topic).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(queue).getByRole('button', { name: 'Browse orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
expect(within(queue).queryByRole('button', { name: /^Purge/ })).not.toBeInTheDocument()
|
||||
const [fulfill] = bodyRows('Subscribers')
|
||||
expect(
|
||||
within(fulfill).getByRole('button', { name: 'Browse dead letters for Fulfill Order' })
|
||||
).toBeInTheDocument()
|
||||
expect(within(fulfill).queryByRole('button', { name: /^Pause/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows management actions when the user may operate and delete', () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<EsbSection data={{ ...orderEsb, permissions: { canOperate: true, canDelete: true } }} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
const [topic, queue] = bodyRows('Publications')
|
||||
expect(within(topic).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(queue).getByRole('button', { name: 'Purge orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(queue).getByRole('button', { name: 'Pause queue orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
const [fulfill, cancel] = bodyRows('Subscribers')
|
||||
expect(within(fulfill).getByRole('button', { name: 'Pause Fulfill Order' })).toBeInTheDocument()
|
||||
expect(within(cancel).getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders a process's publications and the triggers that start it", () => {
|
||||
render(<EsbSection data={fulfillOrderEsb} />)
|
||||
expect(bodyRows('Publications')).toHaveLength(1)
|
||||
expect(bodyRows('Triggers')).toHaveLength(1)
|
||||
expect(screen.queryByRole('table', { name: 'Subscribers' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says so when a process has no publications or triggers', () => {
|
||||
render(<EsbSection data={{ ...fulfillOrderEsb, publications: [], triggers: [] }} />)
|
||||
expect(
|
||||
screen.getByText('This process does not publish to any destination.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('No destinations trigger this process.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no accessibility violations', async () => {
|
||||
const { container } = render(<EsbSection data={orderEsb} />)
|
||||
// next/link updates its prefetch state while axe runs; act() flushes it.
|
||||
let results: Awaited<ReturnType<typeof axe>> | undefined
|
||||
await act(async () => {
|
||||
results = await axe(container)
|
||||
})
|
||||
expect(results).toHaveNoViolations()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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 EsbSection — the ESB part of a table's or process's Developer view: the destinations it
|
||||
* publishes to and the triggers on them, with per-node counters and management actions.
|
||||
*/
|
||||
|
||||
import React, { useId } from 'react'
|
||||
import { Network } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
EsbDestination,
|
||||
EsbDestinationType,
|
||||
EsbPermissions,
|
||||
EsbProcessResponse,
|
||||
EsbTableResponse,
|
||||
} from '@/types'
|
||||
import { EsbQueueActions, destinationQueue } from './EsbActions'
|
||||
import { EsbCounters } from './EsbCounters'
|
||||
import { EsbBrowseButton } from './EsbMessageList'
|
||||
import { EsbTriggerRow } from './EsbTriggerRow'
|
||||
|
||||
/** Label for each destination type. */
|
||||
export const DESTINATION_TYPES: Record<EsbDestinationType, string> = { QUEUE: 'Queue', TOPIC: 'Topic' }
|
||||
|
||||
/** Column headings of a trigger table, matching {@link EsbTriggerRow}. */
|
||||
export const TRIGGER_COLUMNS = ['Process', 'Destination', 'State', 'Counters', 'Dead letters', 'Actions']
|
||||
|
||||
/** Props for {@link EsbSection}. */
|
||||
interface EsbSectionProps {
|
||||
/** The table's or process's ESB data, or `null` when it has none or the user may not see it. */
|
||||
data: EsbTableResponse | EsbProcessResponse | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a table's or process's ESB publications and triggers, or nothing when `data` is `null`.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.data - Response of `GET /esb/table/{table}` or `GET /esb/process/{process}`, or `null`.
|
||||
* @returns A section with a Publications table (destination, type, events, counters, queue
|
||||
* depth, actions) and a Subscribers table for a table or a Triggers table for a process
|
||||
* (process, destination, state, counters, dead letters, actions), each replaced by a
|
||||
* sentence when empty; or `null`.
|
||||
*/
|
||||
export function EsbSection({ data }: EsbSectionProps) {
|
||||
const headingId = useId()
|
||||
if (!data) return null
|
||||
|
||||
const isTable = 'table' in data
|
||||
const owner = isTable ? data.table : data.process
|
||||
const triggers = isTable ? data.subscribers : data.triggers
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className="space-y-4 rounded-xl border border-border bg-card p-4"
|
||||
data-qqq-id={`esb-section-${owner}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
|
||||
<h3 id={headingId} className="text-lg font-semibold text-foreground">
|
||||
Enterprise Service Bus
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<EsbList
|
||||
title="Publications"
|
||||
columns={['Destination', 'Type', 'Events', 'Counters', 'Queue depth', 'Actions']}
|
||||
empty={`This ${isTable ? 'table' : 'process'} does not publish to any destination.`}
|
||||
>
|
||||
{data.publications.map(({ destination, events }) => (
|
||||
<tr
|
||||
key={destination.name}
|
||||
className="border-b border-border/50 align-top last:border-0"
|
||||
data-qqq-id={`esb-publication-${destination.name}`}
|
||||
>
|
||||
<td className="px-2 py-2 font-mono text-xs text-foreground">{destination.name}</td>
|
||||
<td className="px-2 py-2 text-foreground">
|
||||
{DESTINATION_TYPES[destination.type] ?? destination.type}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-foreground">{events.join(', ')}</td>
|
||||
<td className="px-2 py-2">
|
||||
<EsbCounters counters={destination.counters} kind="publish" />
|
||||
</td>
|
||||
<td className="px-2 py-2 tabular-nums text-foreground">
|
||||
{destination.queueInfo ? destination.queueInfo.messageCount : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<EsbDestinationActions destination={destination} permissions={data.permissions} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</EsbList>
|
||||
|
||||
<EsbList
|
||||
title={isTable ? 'Subscribers' : 'Triggers'}
|
||||
columns={TRIGGER_COLUMNS}
|
||||
empty={
|
||||
isTable
|
||||
? 'No processes you can access are triggered by these destinations.'
|
||||
: 'No destinations trigger this process.'
|
||||
}
|
||||
>
|
||||
{triggers.map((trigger) => (
|
||||
<EsbTriggerRow key={trigger.name} trigger={trigger} permissions={data.permissions} />
|
||||
))}
|
||||
</EsbList>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsing and management actions for the queue behind a destination. A topic has none: its
|
||||
* messages wait in each trigger's subscription, not on the topic.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.destination - The destination.
|
||||
* @param props.permissions - The current user's ESB permissions.
|
||||
* @returns A browse button when the broker supports browsing, and the permitted queue actions;
|
||||
* or `null` for a topic.
|
||||
*/
|
||||
export function EsbDestinationActions({
|
||||
destination,
|
||||
permissions,
|
||||
}: {
|
||||
destination: EsbDestination
|
||||
permissions: EsbPermissions
|
||||
}) {
|
||||
const queue = destinationQueue(destination)
|
||||
if (!queue) return null
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{queue.capabilities.browse && (
|
||||
<EsbBrowseButton
|
||||
label="Browse"
|
||||
ariaLabel={`Browse ${destination.name}`}
|
||||
title={`Messages in ${destination.name}`}
|
||||
source={{ kind: 'destination', name: destination.name }}
|
||||
queue={queue}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
<EsbQueueActions queue={queue} permissions={permissions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled table of ESB rows, or a sentence when there are none.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.title - Heading, also the table's accessible name.
|
||||
* @param props.columns - Column headings.
|
||||
* @param props.empty - Sentence shown when there are no rows.
|
||||
* @param props.children - The table rows.
|
||||
* @returns The heading followed by the table or the empty sentence.
|
||||
*/
|
||||
export function EsbList({
|
||||
title,
|
||||
columns,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
columns: string[]
|
||||
empty: string
|
||||
children: React.ReactNode[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-foreground">{title}</h4>
|
||||
{children.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{empty}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" aria-label={title}>
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
scope="col"
|
||||
className="whitespace-nowrap px-2 py-1.5 font-medium text-muted-foreground"
|
||||
>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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 EsbTriggerRow — one table row for a process triggered by an ESB destination, with its
|
||||
* management actions.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import type { EsbPermissions, EsbTrigger, EsbTriggerMode, EsbTriggerState } from '@/types'
|
||||
import { CHIP_COLOR_CLASSES, type ChipColor } from '@/lib/utils/adornment-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { EsbTriggerActions, EsbQueueActions, deadLetterQueue, type EsbQueueRef } from './EsbActions'
|
||||
import { EsbCounters } from './EsbCounters'
|
||||
import { EsbBrowseButton } from './EsbMessageList'
|
||||
|
||||
/** Label and chip color for each trigger state. */
|
||||
const STATES: Record<EsbTriggerState, { label: string; color: ChipColor }> = {
|
||||
RUNNING: { label: 'Running', color: 'success' },
|
||||
PAUSED: { label: 'Paused', color: 'warning' },
|
||||
CONNECTING: { label: 'Connecting', color: 'info' },
|
||||
STOPPED: { label: 'Stopped', color: 'default' },
|
||||
}
|
||||
|
||||
/** Label for each trigger mode. */
|
||||
const MODES: Record<EsbTriggerMode, string> = { SINGLE: 'Single', BATCH: 'Batch' }
|
||||
|
||||
/**
|
||||
* Renders a trigger as a row of six cells: the process (linked, with its mode, concurrency
|
||||
* and attempts), the destination it consumes, its state, its counters, the number of
|
||||
* messages in its dead-letter queue (`—` when the broker cannot report it), and its actions:
|
||||
* pause or resume, restart and replay when permitted, and browsing its dead letters.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.trigger - The trigger to show.
|
||||
* @param props.permissions - The current user's ESB permissions.
|
||||
* @returns A `<tr>` for a table with Process, Destination, State, Counters, Dead letters and
|
||||
* Actions columns.
|
||||
*/
|
||||
export function EsbTriggerRow({
|
||||
trigger,
|
||||
permissions,
|
||||
}: {
|
||||
trigger: EsbTrigger
|
||||
permissions: EsbPermissions
|
||||
}) {
|
||||
const subscription: EsbQueueRef | null = trigger.subscription
|
||||
? {
|
||||
providerName: trigger.destination.provider,
|
||||
brokerQueueName: trigger.subscription.brokerName,
|
||||
capabilities: trigger.destination.capabilities,
|
||||
messageCount: trigger.subscription.messageCount,
|
||||
paused: trigger.subscription.paused ?? null,
|
||||
}
|
||||
: null
|
||||
const state = STATES[trigger.state] ?? { label: trigger.state, color: 'default' }
|
||||
const attempts = `${trigger.maxAttempts} ${trigger.maxAttempts === 1 ? 'attempt' : 'attempts'}`
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-border/50 align-top last:border-0"
|
||||
data-qqq-id={`esb-trigger-${trigger.name}`}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
<Link
|
||||
href={`/app/${encodeURIComponent(trigger.processName)}/dev`}
|
||||
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{trigger.processLabel}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{MODES[trigger.mode] ?? trigger.mode}, concurrency {trigger.concurrency}, {attempts}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-2 py-2 font-mono text-xs text-foreground">
|
||||
{trigger.destination.name}
|
||||
{subscription && <p className="text-muted-foreground">{subscription.brokerQueueName}</p>}
|
||||
{subscription && <p>Subscription depth: {subscription.messageCount ?? '—'}</p>}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||
CHIP_COLOR_CLASSES[state.color]
|
||||
)}
|
||||
>
|
||||
{state.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<EsbCounters counters={trigger.counters} kind="consume" />
|
||||
</td>
|
||||
<td className="px-2 py-2 tabular-nums text-foreground">
|
||||
{trigger.deadLetter.messageCount ?? '—'}
|
||||
</td>
|
||||
<td className="space-y-1 px-2 py-2">
|
||||
<EsbTriggerActions trigger={trigger} permissions={permissions} />
|
||||
{subscription && (
|
||||
<>
|
||||
{subscription.capabilities.browse && (
|
||||
<EsbBrowseButton
|
||||
label="Browse subscription"
|
||||
ariaLabel={`Browse subscription for ${trigger.processLabel}`}
|
||||
title={`Messages in ${subscription.brokerQueueName}`}
|
||||
source={{
|
||||
kind: 'subscription',
|
||||
name: trigger.destination.name,
|
||||
triggerName: trigger.name,
|
||||
}}
|
||||
queue={subscription}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
<EsbQueueActions queue={subscription} permissions={permissions} />
|
||||
</>
|
||||
)}
|
||||
{trigger.destination.capabilities.browse && (
|
||||
<EsbBrowseButton
|
||||
label="Browse dead letters"
|
||||
ariaLabel={`Browse dead letters for ${trigger.processLabel}`}
|
||||
title={`Dead letters for ${trigger.processLabel}`}
|
||||
source={{ kind: 'deadLetters', triggerName: trigger.name }}
|
||||
queue={deadLetterQueue(trigger)}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the process Developer view
|
||||
|
||||
import React from 'react'
|
||||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { axe } from 'jest-axe'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { fulfillOrderEsb } from '@/mocks/fixtures/esb'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import type { EsbProcessResponse } from '@/types'
|
||||
import { ProcessDeveloperView } from './ProcessDeveloperView'
|
||||
|
||||
const fulfillOrder = qInstance.processes.fulfillOrder
|
||||
|
||||
/**
|
||||
* Renders the view inside a query client.
|
||||
*
|
||||
* @param esb - The process's ESB data.
|
||||
* @returns The render container.
|
||||
*/
|
||||
function renderView(esb: EsbProcessResponse | null): HTMLElement {
|
||||
const { container } = render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ProcessDeveloperView process={fulfillOrder} esb={esb} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the value shown for a summary statistic.
|
||||
*
|
||||
* @param label - The statistic's label.
|
||||
* @returns Its displayed value.
|
||||
*/
|
||||
function stat(label: string): string | null | undefined {
|
||||
return screen.getByText(label, { selector: 'dt' }).nextElementSibling?.textContent
|
||||
}
|
||||
|
||||
describe('ProcessDeveloperView', () => {
|
||||
it('summarizes the process metadata', () => {
|
||||
renderView(null)
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Process Developer View: fulfillOrder' })
|
||||
).toBeInTheDocument()
|
||||
expect(stat('Label')).toBe('Fulfill Order')
|
||||
expect(stat('Steps')).toBe('2')
|
||||
expect(stat('Table')).toBe('order')
|
||||
expect(stat('Access')).toBe('Allowed')
|
||||
})
|
||||
|
||||
it('shows the full metadata as JSON on demand', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderView(null)
|
||||
expect(screen.queryByText(/"trackingNumber"/)).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Full Process Metadata' }))
|
||||
expect(screen.getByText(/"trackingNumber"/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the process\'s ESB publications and triggers', () => {
|
||||
renderView(fulfillOrderEsb)
|
||||
expect(screen.getByRole('heading', { name: 'Enterprise Service Bus' })).toBeInTheDocument()
|
||||
const publications = screen.getByRole('table', { name: 'Publications' })
|
||||
expect(within(publications).getByText('fulfillmentResults')).toBeInTheDocument()
|
||||
expect(publications).toHaveTextContent('COMPLETED, FAILED')
|
||||
const triggers = screen.getByRole('table', { name: 'Triggers' })
|
||||
expect(within(triggers).getAllByRole('row')).toHaveLength(2)
|
||||
expect(triggers).toHaveTextContent('orderFulfillment')
|
||||
})
|
||||
|
||||
it('omits the ESB section when the process has none', () => {
|
||||
renderView(null)
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'Enterprise Service Bus' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no accessibility violations', async () => {
|
||||
const container = renderView(fulfillOrderEsb)
|
||||
let results: Awaited<ReturnType<typeof axe>> | undefined
|
||||
await act(async () => {
|
||||
results = await axe(container)
|
||||
})
|
||||
expect(results).toHaveNoViolations()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 ProcessDeveloperView — a process's Developer view: a metadata summary, the process's ESB
|
||||
* publications and triggers, and its full metadata as JSON.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Code } from 'lucide-react'
|
||||
|
||||
import type { EsbProcessResponse, QProcessMetaData } from '@/types'
|
||||
import { EsbSection } from './EsbSection'
|
||||
|
||||
/** Props for {@link ProcessDeveloperView}. */
|
||||
interface ProcessDeveloperViewProps {
|
||||
/** The process's metadata. */
|
||||
process: QProcessMetaData
|
||||
/** The process's ESB data, or `null` when it has none or the user may not see it. */
|
||||
esb: EsbProcessResponse | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a process's Developer view.
|
||||
*
|
||||
* @param props - See {@link ProcessDeveloperViewProps}.
|
||||
* @returns A heading, a summary (label, step count, table, access), the `<EsbSection>` when
|
||||
* `esb` is not `null`, and a collapsible block with the full metadata as JSON.
|
||||
*/
|
||||
export function ProcessDeveloperView({ process, esb }: ProcessDeveloperViewProps) {
|
||||
const [jsonOpen, setJsonOpen] = useState(false)
|
||||
const stats: Array<[string, string]> = [
|
||||
['Label', process.label],
|
||||
['Steps', String((process.frontendSteps ?? []).length)],
|
||||
['Table', process.tableName || '—'],
|
||||
['Access', process.hasPermission === false ? 'Denied' : 'Allowed'],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id={`process-dev-${process.name}`}>
|
||||
<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">
|
||||
Process Developer View: <span className="font-mono text-primary">{process.name}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{stats.map(([label, value]) => (
|
||||
<div key={label} className="rounded-xl border border-border bg-card p-4">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-1 text-lg font-semibold text-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<EsbSection data={esb} />
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJsonOpen((open) => !open)}
|
||||
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={jsonOpen}
|
||||
data-qqq-id="button-json-block-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" aria-hidden="true" />
|
||||
Full Process Metadata
|
||||
</span>
|
||||
{jsonOpen ? (
|
||||
<ChevronUp className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{jsonOpen && (
|
||||
<pre
|
||||
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
|
||||
data-qqq-id="json-output"
|
||||
>
|
||||
{JSON.stringify(process, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,15 +17,18 @@
|
||||
// Tests for CommandMenu: typed entries from navigation targets
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
|
||||
const pushMock = vi.fn()
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock }) }))
|
||||
let pathname = '/app'
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock }), usePathname: () => pathname }))
|
||||
|
||||
import { CommandMenu } from './CommandMenu'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import type { QInstance, QTableMetaData } from '@/types'
|
||||
import { addRecentRecord } from '@/lib/utils/recent-records'
|
||||
|
||||
const app = { label: 'People App', path: '/app/peopleApp' }
|
||||
const targets: NavTarget[] = [
|
||||
@@ -67,4 +70,76 @@ describe('CommandMenu', () => {
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/clonePeople')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ranks labels that start with the search first and matches several words in order (Material filter)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const more: NavTarget[] = [
|
||||
...targets,
|
||||
{ key: 'petSpecies', label: 'Pet Species', path: '/app/petSpecies', nodeType: 'TABLE', ancestors: [] },
|
||||
{ key: 'carpet', label: 'Carpet', path: '/app/carpet', nodeType: 'TABLE', ancestors: [] },
|
||||
]
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={more} />)
|
||||
await user.type(screen.getByRole('combobox'), 'pet')
|
||||
expect(screen.getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet', 'Pet Species', 'Carpet'])
|
||||
await user.type(screen.getByRole('combobox'), ' spe')
|
||||
expect(screen.getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet Species'])
|
||||
await user.clear(screen.getByRole('combobox'))
|
||||
await user.type(screen.getByRole('combobox'), 'nothing here')
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('No results found.')
|
||||
})
|
||||
|
||||
describe('current table actions and recently viewed records', () => {
|
||||
const person = {
|
||||
name: 'person', label: 'Person', primaryKeyField: 'id', capabilities: ['TABLE_INSERT', 'TABLE_UPDATE'],
|
||||
readPermission: true, insertPermission: true, editPermission: true, deletePermission: false,
|
||||
} as unknown as QTableMetaData
|
||||
const metaData = {
|
||||
tables: { person, audit: { name: 'audit', label: 'Audit', readPermission: true } },
|
||||
processes: { clonePeople: { name: 'clonePeople', label: 'Clone People', tableName: 'person', isHidden: false, hasPermission: true, iconName: 'content_copy' } },
|
||||
} as unknown as QInstance
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
pushMock.mockClear()
|
||||
})
|
||||
|
||||
it('offers the table actions on a record view and opens the audit through the hash', async () => {
|
||||
pathname = '/app/person/7'
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<CommandMenu open onClose={onClose} navTargets={targets} metaData={metaData} />)
|
||||
const group = document.querySelector('[data-qqq-id="command-group-actions"]')!
|
||||
expect(group).toHaveTextContent('Person Actions')
|
||||
expect(Array.from(group.querySelectorAll('[role="option"]')).map((option) => option.textContent)).toEqual(['New', 'Copy', 'Edit', 'Audit', 'Clone People'])
|
||||
await user.click(screen.getByRole('option', { name: 'Edit' }))
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/person/7/edit')
|
||||
await user.click(screen.getByRole('option', { name: 'Audit' }))
|
||||
expect(window.location.hash).toBe('#audit')
|
||||
window.location.hash = ''
|
||||
})
|
||||
|
||||
it('offers New and the processes on the query screen, and nothing on the edit screen', () => {
|
||||
pathname = '/app/person'
|
||||
const { unmount } = render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={metaData} />)
|
||||
expect(Array.from(document.querySelectorAll('[data-qqq-id="command-group-actions"] [role="option"]')).map((option) => option.textContent)).toEqual(['New', 'Clone People'])
|
||||
unmount()
|
||||
pathname = '/app/person/7/edit'
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={metaData} />)
|
||||
expect(document.querySelector('[data-qqq-id="command-group-actions"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists recently viewed records with their table icon and opens them', async () => {
|
||||
pathname = '/app'
|
||||
addRecentRecord({ tableName: 'person', tableLabel: 'Person', tableIcon: { name: 'person' }, recordId: '7', recordLabel: 'Avery Sample', path: '/app/person/7' })
|
||||
const user = userEvent.setup()
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={metaData} />)
|
||||
const recent = document.querySelector('[data-qqq-id="command-recent-person-7"]')!
|
||||
expect(recent).toHaveTextContent('Avery SamplePerson')
|
||||
expect(recent.querySelector('svg')).toHaveAttribute('data-qqq-icon', 'person')
|
||||
await user.type(screen.getByRole('combobox'), 'avery')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/person/7')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,18 +15,22 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file CommandMenu — Cmd+K / Ctrl+K command palette that fuzzy-searches every navigable app, table, process and report.
|
||||
* @file CommandMenu — Cmd+K / Ctrl+K / "." command palette (Material CommandMenu): the current
|
||||
* table's actions, every navigable app, table, process and report, and the recently viewed records.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { buildTableActions, commandMatches, compareCommandLabels, tableScreenFor, type TableAction } from '@/lib/utils/command-palette'
|
||||
import { getRecentRecords, type RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
@@ -45,6 +49,28 @@ interface CommandMenuProps {
|
||||
onClose: () => void
|
||||
/** Navigable app-tree nodes (hidden objects already excluded). */
|
||||
navTargets: NavTarget[]
|
||||
/** Instance metadata, for the current table's actions (omit for navigation only). */
|
||||
metaData?: QInstance
|
||||
}
|
||||
|
||||
/** Shared classes of a palette entry (44 px tall on touch screens). */
|
||||
const ITEM_CLASS = cn(
|
||||
'flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm pointer-coarse:min-h-11',
|
||||
'text-foreground',
|
||||
'hover:bg-accent',
|
||||
'aria-selected:bg-primary/10 aria-selected:text-primary',
|
||||
'outline-none transition-colors'
|
||||
)
|
||||
|
||||
/**
|
||||
* A palette group heading.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.children - Heading text.
|
||||
* @returns The heading element.
|
||||
*/
|
||||
function GroupHeading({ children }: { children: React.ReactNode }) {
|
||||
return <span className="px-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">{children}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,9 +84,19 @@ interface CommandMenuProps {
|
||||
* @returns A fixed full-screen backdrop with the command palette dialog, or
|
||||
* `null` when `open` is `false`. Keyboard: ↑↓ navigate, ↵ open, Esc close.
|
||||
*/
|
||||
export function CommandMenu({ open, onClose, navTargets }: CommandMenuProps) {
|
||||
export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenuProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [search, setSearch] = useState('')
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
const [hash, setHash] = useState('')
|
||||
|
||||
// Recently viewed records and the hash (an open audit dialog hides the table actions) are read on open
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setRecentRecords(getRecentRecords())
|
||||
setHash(window.location.hash)
|
||||
}, [open])
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
@@ -122,6 +158,41 @@ export function CommandMenu({ open, onClose, navTargets }: CommandMenuProps) {
|
||||
[router, onClose]
|
||||
)
|
||||
|
||||
// The current table's actions (Material "{Table} Actions"), on its query and record screens
|
||||
const screen = useMemo(() => (open ? tableScreenFor(pathname ?? '', hash, metaData) : null), [open, pathname, hash, metaData])
|
||||
const tableActions = useMemo(() => (screen && metaData ? buildTableActions(screen, metaData) : []), [screen, metaData])
|
||||
|
||||
// Material's filter (per-word substring) and ranking (labels that start with the search first)
|
||||
const visibleActions = search ? tableActions.filter((action) => commandMatches(action.label, search)) : tableActions
|
||||
const visibleTargets = useMemo(() => {
|
||||
if (!search) return navTargets
|
||||
return navTargets
|
||||
.filter((target) => commandMatches([target.label, ...target.ancestors.map((ancestor) => ancestor.label)].join(' '), search))
|
||||
.sort((a, b) => compareCommandLabels(a.label, b.label, search))
|
||||
}, [navTargets, search])
|
||||
const visibleRecent = useMemo(() => {
|
||||
if (!search) return recentRecords
|
||||
return recentRecords
|
||||
.filter((record) => commandMatches(`${record.recordLabel} ${record.tableLabel}`, search))
|
||||
.sort((a, b) => compareCommandLabels(a.recordLabel, b.recordLabel, search))
|
||||
}, [recentRecords, search])
|
||||
|
||||
/**
|
||||
* Runs a table action: navigates to it, or opens the record's audit dialog (`#audit`).
|
||||
*
|
||||
* @param action - The chosen action.
|
||||
*/
|
||||
const runAction = (action: TableAction) => {
|
||||
if (action.path) {
|
||||
handleSelect(action.path)
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
// a hash change (not a router push) reaches the record view's hash handler
|
||||
window.history.pushState(null, '', '#audit')
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'))
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
@@ -147,14 +218,14 @@ export function CommandMenu({ open, onClose, navTargets }: CommandMenuProps) {
|
||||
ref={dialogRef}
|
||||
onKeyDown={trapFocus}
|
||||
>
|
||||
<Command shouldFilter={true} label="Command palette">
|
||||
<Command shouldFilter={false} label="Command palette">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search apps, tables, processes..."
|
||||
placeholder="Search for tables, actions, or recently viewed items..."
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autoFocus
|
||||
data-qqq-id="command-menu-search"
|
||||
@@ -177,32 +248,39 @@ export function CommandMenu({ open, onClose, navTargets }: CommandMenuProps) {
|
||||
aria-live="polite"
|
||||
data-qqq-id="command-menu-results"
|
||||
>
|
||||
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground" role="status" data-qqq-id="command-menu-empty">
|
||||
No results found.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{navTargets.length > 0 && (
|
||||
<Command.Group
|
||||
heading={
|
||||
<span className="px-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Navigation
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{navTargets.map((target) => {
|
||||
{screen && visibleActions.length > 0 && (
|
||||
<Command.Group heading={<GroupHeading>{screen.table.label} Actions</GroupHeading>} data-qqq-id="command-group-actions">
|
||||
{visibleActions.map((action) => (
|
||||
<Command.Item
|
||||
key={action.key}
|
||||
value={`action:${action.key}`}
|
||||
onSelect={() => runAction(action)}
|
||||
className={ITEM_CLASS}
|
||||
data-qqq-id={`command-action-${action.key}`}
|
||||
>
|
||||
<MetadataIcon icon={action.icon} kind="process" />
|
||||
<span className="flex-1 truncate font-medium">{action.label}</span>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{visibleTargets.length > 0 && (
|
||||
<Command.Group heading={<GroupHeading>Navigation</GroupHeading>} data-qqq-id="command-group-navigation">
|
||||
{visibleTargets.map((target) => {
|
||||
const context = target.ancestors.map((ancestor) => ancestor.label).join(' / ')
|
||||
return (
|
||||
<Command.Item
|
||||
key={target.path}
|
||||
value={`${target.label} ${context} ${target.path}`}
|
||||
value={`page:${target.path}`}
|
||||
onSelect={() => handleSelect(target.path)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm',
|
||||
'text-foreground',
|
||||
'hover:bg-accent',
|
||||
'aria-selected:bg-primary/10 aria-selected:text-primary',
|
||||
'outline-none transition-colors'
|
||||
)}
|
||||
className={ITEM_CLASS}
|
||||
data-qqq-id={`command-item-${target.key}`}
|
||||
data-node-type={target.nodeType}
|
||||
>
|
||||
@@ -216,6 +294,24 @@ export function CommandMenu({ open, onClose, navTargets }: CommandMenuProps) {
|
||||
})}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{visibleRecent.length > 0 && (
|
||||
<Command.Group heading={<GroupHeading>Recently Viewed Records</GroupHeading>} data-qqq-id="command-group-recent">
|
||||
{visibleRecent.map((record) => (
|
||||
<Command.Item
|
||||
key={record.path}
|
||||
value={`recent:${record.path}`}
|
||||
onSelect={() => handleSelect(record.path)}
|
||||
className={ITEM_CLASS}
|
||||
data-qqq-id={`command-recent-${record.tableName}-${record.recordId}`}
|
||||
>
|
||||
<MetadataIcon icon={record.tableIcon} kind="table" />
|
||||
<span className="flex-1 truncate font-medium">{record.recordLabel}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{record.tableLabel}</span>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
{/* Footer hint */}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
|
||||
describe('ErrorBoundary recovery', () => {
|
||||
it('runs the query recovery action before rendering its children again', async () => {
|
||||
let broken = true
|
||||
const onReset = vi.fn(() => { broken = false })
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
function Content() {
|
||||
if (broken) throw new Error('Bad saved filter')
|
||||
return <p>Recovered query</p>
|
||||
}
|
||||
|
||||
try {
|
||||
render(<ErrorBoundary onReset={onReset} resetLabel="Reset saved query and reload"><Content /></ErrorBoundary>)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Reset saved query and reload' }))
|
||||
expect(onReset).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText('Recovered query')).toBeVisible()
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loaded 100 of 709 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user