Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d0f93f75 | ||
|
|
9756b9d93b | ||
|
|
f205992878 | ||
|
|
5f503fa7e9 | ||
|
|
09828ab1cc | ||
|
|
c39748b1a0 | ||
|
|
829ef6b384 | ||
|
|
a94e1e39c8 | ||
|
|
9bea8cc63f | ||
|
|
ada9946eea | ||
|
|
fa960580f6
|
||
|
|
596bee88cc | ||
|
|
df764362b8 | ||
|
|
07b4a973de
|
||
|
|
e36afde222 | ||
|
|
69836fc62f
|
||
|
|
3cb3659a41 | ||
|
|
b30ff4e916
|
||
|
|
d558c1378d
|
||
|
|
f1c5f09b4a | ||
|
|
c9a56b0d9f
|
||
|
|
3bab5596ab
|
||
|
|
ae2498868d
|
||
|
|
ea21505e96
|
||
|
|
1b37e5efa9 | ||
|
|
ba20307fa0
|
||
|
|
81a03eab2d | ||
|
|
ab9cf837df
|
||
|
|
5c9307015c
|
||
|
|
55c150136b | ||
|
|
d644995c85
|
||
|
|
6f95554362
|
||
|
|
ba38367244 | ||
|
|
c623b3bc87
|
||
|
|
17f605261a | ||
|
|
0db8ab0bfa
|
||
|
|
68a2b9de0c | ||
|
|
af42c7613f
|
||
|
|
f5170ed130
|
||
|
|
77fc832f9e
|
||
|
|
627ad7ae34
|
||
|
|
03f67def30
|
||
|
|
cc64297d83
|
||
|
|
1d7c1a5ebf
|
||
|
|
960538ebc0 | ||
|
|
67eca54900
|
||
|
|
b4b59cee6e | ||
|
|
28fdb2a781 | ||
|
|
524adff0d5
|
||
|
|
893bbf26df
|
||
|
|
1df522097f
|
||
|
|
f5a56eaf7e
|
||
|
|
9b475797a0
|
||
|
|
5283503e89
|
||
|
|
bb9e083539
|
||
|
|
d5003ebdd5
|
||
|
|
9a63173efe | ||
|
|
93e0fd7303
|
||
|
|
b24b74e30d | ||
|
|
ea028dc86f
|
||
|
|
3e5b097667
|
||
|
|
98229202f3
|
||
|
|
9df3324687
|
||
|
|
ae6649f8e1 | ||
|
|
a504a21718
|
||
|
|
9aff198a60 | ||
|
|
2d125e4f90
|
||
|
|
ec974fa121
|
||
|
|
4f1641cb72
|
||
|
|
154be4f827
|
||
|
|
04fc4d7692
|
||
|
|
81fa126305
|
||
|
|
1fc984a3c6
|
||
|
|
a5fb8dd8d5
|
||
|
|
692d0cb166 | ||
|
|
e9208fc34c | ||
|
|
2cbfb6d962 | ||
|
|
b2a41b3eb9
|
||
|
|
35d937ed30 | ||
|
|
a00473c305
|
||
|
|
e76f0325a5
|
||
|
|
de1d4a5295
|
||
|
|
42ee6b87ce
|
||
|
|
a73e4e245c
|
||
|
|
e0ca5b1bb4
|
||
|
|
c9773cf45f
|
||
|
|
15cdffe7f3
|
||
|
|
41462cc4f6
|
||
|
|
039fa56a0b
|
||
|
|
f93a1d3c8f
|
||
|
|
600af361f4
|
||
|
|
0969231c95
|
||
|
|
99170dbff8
|
||
|
|
74cf6e88d7
|
||
|
|
0f6c37c998 | ||
|
|
4306da9cdd
|
||
|
|
abfbbb2e55 | ||
|
|
4139316789
|
||
|
|
aa0bc65774
|
||
|
|
86b54181f7 | ||
|
|
b32141cf65
|
||
|
|
16699aedfa | ||
|
|
e0ff899cb6
|
||
|
|
e807d30209
|
||
|
|
11d0f34639
|
||
|
|
45928e8abf
|
||
|
|
1a381c2a68
|
||
|
|
ee3682e859 | ||
|
|
62ec386035
|
||
|
|
427a037d53 | ||
|
|
2381579a52
|
||
|
|
112bbc557a | ||
|
|
463e9cc8e9
|
||
|
|
20fd1fa3d3 | ||
|
|
4f685d200b
|
||
|
|
9fc9cab48f
|
||
|
|
aafeabfb33
|
||
|
|
4663557ca4 | ||
|
|
3cf64ce24c
|
||
|
|
b19d4c09b4
|
||
|
|
caa89dc24f | ||
|
|
5f3b7c697d | ||
|
|
bebb4f2444
|
||
|
|
5afd785a22
|
||
|
|
2d6a33ea9f
|
||
|
|
70594c40ba | ||
|
|
9f3c48f4c7 | ||
|
|
38d2c62f3a
|
||
|
|
3ab4815526 | ||
|
|
f0b4c775f0 | ||
|
|
f29bedd437
|
||
|
|
336affb9e2
|
||
|
|
92b165eb9c
|
||
|
|
42fa4c3c5f
|
||
|
|
72872f5ccd
|
||
|
|
b89029c0ff
|
||
|
|
bb81e3c9a4
|
||
|
|
413c736050
|
||
|
|
c07fba1d4b | ||
|
|
8437aeb0eb
|
||
|
|
e39d4b847b | ||
|
|
9b596289af
|
||
|
|
e434716cbd
|
||
|
|
dcc3a47522 | ||
|
|
90fc22327c
|
||
|
|
39b8f8b7ae | ||
|
|
8f90df5ae9
|
||
|
|
e8bcb0bbe3
|
||
|
|
28b2de402c | ||
|
|
acd324e410 | ||
|
|
4c9d0ab9e1 | ||
|
|
cd2754b93c | ||
|
|
be72703bd7 | ||
|
|
219db989ed | ||
|
|
4cfb03c6b7 | ||
|
|
496cb0a14c | ||
|
|
996b0271de
|
||
|
|
2580cf95a6
|
||
|
|
a07213f633
|
||
|
|
17203f9876
|
||
|
|
e30c23dcbf
|
||
|
|
3fb7002a52
|
||
|
|
05ceeb1925
|
||
|
|
305d85bf61
|
||
|
|
4bd2c95d01
|
||
|
|
863099b95f
|
||
|
|
9d57861672
|
||
|
|
ce8da2d2a4
|
||
|
|
4c40679023
|
||
|
|
40cc60b1d4 | ||
|
|
292a2a9aea
|
||
|
|
771e46ecc3
|
||
|
|
51775ab0cb
|
||
|
|
4b8226a4cc
|
||
|
|
db941a8cc5 | ||
|
|
65db5355af
|
||
|
|
085430e17f
|
||
|
|
daf4005186
|
||
|
|
de51fcbf83
|
||
|
|
a521be57cc | ||
|
|
4e6473aae2
|
||
|
|
34efebb434
|
||
|
|
439c4b00e4
|
||
|
|
648d2d8bf8
|
||
|
|
f95baaf8c7
|
||
|
|
e78537ad36
|
||
|
|
8478404802
|
||
|
|
4ba1bb4cae | ||
|
|
c50675109a
|
||
|
|
ab9245e66c | ||
|
|
be2d0fc66e | ||
|
|
652ade887f | ||
|
|
3330f1bb63
|
||
|
|
1bfebd09fb
|
||
|
|
fb8b4efa6a
|
||
|
|
d3629d72b1
|
||
|
|
48e81b4d12 | ||
|
|
94494fc34f
|
||
|
|
2bdd7d980b
|
||
|
|
ff1ea58cf1
|
||
|
|
58cca84fda
|
||
|
|
91b335a42d
|
||
|
|
617bd1962d
|
||
|
|
cfba697339
|
||
|
|
3db5a0507d | ||
|
|
36c07c7aa7 | ||
|
|
2ba63346f0
|
||
|
|
a1c4a7de1a | ||
|
|
4d4ba49e8f | ||
|
|
4f2cf6eb64
|
||
|
|
4ad866286b
|
||
|
|
09d1c2b793
|
||
|
|
dc6b1e2373
|
||
|
|
96b789088e
|
||
|
|
a75fd8b5f6
|
||
|
|
728dd2b559 | ||
|
|
635def9ba8
|
||
|
|
bdc38f08e9 | ||
|
|
df9265e877 | ||
|
|
bb05d81cdb
|
||
|
|
54a2db37b7
|
||
|
|
3dfc93612f
|
||
|
|
89c35e9f2c
|
||
|
|
2e43dc23d8 | ||
|
|
5efb0782cd
|
||
|
|
bac9215e32 | ||
|
|
cc13b045f4 | ||
|
|
1e82e8696d
|
||
|
|
62d76af238
|
||
|
|
974407da47
|
||
|
|
bfe8a6e31c
|
||
|
|
a6c95b1ec7 | ||
|
|
e2838496b6
|
||
|
|
05be095a8d
|
||
|
|
3fcefdb6b4
|
||
|
|
5cafa460b0
|
||
|
|
a0a26b547b
|
||
|
|
8408f6c445
|
||
|
|
ebcd8aec30
|
||
|
|
ed0f056c85
|
||
|
|
acfd28eea1
|
||
|
|
365f57c21a
|
||
|
|
b0e30cfe36
|
||
|
|
9aacbf0052
|
||
|
|
3e140cb6c6
|
||
|
|
d7266d2951
|
||
|
|
b2a9e28ce0
|
||
|
|
c6f0fc87f6
|
||
|
|
f1bff85157
|
||
|
|
33f67fa8a8
|
||
|
|
69aef5f05c
|
||
|
|
db0b421678
|
||
|
|
01fb7f6965
|
||
|
|
d8bc9282db
|
||
|
|
1a6c126721
|
||
|
|
f833454c17
|
||
|
|
d01d470b6e
|
||
|
|
67cf8ed57e
|
||
|
|
0997deb12c
|
||
|
|
196c899389
|
||
|
|
7c755d5741
|
||
|
|
38204cdacc
|
||
|
|
622ae28ceb
|
||
|
|
63dcf7e144
|
||
|
|
9d2ece4228
|
||
|
|
1bb7db1924
|
||
|
|
cce505e5a5
|
||
|
|
2625355d88
|
||
|
|
6fa2ccf301
|
||
|
|
ac5aa368d7
|
||
|
|
3509ff8452
|
||
|
|
46436866be
|
||
|
|
35cc9da17c
|
||
|
|
1a7b8e371f
|
||
|
|
d011c546c0
|
||
|
|
e2e0c984ce
|
||
|
|
60d0b0ffc7 | ||
|
|
805ce7d72c
|
||
|
|
690737fa46
|
||
|
|
1f4a38b9ff
|
||
|
|
236fa03923
|
||
|
|
1afd72ad32
|
||
|
|
7c130187b9
|
||
|
|
35de9ef8bb
|
||
|
|
68eb31d18d | ||
|
|
1f624a2544
|
||
|
|
438eda36e3 | ||
|
|
7a75f5762c | ||
|
|
d1c181aae9 | ||
|
|
c09dd23fc3 | ||
|
|
9b006df8b2
|
||
|
|
0ef9fa7273
|
||
|
|
3d30490859
|
||
|
|
4376c28896
|
||
|
|
5fd1015b80
|
||
|
|
4604312b18
|
||
|
|
295cf16bbf
|
||
|
|
fc5f5d946d
|
||
|
|
fce8aad1ad
|
||
|
|
14ec5cc390
|
||
|
|
35249098bb | ||
|
|
d4f5bf9b7b
|
||
|
|
8f7e741bac
|
||
|
|
77d61670c3
|
||
|
|
33d7d6e9c8
|
||
|
|
dc1afc146f
|
||
|
|
343e603874
|
||
|
|
df8e27708f | ||
|
|
e598437ef5
|
||
|
|
816dad4a37
|
||
|
|
277116e35e
|
||
|
|
8a0fbbd334
|
||
|
|
eb3ad6853c
|
||
|
|
b7f3c71b74
|
||
|
|
6316df0b4a
|
||
|
|
455d8a587c | ||
|
|
88ac531e50 | ||
|
|
16a3c0797d
|
||
|
|
a2f231862c | ||
|
|
11a8f275d4 | ||
|
|
248bca9f43
|
||
|
|
220f106d62
|
||
|
|
c2d0f61c15
|
||
|
|
509ab237cf | ||
|
|
641f4718e5
|
||
|
|
46c38f5c05
|
||
|
|
0282328bb0 | ||
|
|
7fcb6ac301
|
||
|
|
4b65167cb9
|
||
|
|
385aa693b1 | ||
|
|
347d314ab0 | ||
|
|
6989970011
|
||
|
|
d62a63eb2e
|
||
|
|
39acafc61d | ||
|
|
4792a5b66f | ||
|
|
be6217537d | ||
|
|
6d0558653f
|
||
|
|
ffde7af138
|
||
|
|
30b61917fc
|
||
|
|
705defe5f6 | ||
|
|
2b84663d7b
|
||
|
|
820f5e2e21
|
||
|
|
aab7acf7a6
|
||
|
|
1056674285
|
||
|
|
b6688d780b
|
||
|
|
c63510e200 | ||
|
|
01dd7e9586
|
||
|
|
c7fe584911 | ||
|
|
6abfb8bdaa
|
||
|
|
05479bfcdf
|
||
|
|
6ed257565a | ||
|
|
9169112f18
|
||
|
|
03d7bae9e4
|
||
|
|
ef7f1c5714 | ||
|
|
42ddc06864
|
||
|
|
6dea1c3a86 | ||
|
|
ebc734545e
|
||
|
|
67e817adfa | ||
|
|
47dcd6ff50
|
||
|
|
7720ce2fbe | ||
|
|
e7ab2f8f9b | ||
|
|
bc79580464
|
||
|
|
008aa8daf8
|
||
|
|
22648a5c79
|
||
|
|
78edbdf4c3
|
||
|
|
4d673edb5a | ||
|
|
a3ad88bc6f
|
||
|
|
12b2eee0a3
|
||
|
|
6bc5ea875f
|
||
|
|
9a5acd9e9e
|
||
|
|
363f68eb40
|
||
|
|
1187660904
|
||
|
|
92bcefddb8
|
||
|
|
be6ea9a723 | ||
|
|
6e7c908c06
|
||
|
|
ae47adc03b | ||
|
|
15a7e43e6f | ||
|
|
8d02617aad
|
||
|
|
54a67f69ef | ||
|
|
8ea0c89312 | ||
|
|
ed079d632b
|
||
|
|
ebdd4c2289
|
||
|
|
64cd1e6dc9
|
||
|
|
f11cc85685 | ||
|
|
7398f1aa01 | ||
|
|
061a7cd09b
|
||
|
|
596815c605
|
||
|
|
5de69be7e3 | ||
|
|
8880ec7974
|
||
|
|
df59430694
|
||
|
|
4644b4023d
|
||
|
|
9842bc6554
|
||
|
|
f23e03e417
|
||
|
|
3508c505f5
|
||
|
|
b32a29d25b | ||
|
|
fec62ec611
|
||
|
|
75c9c5d64d
|
||
|
|
98409c85af | ||
|
|
ddc66c6b63
|
||
|
|
2ed5239642
|
||
|
|
725ef87efd
|
||
|
|
a724fc191c | ||
|
|
5b4ef76b01
|
||
|
|
c1be9c61b3
|
||
|
|
c8b7e9b16a
|
||
|
|
72377ac30b | ||
|
|
4d8b93ed42 | ||
|
|
cf6a326bca
|
||
|
|
c2aa695044
|
||
|
|
6c1e3a67b1
|
||
|
|
9d87b6dfee
|
||
|
|
21ba08bcd5
|
||
|
|
89add474cf | ||
|
|
3f161f6fd5 | ||
|
|
c183249a4b
|
||
|
|
9ce3b81b04
|
||
|
|
a62b10ae12
|
||
|
|
71bcc37c9e
|
||
|
|
a9975487f6
|
||
|
|
95931a7c7e
|
||
|
|
38207b3ef5
|
||
|
|
1f4841bf12
|
||
|
|
5629bcb3c7 | ||
|
|
4fe5ee94f2 | ||
|
|
ed1dc64330 | ||
|
|
ef6f0a9219 | ||
|
|
3ae29246c9 | ||
|
|
6eac444a22 | ||
|
|
46158c42cc
|
||
|
|
03e7415aa2 | ||
|
|
b89d406419
|
||
|
|
b699204d87 | ||
|
|
51c2db74f8
|
||
|
|
f34269bd38
|
||
|
|
930804d36c
|
||
|
|
2f745d578b
|
||
|
|
b50513c3b4 | ||
|
|
60b110a0b1
|
||
|
|
148f9d653b
|
||
|
|
ace6579fc0
|
||
|
|
66edf51719
|
||
|
|
3175b47165
|
||
|
|
fabbad15a7
|
||
|
|
baf7e0a554
|
||
|
|
85da466255
|
||
|
|
a7acea381a
|
||
|
|
4d2b9f685c | ||
|
|
36382059bc | ||
|
|
51e00b4195
|
||
|
|
84c95e524a | ||
|
|
06d5aacd91 | ||
|
|
0449ff315a
|
||
|
|
577bd3dee8
|
||
|
|
4586f5b4f2 | ||
|
|
effee583b7
|
||
|
|
1f1c5f546a
|
||
|
|
a42887f6e5
|
||
|
|
a163dc53b1
|
||
|
|
77eef1c816
|
||
|
|
9a905890a8
|
||
|
|
fe0077e86c
|
||
|
|
61be012fc2
|
||
|
|
bc01d10a27 | ||
|
|
d3daa4d067
|
||
|
|
49788dd4e8
|
||
|
|
1412af5f69 | ||
|
|
9150fb732f
|
||
|
|
e540b6fcbd
|
||
|
|
7f03ba1f4c
|
||
|
|
4e17d720f4
|
||
|
|
984c26b62b
|
||
|
|
d1eae03894
|
||
|
|
310c23f9cd
|
||
|
|
de387a61f7
|
||
|
|
e333223470 | ||
|
|
c064048c6f
|
||
|
|
2b4d82309c
|
||
|
|
4fc3dfd7aa
|
||
|
|
8c9619e7f7
|
||
|
|
d3298cca2b
|
||
|
|
edaa1ffb33
|
||
|
|
c564a29ce8
|
||
|
|
3ab0ed88fa
|
||
|
|
12ef8571f1
|
||
|
|
a4c8eaf8c9
|
||
|
|
b0b0d469fe | ||
|
|
3ceed91214
|
||
|
|
4e12a5d524
|
No files matched your search
@@ -1,104 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6
|
||||
|
||||
# ─── Jobs ────────────────────────────────────────────────────────────────────
|
||||
# The dashboard jar (static export under next-dashboard/) is published to Maven
|
||||
# Central on release tags, with the same orb commands and credentials as the
|
||||
# Material Dashboard. QApplicationJavalinServer serves it at / when it is present.
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job; avoid the interactive needrestart prompt.
|
||||
sudo sed -i 's|http://security.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
jobs:
|
||||
publish_dashboard_jar:
|
||||
executor: qqq-orb/java
|
||||
steps:
|
||||
- configure-package-install
|
||||
- checkout
|
||||
- qqq-orb/install_java:
|
||||
install_node: true
|
||||
node_version: "22"
|
||||
- run:
|
||||
name: 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: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build:export
|
||||
- qqq-orb/mvn_jar_deploy
|
||||
|
||||
# ─── Workflows ───────────────────────────────────────────────────────────────
|
||||
# Branch strategy mirrors the QRun-IO orb convention:
|
||||
# test_only → all feature / non-protected branches
|
||||
# publish_snapshot → develop
|
||||
# publish_release_candidate → release/*
|
||||
# publish_release → main / version tags
|
||||
# publish_hotfix_release → hotfix/*
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/node_app_test_only:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ develop ]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ /release\/.*/ ]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ main ]
|
||||
tags:
|
||||
only: [ /v.*/ ]
|
||||
- publish_dashboard_jar:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: /^v[0-9]+\.[0-9]+\.[0-9]+(-RC\.[0-9]+)?$/
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ /hotfix\/.*/ ]
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: /.*/
|
||||
@@ -0,0 +1,5 @@
|
||||
# Default reviewers for every change.
|
||||
* @KofTwentyTwo
|
||||
|
||||
# The UI API contract (QRun-IO/qqq-frontend-next#107): the vendored documents and the off-spec list
|
||||
/src/lib/api/openapi/ @KofTwentyTwo
|
||||
@@ -0,0 +1,32 @@
|
||||
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
|
||||
# major updates stay separate so each gets its own review. At most three version-update PRs per
|
||||
# ecosystem are open at once, so their real-backend acceptance runs do not starve release PRs of runners. Security updates are enabled in the
|
||||
# repository settings and are not limited by this schedule.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm # pnpm-lock.yaml
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
npm-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ['*']
|
||||
|
||||
- package-ecosystem: maven # pom.xml (the dashboard jar)
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
maven-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
@@ -1,86 +0,0 @@
|
||||
name: Real-backend acceptance
|
||||
|
||||
# Full QRun-IO/qqq#649 acceptance: the QRun-owned sample application built from QQQ source,
|
||||
# serving this repository's production static export, driven by Playwright in each browser.
|
||||
# The matrix gate fails on any required row without a first-attempt pass.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
qqq_ref:
|
||||
description: QRun-IO/qqq branch or tag to build the sample backend from
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: acceptance-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# The released QQQ version this dashboard is accepted against; override per run.
|
||||
QQQ_REF: ${{ inputs.qqq_ref || vars.QQQ_ACCEPTANCE_REF || 'develop' }}
|
||||
NEXT_TELEMETRY_DISABLED: '1'
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 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:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: QRun-IO/qqq
|
||||
ref: ${{ env.QQQ_REF }}
|
||||
path: qqq-backend
|
||||
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
cache: maven
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
- name: Build QQQ from source
|
||||
run: mvn -B -q -f qqq-backend/pom.xml install -DskipTests -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true
|
||||
- name: Build and install the dashboard jar (static export)
|
||||
run: |
|
||||
pnpm build:jar
|
||||
mvn -B -q install
|
||||
echo "NEXT_JAR_VERSION=$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)" >> "$GITHUB_ENV"
|
||||
- name: Build the sample application jar
|
||||
run: |
|
||||
# Run from the QQQ checkout: its checkstyle/pmd configuration paths are relative to it.
|
||||
(cd qqq-backend && mvn -B -q -f qqq-sample-project/pom.xml package -DskipTests -Djacoco.skip=true \
|
||||
-Dqqq.frontend.next.version="$NEXT_JAR_VERSION")
|
||||
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/qqq-backend/qqq-sample-project/target/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
|
||||
- name: Acceptance (${{ matrix.browser }})
|
||||
env:
|
||||
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
|
||||
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 == 'mobile,tablet' && 'touch' || matrix.browser }}
|
||||
path: test-results/acceptance/
|
||||
retention-days: 30
|
||||
@@ -1,108 +1,41 @@
|
||||
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:
|
||||
name: Next centralized CI
|
||||
'on':
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
qqq_ref:
|
||||
description: QQQ source ref for acceptance
|
||||
type: string
|
||||
required: false
|
||||
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
|
||||
ci:
|
||||
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
|
||||
github.event.action)) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
acceptance:
|
||||
if: github.event_name != 'pull_request' || !contains(fromJSON('["labeled","unlabeled"]'),
|
||||
github.event.action) || github.event.label.name == 'full-acceptance'
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
with:
|
||||
qqq_ref: ${{ inputs.qqq_ref || '' }}
|
||||
dashboard-image:
|
||||
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
|
||||
github.event.action)) || (github.event_name == 'push' && startsWith(github.ref,
|
||||
'refs/tags/v'))
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
@@ -1,75 +0,0 @@
|
||||
name: Dashboard image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/qrun-io/qqq-frontend-next
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
if: github.event_name == 'push'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/quickstart/Dockerfile
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
load: ${{ github.event_name == 'pull_request' }}
|
||||
tags: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=dashboard-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=dashboard-${{ matrix.arch }}
|
||||
- name: Verify runnable dashboard
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
TAG: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
|
||||
run: |
|
||||
container=$(docker run -d --rm -p 127.0.0.1:3000:3000 "$TAG")
|
||||
trap 'docker rm -f "$container"' EXIT
|
||||
curl -fsS --max-time 3 --retry 30 --retry-delay 1 --retry-all-errors \
|
||||
--retry-max-time 90 http://127.0.0.1:3000/app/person >/dev/null \
|
||||
|| { docker logs "$container"; exit 1; }
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push'
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish versioned multi-architecture image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag "$IMAGE:${GITHUB_REF_NAME#v}" \
|
||||
"$IMAGE:$GITHUB_SHA-amd64" "$IMAGE:$GITHUB_SHA-arm64"
|
||||
@@ -0,0 +1,3 @@
|
||||
profile: frontend-next
|
||||
orb_version: '0.8'
|
||||
default_branch: main
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
||||
// polyfills (crypto-browserify -> elliptic, GHSA-848j-6mx2-7j84) that no story needs.
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
staticDirs: ['../public'],
|
||||
addons: [
|
||||
'@storybook/addon-docs',
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@ QQQ is a metadata-driven, low-code application framework. The admin UI renders e
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 15 (App Router) with React 19
|
||||
- **Framework:** Next.js 16 (App Router) with React 19
|
||||
- **Language:** TypeScript 5.x (`strict: true` — no `any` in component props)
|
||||
- **Styling:** Tailwind CSS 4.x with CSS custom properties for QQQ theme tokens
|
||||
- **Components:** shadcn/ui (Radix primitives + Tailwind)
|
||||
@@ -143,6 +143,13 @@ pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
```
|
||||
|
||||
## Branching and Releases
|
||||
|
||||
Trunk-based: `main` is the only long-lived branch (no `develop`, `release/*` or `hotfix/*`).
|
||||
Branch from `main`, keep the branch current by merging `main` in (no rebase or force-push under
|
||||
review), sign every commit, and open the PR into `main`. Releases are signed `v*` tags on `main`.
|
||||
Required checks and the release flow are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
Full implementation plans are in `docs/implementation-plans/`. The project is built in 6 work packages:
|
||||
@@ -182,3 +189,13 @@ the second-brain vault (`R:/Git.Local/KofTwentyTwo/second-brain`):
|
||||
Consult the dossier before deep-diving the code — it covers the API contract this
|
||||
frontend targets (including versioned-middleware endpoint gaps), maturity assessment,
|
||||
known parity gaps, and its relationship to `qqq-frontend-material-dashboard`.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,51 @@
|
||||
# Contributing
|
||||
|
||||
## Branching: trunk-based on `main`
|
||||
|
||||
`main` is the only long-lived branch. There is no `develop`, `release/*` or `hotfix/*` branch.
|
||||
|
||||
1. Branch from the current `main` (`feature/...`, `fix/...`, `docs/...`, `chore/...`).
|
||||
2. Keep the branch current by merging `main` into it; do not rebase or force-push a branch
|
||||
that is under review.
|
||||
3. Open a pull request into `main`. Every commit must be signed.
|
||||
4. A pull request merges when the required checks pass and the code owner approves.
|
||||
Head branches are deleted automatically after the merge.
|
||||
|
||||
### Required checks
|
||||
|
||||
| Check | Source |
|
||||
|-------|--------|
|
||||
| `Typecheck, lint, unit tests and coverage` | `.github/workflows/ci.yml` |
|
||||
| `Mocked e2e (Playwright, chromium)` | `.github/workflows/ci.yml` |
|
||||
| `Bundle budget (static export)` | `.github/workflows/ci.yml` |
|
||||
| `Storybook build` | `.github/workflows/ci.yml` |
|
||||
| `ci/circleci: qqq-orb/node_app_test_only` | `.circleci/config.yml` |
|
||||
|
||||
The real-backend acceptance matrix (`.github/workflows/acceptance.yml`) runs on every pull request
|
||||
and on `main`. Treat a red row as a release blocker and investigate it; never re-run it until it passes.
|
||||
|
||||
## Releases
|
||||
|
||||
A release is a signed `v<version>` tag on `main` (for example `v1.0.0-RC.14`), where `package.json`
|
||||
and `pom.xml` carry that version. The tag publishes the dashboard jar to Maven Central (CircleCI
|
||||
`publish_dashboard_jar`) and the dashboard container image (`.github/workflows/dashboard-image.yml`).
|
||||
Release notes live in `docs/releases/`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Dependabot opens grouped weekly updates for npm, GitHub Actions and Maven (`.github/dependabot.yml`),
|
||||
plus security updates as they are published.
|
||||
|
||||
## UI API calls (merge order)
|
||||
Next calls the backend only through operations described in the vendored UI API documents:
|
||||
`src/lib/api/openapi/qqq-v1.json`, plus one JSON file per extension in `extensions/`.
|
||||
`ui-api-contract.test.ts` fails on any other call.
|
||||
|
||||
A feature that needs new backend calls lands in this order:
|
||||
1. The qbit or module ships the operations as a UI API extension, with spec classes and its
|
||||
committed `openapi/qqq-v1/<name>.yaml`.
|
||||
2. That document is vendored here, as `extensions/<name>.json` or a re-vendored `qqq-v1.json`.
|
||||
3. The UI PR that calls them.
|
||||
|
||||
`OFF_SPEC_CALLS` in `ui-api-calls.ts` is a shrinking list of calls that predate the extension point.
|
||||
`OFF_SPEC_CEILING` caps it: lower the ceiling as entries move onto specs, and never raise it.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
**This checkout targets Next UI 1.0.0-RC.36**, which adds accessibility fixes: readable link text, 24px clear targets, h2 widget titles and skip-link focus to RC35. Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing; see the [RC36 notes](docs/releases/1.0.0-RC.36.md) for the preparation checkpoint and verification scope. [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) 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).
|
||||
|
||||
@@ -14,16 +14,76 @@ 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>
|
||||
<version>1.0.0-RC.36</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 explicit version overrides an older BOM-managed frontend. RC36 requires a published QQQ 4.1 candidate: 4.1.0-RC.1 or later; 4.1.0-RC.2 or later for standalone branding images (QQQ #1009); and **4.1.0-RC.3 or later** for FILE_PREVIEW thumbnails (QQQ #1056) and the instance home app (QQQ #1059); **4.1.0-RC.4 or later** for record developer panels (QQQ #1076); **4.1.0-RC.5 or later** for the record picker (QQQ #1099), form validation limits (QQQ #1100) and date filter presets (QQQ #1115). The Alerts bell appears only with qbit-alerts (0.1.0-RC.1 or later); the notification center needs qbit-alerts 0.1.0-RC.2 or later. The assistant panel appears only with the qbit-assistant backend (0.1.0-RC.3 or later). The SQL Workbench needs the qbit-sql-workbench QBit (1.0.0-RC.1 or later); QA capture appears only with qbit-qa-capture (the `qa-capture` UI API extension); see the release notes for the verified backend. 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 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)).
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
How an existing QQQ backend application (for example one still on QQQ 0.x with the Material Dashboard) switches to, or upgrades, the Next dashboard. The frontend is distributed only as a Maven jar; there is no npm package (`package.json` is `private`).
|
||||
|
||||
### Coordinates and compatible versions
|
||||
|
||||
| Piece | Coordinates | Version |
|
||||
|---|---|---|
|
||||
| Next dashboard | `com.kingsrook.qqq:qqq-frontend-next` | `1.0.0-RC.13` (latest; [all versions](https://repo1.maven.org/maven2/com/kingsrook/qqq/qqq-frontend-next/)) |
|
||||
| QQQ BOM | `com.kingsrook.qqq:qqq-bom-pom` (import scope) | `4.1.0-RC.3` (manages `qqq-frontend-next` at `1.0.0-RC.11`, so pin `1.0.0-RC.13` explicitly) |
|
||||
| SQL Workbench QBit (optional) | `com.kingsrook.qbits:qbit-sql-workbench` | `1.0.0-RC.1` (built against QQQ `4.1.0-RC.2`) |
|
||||
| Container image (optional, separate Node server) | `ghcr.io/qrun-io/qqq-frontend-next` | `1.0.0-RC.13` |
|
||||
|
||||
| Next feature | Minimum QQQ | Backend work |
|
||||
|---|---|---|
|
||||
| Dashboard at `/` | 4.1.0-RC.1 | Add the jar; `QApplicationJavalinServer` (`qqq-middleware-javalin`) serves it. |
|
||||
| Standalone branding images | 4.1.0-RC.2 | Branding metadata ([qqq#1009](https://github.com/QRun-IO/qqq/issues/1009)). |
|
||||
| FILE_PREVIEW thumbnails and lightbox | 4.1.0-RC.3 | Adorn each field with `AdornmentType.FilePreviewValues.newFieldAdornment()` ([qqq#1056](https://github.com/QRun-IO/qqq/issues/1056)). The field value, or `thumbnailUrlField` / `fullSizeUrlField`, must be an http(s) URL or a same-origin path; a BLOB field needs a `FILE_DOWNLOAD` adornment as well. Plain URL fields are never auto-detected. |
|
||||
| Instance home app | 4.1.0-RC.3 | `qInstance.setHomeAppName("<app>")` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)). Users who may not open that app get Next's `/app` overview. |
|
||||
| SQL Workbench | 4.1.0-RC.2 + QBit | Produce the QBit (`new SqlWorkbenchQBitProducer().withQBitConfig(...).produce(qInstance)`); it adds the `sqlWorkbench` app with the `SQL_WORKBENCH` widget, five tables and `/qqq/v1/sql-workbench/*` routes. Grant `sqlWorkbench.hasAccess` (and `sqlWorkbench.write` only for write mode) to super-users; configure `readOnlyCredentials` in production. See the [QBit README](https://github.com/QRun-IO/qbit-sql-workbench#readme). |
|
||||
| Responsive layouts, readable child lists, compact dates | 4.1.0-RC.1 | None; frontend only. Child-record widgets honour `onlyIncludeFieldNames` order. |
|
||||
|
||||
### Switching from the Material Dashboard
|
||||
|
||||
1. Move the application to QQQ 4.1 (`qqq-bom-pom` `4.1.0-RC.3`). This is a major upgrade from QQQ 0.x; follow the QQQ 4.1 upgrade notes for the backend itself.
|
||||
2. Add the `qqq-frontend-next` dependency above. When the jar is on the classpath, QQQ serves Next at `/` unless the Material Dashboard is requested explicitly.
|
||||
3. Choose the dashboard in code with `withServeFrontendNext(true)` / `withServeFrontendMaterialDashboard(true)`, or, when neither is set in code, with `-Dqqq.javalin.frontend=next|material|none`. An app that sets `withServeFrontendMaterialDashboard(false)` and serves Material through its own route provider gets Next at `/` as soon as the jar is added; remove that custom route, or set `withServeFrontendNext(false)` to keep Material.
|
||||
4. Authentication: keep the existing identity-provider client. OAUTH2 redirects to `{origin}/token` and AUTH_0 to `{origin}/`, as Material did; both use PKCE.
|
||||
5. URLs change from `/<app>/<table>/<id>` (Material) to `/app/<table>/<id>` (Next). Map old bookmarks and links with `QInstance.redirects` (exact or wildcard paths).
|
||||
6. Optional: set `homeAppName`, add `FILE_PREVIEW` adornments, add the SQL Workbench QBit (table above). No database migrations are needed for the dashboard itself; the SQL Workbench QBit needs its five tables (DDL in its README).
|
||||
|
||||
For a full per-feature checklist (backend upgrade, QBits, migrations, permissions) see the QQQ adoption checklist: https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc
|
||||
|
||||
To upgrade between RCs, change only the `qqq-frontend-next` version and check the minimum QQQ version in the table above and in the [release notes](docs/releases/).
|
||||
|
||||
### Configuration
|
||||
|
||||
Served from the jar there is nothing to configure in the frontend: it calls the API on the same origin at `/qqq/v1`, and the Javalin server sends the security headers and Content-Security-Policy. External embeds (for example QuickSight frames) need an allowed CSP source, not a disabled policy. The build-time variables under [Environment Variables](#environment-variables) apply only to frontend development and the standalone container image.
|
||||
|
||||
### App home navigation grids
|
||||
|
||||
App homes show automatic section ("Actions", "Reports", "Data") and child-app ("Apps")
|
||||
card grids. When the app declares one or more widgets, the "Apps" grid is left out (even if
|
||||
a declared widget is unavailable to the current user), so widget-based navigation is not
|
||||
duplicated; the section grids stay.
|
||||
|
||||
Set `supplementalAppMetaData.nextUi.showChildGrid` on the app to override this default:
|
||||
`true` shows both grids alongside widgets; `false` hides both grids, for a home whose
|
||||
widgets already cover its sections and child apps.
|
||||
Only boolean values override the default. Existing visibility and permission rules still apply.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Release candidates only; final 1.0 is held for application soak and owner approval ([#712](https://github.com/QRun-IO/qqq/issues/712), tracker [#713](https://github.com/QRun-IO/qqq/issues/713)).
|
||||
- Served at `/` only: sub-path hosting is not supported ([#732](https://github.com/QRun-IO/qqq/issues/732)).
|
||||
- Material parity is not complete ([#714](https://github.com/QRun-IO/qqq/issues/714)); check the parity ledger for the screens your app relies on.
|
||||
- Real Auth0, Google Drive and QuickSight integrations are not yet verified end to end ([#713](https://github.com/QRun-IO/qqq/issues/713)).
|
||||
- The container image fixes the backend origin at build time (`host.docker.internal:8000`); use the jar, or rebuild the image, for another origin.
|
||||
- WebKit and tablet acceptance runs show intermittent navigation diagnostics ([#985](https://github.com/QRun-IO/qqq/issues/985), [#904](https://github.com/QRun-IO/qqq/issues/904)).
|
||||
|
||||
## Run the local QQQ sample
|
||||
|
||||
Use the [QQQ quickstart](https://github.com/QRun-IO/qqq/blob/main/qqq-sample-project/README.md#quickstart-with-next): it clones editable Java sample source, compiles it against published QQQ, and serves this dashboard from the sample on port 8000. It needs JDK 21+, Git, curl and unzip; no Node.js, Maven or Docker installation.
|
||||
@@ -55,7 +115,7 @@ The dev server starts at [http://localhost:3000](http://localhost:3000).
|
||||
| `QQQ_NEXT_OUTPUT` | _(unset)_ | `export` produces the static export for QQQ hosting (`pnpm build:export`) |
|
||||
| `NEXT_PUBLIC_MOCK_API` | _(unset)_ | Set to `true` to activate MSW browser mocks for dev/demo without a live backend |
|
||||
|
||||
Copy `.env.example` to `.env.local` and adjust as needed.
|
||||
Copy `.env.mock` to `.env.local` to run against MSW mocks, or set `NEXT_PUBLIC_MOCK_API=false` and point `NEXT_PUBLIC_API_BASE_URL` at a real backend.
|
||||
|
||||
## Available Commands
|
||||
|
||||
@@ -130,3 +190,35 @@ src/
|
||||
- **Charts:** Recharts
|
||||
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
|
||||
- **Package Manager:** pnpm
|
||||
|
||||
## Default query columns
|
||||
|
||||
A new query view shows at most six fields: the visible primary key first, then
|
||||
eligible fields from T1 sections followed by T2 sections, preserving section and
|
||||
field order within each tier. Tables without sections use metadata field order.
|
||||
Hidden fields, heavy fields, TEXT/HTML/BLOB/JSON-like types, and CODE_EDITOR or
|
||||
RENDER_HTML adornments are excluded from the automatic selection (the primary key
|
||||
is retained unless hidden). Fields outside T1/T2 are initially unchecked.
|
||||
Other available columns remain selectable in the column picker.
|
||||
|
||||
Applications can override this selection with a string-array hint:
|
||||
|
||||
```json
|
||||
{
|
||||
"supplementalMetaData": {
|
||||
"nextUi": {
|
||||
"defaultColumns": ["id", "title", "status"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The legacy `supplementalTableMetaData.nextUi.defaultColumns` path is also accepted;
|
||||
the modern `supplementalMetaData.nextUi` object takes precedence. A valid hint
|
||||
selects exactly the named available columns, without the six-field cap or automatic
|
||||
primary-key inclusion. Unknown or unavailable fields are ignored; an empty array
|
||||
selects no columns, and malformed hints fall back to the automatic selection.
|
||||
Saved views and legacy stored visibility (including `{}`, meaning all base columns)
|
||||
are preserved. New View and Reset All Changes on an unsaved view restore these
|
||||
metadata defaults. Quick search searches only visible string fields. Responsive
|
||||
card/grid behavior is unchanged.
|
||||
@@ -26,7 +26,9 @@ The ID may be on a layout-neutral wrapper around a native button or menu item. S
|
||||
- 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`.
|
||||
- File previews: a `FILE_PREVIEW` value keeps its cell or value ID (`grid-cell-{field}`, `field-value-{field}`) on the thumbnail button with `data-preview-kind`; the box is `{id}-thumbnail`. The dialog uses `file-preview-dialog`, `file-preview-image`/`-pdf`/`-video`/`-audio`/`-unavailable`, `file-preview-details`, and `file-preview-{previous,next,zoom-in,zoom-out,zoom-fit,open,download,close}`.
|
||||
- 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`.
|
||||
- Jobs: `button-jobs`, `jobs-active-badge`, `jobs-popover`, `link-job-{jobId}`, `link-all-jobs`, `jobs-page`, `tab-jobs-{active,recent,all}`, `job-progress-view`, `job-status`, `job-phases`, `job-progress-bar`, `job-progress-stats`, `job-progress-message`, `job-log-panel`, `job-log-scroll`, `button-job-log-{toggle,copy,latest}`, `job-background-countdown`, `button-job-{stay,background,cancel,retry,done}`, and `link-job-record`. Jobs motion is confined to `.qqq-job-{fill,shimmer,pulse,ring}` and is static under reduced motion.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Help everywhere — implementation and verification
|
||||
|
||||
Backend: QRun-IO/qqq#1177 (`docs/HELP-CONTENT.md`).
|
||||
|
||||
## Implementation
|
||||
|
||||
- Vendored the supplied help-core `openapi.yaml` as the existing one-space-indented JSON document. The prior vendoring commits are `01dd7e9` and `8a0fbbd`; neither contains a generator script. Conversion uses the installed ESLint dependency `js-yaml`, then `JSON.stringify(document, null, 1)` with a final newline. The full core document is retained, including its other changes within UI API 1.2.0.
|
||||
- Added `summary` to `QHelpContent`, reused that type for widget slots, and added app/process/report slot maps. `src/lib/api/help.ts` uses the typed API client and checks the advertised `help` feature before calling search. No off-spec exception was added. Search and coverage are optional spec operations; coverage is not called.
|
||||
- `src/components/help/HelpIcon.tsx` selects role-specific help, shows the plain summary, and opens the shared long-help panel by click, Enter, or Space. Existing customization IDs are retained on the corresponding control or tooltip. Input descriptions remain available while the tooltip is closed.
|
||||
- `HelpPanelProvider` is mounted in the authenticated dashboard. One `next/dynamic` panel renders sanitized long content using the existing `HelpContent` renderer, with related help, safe navigation, Escape dismissal, focus trapping, and return focus. Help controls use theme tokens and coarse-pointer touch targets.
|
||||
- Existing field, section, grid-column, widget-title, widget-column, and process-step help now uses the shared icon. New placements cover app, table query/view/edit/create/copy, process, report, and record/table action headers or labels. Action help is a sibling of the action control; Radix menu help participates in roving focus.
|
||||
- `HelpSearchResults` provides 200 ms debouncing, a two-character minimum, feature gating, and loading/error/empty states. Both `CommandMenu` and the global `SearchPage` use it. The page's existing grouped-results area is a natural place for help; the compact header/search-dialog listbox retains its current record/navigation model. The palette also offers **Search help…**.
|
||||
- Author mode retains key suffixes. **Add help** / **Edit help** links require the core table's insert permission and use `/app/helpContent/create#/defaultValues=…` with `key` and the most specific `role`. `HELP_CONTENT_TABLE` is the one production constant for that core-defined table.
|
||||
|
||||
## Tests and fixtures
|
||||
|
||||
- New Vitest tests cover the API gate, role precedence, summaries, keyboard controls, author URL presets and permission checks, panel sanitization/focus, and palette help search.
|
||||
- `tests/acceptance/fixture/HelpFixtures.java` defines synthetic Help Lab metadata, Markdown bodies and distinct summaries, an owned data table, and the core authoring table. It is wired into the normal define/reset lifecycle.
|
||||
- `tests/acceptance/specs/help/help.spec.ts` and `matrix/help.json` add HLP-001 through HLP-006, light/dark cases, mobile tags, and 320/1920-pixel panel checks. Column/table-menu coverage remains desktop-only because mobile query results use cards. The area is registered in `acceptance-select.mjs`.
|
||||
- Existing REC-039/040, PRC-015, and widget-column assertions now focus the help button and inspect formatted long content in the panel. This is necessary because tooltips now contain plain summaries and touch opens the panel. Existing scenarios and matrix requirements remain; none was waived.
|
||||
- Historical results in `docs/acceptance/feature-matrix.md` are preserved. The new help rows are appended as **not run**.
|
||||
@@ -0,0 +1,106 @@
|
||||
# QA capture (Jam)
|
||||
|
||||
Optional QA bug capture for the Next UI. The backend is the `qbit-qa-capture` QBit, which publishes the `qa-capture` UI API extension (1.0.0). [Jam](https://jam.dev) is the first provider. The UI calls only the two operations in [`src/lib/api/openapi/extensions/qa-capture.json`](../src/lib/api/openapi/extensions/qa-capture.json):
|
||||
|
||||
| Operation | Call | Used for |
|
||||
|---|---|---|
|
||||
| `qaCaptureCapability` | `GET /qqq/v1/qbit/qa-capture/capability` | Whether capture is on for the signed-in user, and the Jam team, sanitizer rules, blur selectors and backend context |
|
||||
| `qaCaptureReportCreate` | `POST /qqq/v1/qbit/qa-capture/report` | "Report a bug": creates a report and returns the Recording Link `jam-*` parameters |
|
||||
|
||||
The vendored document is hand-written from the shared contract until the QBit publishes its generated `openapi/qqq-v1/qa-capture.yaml`. Re-vendor it from the QBit before release.
|
||||
|
||||
## When anything loads
|
||||
|
||||
Capture is off unless all of the following hold. When it is off, the UI loads no Jam chunk, adds no Jam meta tags and makes no Jam request:
|
||||
|
||||
1. `GET /qqq/v1/version` lists the `qa-capture` extension. Otherwise the capability is never requested.
|
||||
2. The user is signed in. `QaCaptureLoader` in the root providers then requests the capability once (TanStack Query, `['qqq', 'qa-capture', 'capability']`).
|
||||
3. The capability has `enabled: true`, provider `jam`, a team id and sanitizer rules. Anything else counts as disabled.
|
||||
|
||||
When capture is enabled, `src/lib/qa-capture/jam-loader.ts` (the only module that imports Jam, and only through `import()`) does the following:
|
||||
|
||||
- adds `<meta name="jam:team">` and `<meta name="jam:blur">` (the server's `blurSelectors` plus `[data-qqq-secure]`);
|
||||
- dynamic-imports `@jam.dev/recording-links/sdk` (0.4.0) and `@jam.dev/sdk` (1.0.1);
|
||||
- calls `jam.initialize({ teamId, blurSelectors, network, console })` once, with the sanitizers;
|
||||
- registers `jam.metadata(...)`.
|
||||
|
||||
A Vitest check fails if any other file imports Jam at runtime.
|
||||
|
||||
What the SDKs fetch was checked in their published source. `initialize` loads `https://js.jam.dev/recorder.js` only in two cases: the page URL has `jam-recording` (checked on load and on every `pushState`, `replaceState` or `popstate`), or `loadRecorder()` is called. It loads `https://js.jam.dev/capture.js` only while some tab has an active recorder, which it tracks with the `jam:numRecorders` localStorage counter. `jam.metadata` only stores a callback on `window.__jam__`.
|
||||
|
||||
The browser cannot undo the Jam SDK, so turning capture off for a user (QA mode off) takes effect on the next page load. Request-id collection stops immediately.
|
||||
|
||||
## QA mode
|
||||
|
||||
When `capability.qaMode.preferenceSection` is set, the Preferences page renders that section like any other. Saving it refetches the capability, so turning QA mode on loads Jam without a reload. While `reason` is `QA_MODE`, the header shows a "QA mode on" badge that links to the section.
|
||||
|
||||
## Report a bug
|
||||
|
||||
The user menu shows **Report a bug** when `canReport` is true, and **QA reports** when `reportsHref` is an in-app path. The UI follows that link as given and never builds table paths itself. The Next UI has no help menu (the header help button opens the keyboard shortcuts), so both entries are in the user menu only.
|
||||
|
||||
Report a bug sends `POST /report` with:
|
||||
|
||||
- the page URL, with the sanitizer `queryParamNames`, the OAuth `code`, `state` and token parameters, and every `jam-*` parameter removed;
|
||||
- the title `Bug: <page title>`;
|
||||
- the app, table, record id or process, resolved from `/app/<slug>[/<id>[/<process>]]` against the instance metadata;
|
||||
- the last request ids;
|
||||
- the frontend version, user agent and viewport.
|
||||
|
||||
When the response has `recordingParams`, the UI opens the recorder with `await jam.loadRecorder({ openImmediately: false })` and then `Recorder.open(params['jam-recording'], { jamParams: { 'jam-title', 'jam-reference', ... } })`. The SDK puts those parameters on the URL while recording and removes them when the recorder closes, which lets the QBit's `jam.created` webhook match the capture to the report by `jam-reference`.
|
||||
|
||||
Without `recordingParams`, a toast explains that an administrator must set the Recording Link in the QA capture settings. It includes a QA reports action when `reportsHref` is present.
|
||||
|
||||
## Sanitizers
|
||||
|
||||
`src/lib/qa-capture/sanitizers.ts` builds the four Jam sanitizers from `capability.sanitizer`. They are pure, synchronous and deterministic, and they never log or fetch. The server's rules always get a baseline added: the `authorization`, `proxy-authorization`, `cookie` and `set-cookie` headers; the `password`, `sessionUUID`, `accessToken`, `refreshToken`, `idToken`, `clientSecret` and `code_verifier` fields; the `code`, `state`, `access_token` and `id_token` parameters; and JWTs.
|
||||
|
||||
| Data | Rule |
|
||||
|---|---|
|
||||
| Request and response headers | `headerNames` (lowercase) are deleted |
|
||||
| JSON bodies | values of `bodyFieldNames` keys are replaced at any depth (case-insensitive); `valuePatterns` are applied to every string; the body stays valid JSON |
|
||||
| Form-encoded bodies | the same, field by field |
|
||||
| Other text bodies, WebSocket data | `valuePatterns` |
|
||||
| Request URLs | `queryParamNames` values are redacted; `valuePatterns` are applied |
|
||||
| Same-origin paths under `dropPathPrefixes` | request and response bodies are dropped |
|
||||
| Console arguments | object keys and string patterns as for JSON |
|
||||
|
||||
A `valuePatterns` entry that is not a valid RegExp makes the affected sanitizers throw. Jam then redacts that data instead of sending it (fail closed).
|
||||
|
||||
## Secure fields
|
||||
|
||||
The UI knows a value is secure from its field metadata:
|
||||
|
||||
- the field type is `PASSWORD`;
|
||||
- the field has the `REVEAL` adornment;
|
||||
- secured data restricts the field (`accessTier` is `MASKED` or `REVEALABLE`).
|
||||
|
||||
Elements that show or edit such a value carry `data-jam-blur` and `data-qqq-secure`. This covers `MaskedValue`, a revealed restricted value, grid password and reveal cells, record view reveal and password values, password inputs (including while shown as text) and secure text inputs. The attributes are always present and have no effect when capture is off.
|
||||
|
||||
## Request ids
|
||||
|
||||
While capture is enabled, an axios interceptor keeps the last 50 `{ id, method, path, status, at }` entries from the response header named by `capability.requestIdHeader` (default `X-Request-Id`). The path is stored without its query string. They go into the Jam metadata and the report. Disabling capture clears the buffer.
|
||||
|
||||
## Jam metadata
|
||||
|
||||
At capture time Jam receives the following, kept under 10 KB by dropping the oldest request ids first:
|
||||
|
||||
- the app, table, record id and process;
|
||||
- the frontend version (`NEXT_PUBLIC_APP_VERSION`);
|
||||
- the backend `environment`, `backendVersion` and `node` from the capability;
|
||||
- the request ids;
|
||||
- the report reference;
|
||||
- the page path, filtered as above.
|
||||
|
||||
## Recording Links and sign-in
|
||||
|
||||
A Recording Link can open a protected page as `?jam-recording=...&jam-title=...`. The login redirect, the OIDC/OAuth2 callback and the 401 redirect already carry the query in `returnTo`. Every page load also stashes the `jam-*` parameters in sessionStorage. After sign-in they are put back on the URL with `history.replaceState`, unless the page already has a `jam-recording`. The stash expires after 30 minutes. The Jam SDK watches `replaceState`, so the recorder then opens.
|
||||
|
||||
The public `/qa/recorder` page for Jam domain verification is served by the QBit, not by Next. The standalone server forwards `/qa/*` to the backend.
|
||||
|
||||
## Content-Security-Policy
|
||||
|
||||
Next adds no CSP of its own. In a jar deployment the dashboard CSP comes from the QQQ server (`NextDashboardSecurityHeaders`), and the QBit or application must add the Jam origins listed in `capability.cspOrigins`. The standalone container server (`standalone/qqq-server.mjs`) adds them only when `QQQ_QA_CAPTURE_CSP=true`:
|
||||
|
||||
- `script-src https://js.jam.dev`
|
||||
- `frame-src https://*.jam.dev`
|
||||
- `connect-src https://*.jam.dev`
|
||||
@@ -0,0 +1,17 @@
|
||||
# UI API 1.6 adoption
|
||||
|
||||
Adopts QQQ UI API 1.6.0 (QQQ 4.1.0-RC.15): table clientCapabilities, field validation, BasicErrorResponseV1, /qqq/v1/branding.
|
||||
|
||||
- Vendors the supplied core, api-aware, ESB, alerts, collaboration, data-analysis and EDI documents; regenerates EDI types. Assistant, quick-search, secured-data and sql-workbench documents are unchanged.
|
||||
- Saved views, audits, sharing and bulk actions use per-user table capabilities. Legacy discovery is used only when `clientCapabilities` is absent; a missing or unavailable feature in a present capability object does not enable legacy access.
|
||||
- Forms use `field.validation` for required values, length policies, inclusive/exclusive bounds and whole-value patterns. Existing record-write error mapping remains intact.
|
||||
- Errors prefer `message` over legacy error fields. Global error toasts and process error details expose the trace identifier. Session-expiration handling remains in place.
|
||||
- Standalone branding aliases call `/qqq/v1/branding/{role}` and retry the old public route only after a 404. Instance consumers prefer `supplementalInstanceMetaData.uiSettings`, with deprecated settings as a fallback.
|
||||
|
||||
The app version is unchanged. No commit, push, PR or deployment was made.
|
||||
|
||||
## Verification
|
||||
|
||||
The supplied server specification comparison passes at 1.6.0. `OFF_SPEC_CALLS` remains empty and `OFF_SPEC_CEILING` remains 0.
|
||||
|
||||
Vitest startup is blocked by this session's Windows sandbox (`spawn EPERM` launching esbuild), so no Vitest pass count is claimed. The production bundle compiles, but `pnpm build` stops at its TypeScript child-process launch with `spawn EPERM`; no completed build or bundle-budget delta is available. Standalone `pnpm typecheck` passes. `pnpm lint` passes with six pre-existing warnings. Twelve direct Node smoke checks pass: seven lossless JSON vendoring comparisons and five branding status/fallback cases. The focused contract/extension Vitest invocation and full `pnpm test` both stop before running tests.
|
||||
@@ -2,6 +2,42 @@
|
||||
|
||||
> **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.
|
||||
|
||||
## CI layout
|
||||
|
||||
`plan` and `build` run concurrently. The build produces one static export, dashboard
|
||||
JAR and sample backend; the sample and middleware API JARs plus `out/` are shared
|
||||
with all shards. Each shard has its own backend, one worker and zero retries.
|
||||
The dependency graph is `plan + build → shards → acceptance gate`, with an
|
||||
independent, non-blocking `quarantine` job after `plan + build` in full mode.
|
||||
|
||||
| Trigger | Chromium | Firefox | WebKit | Mobile | Tablet | Selection |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| PR | 2 | — | — | 2 | — | Changed areas plus landing/navigation/CRUD/query/dashboard smoke |
|
||||
| main, `v*` tag, workflow dispatch | 3 | 3 | 4 | 4 | 4 | Entire suite, respecting touch tags |
|
||||
| PR labeled `full-acceptance` | 3 | 3 | 4 | 4 | 4 | Same as full |
|
||||
|
||||
Counts are independent Playwright shards. Mobile and tablet are separate projects
|
||||
and jobs. `scripts/acceptance-select.mjs` owns path selection: docs-only runs smoke;
|
||||
shared infrastructure changes select all areas on the two PR projects. Label changes
|
||||
trigger replanning. No change narrows full runs on main or tags except the explicit
|
||||
quarantine below.
|
||||
|
||||
Require **`acceptance gate`** in branch protection. It always runs, merges blobs
|
||||
into JSON and HTML, checks the matrix (`--partial` only for PR mode), and requires
|
||||
all planned shards to succeed. Its `acceptance` artifact retains the merged report,
|
||||
gate summary and failure attachments for 30 days. Full browser coverage cannot be
|
||||
claimed from a partial PR run. The under-30-minute PR target needs hosted timing
|
||||
validation; local syntax/unit checks do not measure it.
|
||||
|
||||
`WID-029` ([#93](https://github.com/QRun-IO/qqq-frontend-next/issues/93)) has
|
||||
`@quarantine` tags and an entry in `tests/acceptance/quarantine.json`. The default
|
||||
suite excludes it and reports it in `summary.quarantined`. Full runs execute it
|
||||
separately on Chromium with `QQQ_ACCEPTANCE_QUARANTINE=only` and
|
||||
`continue-on-error: true`, retaining `acceptance-quarantine` results. This is a
|
||||
visible known failure, not evidence of a passing feature. Missing issue links or
|
||||
unknown quarantine IDs fail the gate. See the [acceptance README](../../tests/acceptance/README.md)
|
||||
for local commands, artifact contents and cache/retry behavior.
|
||||
|
||||
## 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.
|
||||
@@ -11,8 +47,8 @@ The subsequent column-menu height correction passes28 targeted Chromium/WebKit c
|
||||
|
||||
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
|
||||
five configured Playwright projects below. CI runs Chromium separately from phone/tablet
|
||||
so the primary workflow gate can finish independently. Prioritize Chromium
|
||||
five configured Playwright projects below. CI shards each project separately and merges
|
||||
their evidence into the single gate described above. Prioritize Chromium
|
||||
workflow depth for RC1 and final 1.0; the complete final browser claim still requires all five projects.
|
||||
|
||||
## Verification order
|
||||
|
||||
@@ -91,7 +91,7 @@ The additional ESB integration scenarios and their focused three-browser results
|
||||
| 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-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) 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) |
|
||||
@@ -190,7 +190,7 @@ The additional ESB integration scenarios and their focused three-browser results
|
||||
| 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-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) |
|
||||
@@ -348,6 +348,7 @@ The additional ESB integration scenarios and their focused three-browser results
|
||||
| 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) |
|
||||
| SEC-050 | Optional QA capture (Jam, qbit-qa-capture qa-capture extension): Jam loads only for enabled users; Report a bug opens the recorder; secure values blurred; captures sanitized | extension absent or capability disabled: no Jam chunk, meta or request; enabled: jam:team meta, one initialize with sanitizers, jam.metadata; Report a bug: POST report then Recorder.open with jam-title and jam-reference; Recording Link jam-* parameters survive the OIDC sign-in; QA mode preference refetches the capability | no Recording Link configured: toast, no recorder; Authorization/Cookie headers, secure body fields and JWTs redacted | qbit-qa-capture (not yet released); Vitest: src/lib/qa-capture/*.test.ts, src/components/qa-capture/QaCapture.test.tsx | excluded (Pending: not yet approved by the owner. No real-backend fixture until qbit-qa-capture is released; the row is covered by Vitest until then (QA capture PR, 2026-10-15).) | |
|
||||
|
||||
## Dashboards, widgets, reports, saved and scheduled reports, and sharing
|
||||
|
||||
@@ -442,3 +443,24 @@ The additional ESB integration scenarios and their focused three-browser results
|
||||
| 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) |
|
||||
|
||||
## Contextual help, help search and authoring
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|---|---|---|---|---|---|---|
|
||||
| HLP-001 | App and widget help | summary tooltip; keyboard panel; sanitized long markdown; focus restoration; light and dark; desktop and mobile | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
|
||||
| HLP-002 | Table, field and section help | view screen; edit screen; create screen; role selection | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
|
||||
| HLP-003 | Query columns and table action help | column summary; query table header; action is not invoked by help | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
|
||||
| HLP-004 | Process, step and record action help | process header; step header; record menu keyboard control | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
|
||||
| HLP-005 | Help search | static Search help command; debounced help query; result summary and panel; Go to link | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
|
||||
| HLP-006 | Help author affordance | key and specific role URL presets; standard create form; read-only users cannot author | Strict diagnostics; no off-spec API calls | HelpFixtures.java | not run | |
|
||||
|
||||
The help rows above have not been executed locally; historical results above predate this change.
|
||||
|
||||
## Release notes UI API extension
|
||||
|
||||
`release-notes` 1.0.0 (qbit-release-notes 0.1.0-RC.1+) provides What's new at
|
||||
`/app/whats-new`: category groups, new badges, append paging, safe links/screenshots,
|
||||
release deep links, seen tracking and the user-menu unread count. Extension discovery
|
||||
hides the menu and prevents requests on unsupported servers. Covered by the release-notes
|
||||
client/page and Sidebar Vitest suites; real-backend acceptance remains pending.
|
||||
@@ -545,7 +545,7 @@ Process and report launch, the step lifecycle, process form fields, step compone
|
||||
| Load instance/process/table metadata, with load-error messages | `material:src/qqq/pages/processes/ProcessRun.tsx:1858-1897` | `src/app/(dashboard)/app/[slug]/page.tsx` ("Failed to load process metadata.") | none | Done |
|
||||
| Init, then handle JobComplete / Started / Running / Error | `material:src/qqq/pages/processes/ProcessRun.tsx:1595-1740` | `src/lib/hooks/use-process.ts:handle` | PRC-001, PRC-019 | Done |
|
||||
| Async polling: 1.5 s, ×1.5 backoff to 12 s on 5xx, then "Could not connect to server" | `material:src/qqq/pages/processes/ProcessRun.tsx:98-100, 1745-1795` | `src/lib/hooks/use-process.ts` pollRef / POLL_* | PRC-019 | Done |
|
||||
| Working screen: spinner, message, "N of M", progress bar, "Updated at" | `material:src/qqq/pages/processes/ProcessRun.tsx:631-670` | `src/components/process/ProcessRun.tsx` working block | PRC-019 | Done |
|
||||
| Working screen: spinner, message, "N of M", progress bar, "Updated at" | `material:src/qqq/pages/processes/ProcessRun.tsx:631-670` | `src/components/jobs/InlineJobProgress.tsx` (live job view: phases, message, N / M, eased bar, ETA, elapsed and live log in place of "Updated at") | PRC-019 | Done |
|
||||
| Loading shown before the first step | `material:src/qqq/pages/processes/ProcessRun.tsx:631, 2079` | `src/components/process/ProcessRun.tsx` (phase ≠ step) | PRC-040 | Done |
|
||||
| userFacingError vs internal error | `material:src/qqq/pages/processes/ProcessRun.tsx:1720-1734` | `src/lib/hooks/use-process.ts` ERROR case | PRC-021 | Done |
|
||||
| Error screen: "An error occurred…", show/hide detail toggle, Close/Cancel | `material:src/qqq/pages/processes/ProcessRun.tsx:589-629` | `src/components/process/ProcessErrorState.tsx` | PRC-021, PRC-022 | Done: Next adds Retry |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 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.
|
||||
> **This checkout targets RC36.** Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing. [RC36 notes](../releases/1.0.0-RC.36.md) record the preparation checkpoint and verification scope; RC35 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.
|
||||
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 RC13 preparation checkpoint was [1.0.0-RC.12](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) at `e333223`, 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
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 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.
|
||||
> **This checkout targets RC36.** Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing. [RC36 notes](../releases/1.0.0-RC.36.md) record the preparation checkpoint and verification scope; RC35 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.
|
||||
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 RC36 preparation checkpoint was [1.0.0-RC.35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) (commit `55c1501`); 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.
|
||||
|
||||
@@ -25,7 +25,7 @@ The source of truth for the frontend shape is [`src/types/metadata.ts`](../../sr
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
`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. `QInstance.homeAppName` (QQQ [#1059](https://github.com/QRun-IO/qqq/issues/1059)) designates the app whose dashboard is the instance's home page; it is published only to users who may open that app. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
|
||||
|
||||
## 2. Install and run
|
||||
|
||||
@@ -35,7 +35,7 @@ The preferred QQQ-hosted distribution is the `com.kingsrook.qqq:qqq-frontend-nex
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.11</version>
|
||||
<version>1.0.0-RC.36</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@@ -48,7 +48,7 @@ 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.
|
||||
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.13`, overriding an older BOM-managed frontend; confirm its availability in the [RC13 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) before installing. It requires a published QQQ 4.1 candidate (4.1.0-RC.1 or later; 4.1.0-RC.2 or later for standalone branding images, QQQ #1009; 4.1.0-RC.3 or later for FILE_PREVIEW thumbnails and the instance home app, QQQ #1056/#1059); see the release notes for the verified backend. Use a matching local backend/frontend build to test later branch changes.
|
||||
|
||||
| Setting | Behavior in this checkout |
|
||||
|---|---|
|
||||
@@ -77,7 +77,7 @@ Permissions apply to metadata and data: check table capabilities and the corresp
|
||||
|---|---|
|
||||
| `/` | 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` | Instance home page: the `homeAppName` app's dashboard when the instance designates one the user may open (client-side redirect to `/app/{homeAppName}`; the sidebar logo and breadcrumb Home crumb link there and the sidebar has no separate Dashboard entry), otherwise Next's overview. |
|
||||
| `/app/{appName}` | App home and its ordered widgets/sections. |
|
||||
| `/app/{tableName}` | Query or key lookup, according to table capability. |
|
||||
| `/app/{tableName}/create` | Create record. |
|
||||
@@ -138,7 +138,7 @@ For a fixed choice list, put `inlinePossibleValueSource` on the QQQ field with `
|
||||
|
||||
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.
|
||||
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. The record developer view (`/app/{table}/{id}/dev`) keeps the record screen's header and actions and adds a sticky section navigation over fields, API access (v1 routes and application APIs from their OpenAPI specs, filtered by the user's table permissions, with curl, fetch and Java examples that only use placeholder credentials), the panels returned by `GET /qqq/v1/table/{table}/{pk}/developer/panels` (rendered generically from their blocks: KEY_VALUES, TEXT, CODE, TABLE, LINKS and ACTIONS; app-relative or http(s) links only), relations, recent audits, associated scripts, raw JSON and a searchable table-metadata explorer. Modules and qbits add panels by registering a `DeveloperPanelProviderInterface` in QQQ; no frontend change is needed. 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.
|
||||
|
||||
@@ -312,6 +312,21 @@ These examples are payload fragments emitted by an application's backend rendere
|
||||
{"gridColumns":6,"defaultValues":{"gridCols:sizeClass:xs":12,"gridCols:sizeClass:md":6}}
|
||||
```
|
||||
|
||||
**Readable widths (Next UI).** Without per-breakpoint overrides, a widget's span follows the width of the dashboard grid itself (CSS container queries, so an open sidebar counts), not the viewport. Two `defaultValues` hints tune it, and neither needs a QQQ change:
|
||||
|
||||
- `gridMinWidth`: the narrowest the widget may be, in rem (a number, or `"28rem"` / `"448px"`). The widget shares a row only when it, and a neighbour of the same minimum, each get at least that much; otherwise it takes the full row. Values snap up to the tiers 16, 20, 24, 28, 32, 40, 48 or 64 rem; anything wider always takes the full row. Two `gridColumns: 6` widgets with `gridMinWidth: 28` pair from a 57.5rem (920 px) grid and go to thirds (`gridColumns: 4`) from 86.5rem.
|
||||
- `gridProfile`: `widgets` (charts, statistics: half from a 40rem grid), `cards` or `table` (tables: half from 66rem), or `fields` (small tiles: half from 30rem). The default is `cards` for table-type widgets and `widgets` for the rest.
|
||||
|
||||
`gridCols:sizeClass:*` wins over `gridMinWidth`, which wins over `gridProfile`.
|
||||
|
||||
```json
|
||||
{"type":"table","gridColumns":6,"defaultValues":{"gridMinWidth":28}}
|
||||
```
|
||||
|
||||
Table widgets (`table`, `multiTable`) stop growing at 96rem (1536 px) so columns stay readable on ultra-wide screens; set `defaultValues.tableMaxWidth` to another width (rem, or a CSS length in `rem`, `px`, `ch`, `em` or `%`) or to `"none"`. Where a table widget is narrower than 30rem, or than its declared column widths need (6rem per `fr` or unsized column plus the fixed widths), each row renders as a stacked card of `label: value` lines instead of a table that scrolls sideways.
|
||||
|
||||
To cap a whole dashboard page on ultra-wide screens, give the app a supplemental app metadata of type `dashboardLayout` (a backend `QSupplementalAppMetaData` subclass whose `getType()` returns `dashboardLayout` and that is included in frontend metadata) with `maxContentWidth` (rem, or a CSS length). The page then centres at that width: `{"supplementalAppMetaData":{"dashboardLayout":{"maxContentWidth":160}}}`.
|
||||
|
||||
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:
|
||||
|
||||
@@ -10,11 +10,11 @@ RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-nex
|
||||
|
||||
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.
|
||||
Previously verified public candidate at the RC36 preparation checkpoint: **[1.0.0-RC.35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC20 and RC22 through RC35 remain available and unchanged (RC21 was never published).
|
||||
|
||||
## 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.
|
||||
This checkout targets [RC36](1.0.0-RC.36.md); confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing. RC36 adds accessibility fixes: readable link text, 24px clear targets, h2 widget titles and skip-link focus ([PR175](https://github.com/QRun-IO/qqq-frontend-next/pull/175) (a11y: readable link text, 24px clear target, h2 widget titles, skip link focus)). RC35 added a profile photo uploader and avatars everywhere (UI API 1.8.0) ([PR164](https://github.com/QRun-IO/qqq-frontend-next/pull/164) (profile photo uploader and avatars everywhere, UI API 1.8.0)). RC34 added fail-soft error handling: layered error boundaries, validated assistant payloads and streams that can no longer lock the panel ([PR172](https://github.com/QRun-IO/qqq-frontend-next/pull/172) (layered error boundaries, zod payload repair, streaming can no longer lock the assistant panel)). RC33 added the new navigation (sidebar sections and favorites, one search, a new header, single-key shortcuts) and an assistant crash fix ([PR168](https://github.com/QRun-IO/qqq-frontend-next/pull/168) (sidebar sections and favorites, one search, new header, single-key shortcut switch), [PR170](https://github.com/QRun-IO/qqq-frontend-next/pull/170) (empty assistant query results no longer crash the page)). RC32 added a What's new page and user-menu entry for release notes ([PR165](https://github.com/QRun-IO/qqq-frontend-next/pull/165) (What's new page and user-menu entry, release-notes UI API 1.0.0)). RC31 added UI API 1.6.0 adoption: client capabilities, field validation, one error schema and v1 branding ([PR162](https://github.com/QRun-IO/qqq-frontend-next/pull/162) (adopt QQQ UI API 1.6.0: clientCapabilities, field validation, error schema, v1 branding)). RC30 added optional Jam QA capture on the qbit-qa-capture UI API extension ([PR160](https://github.com/QRun-IO/qqq-frontend-next/pull/160) (optional Jam QA capture on the qbit-qa-capture qa-capture extension)). RC29 added the EDI mapper page on the qbit-edi UI API extension ([PR136](https://github.com/QRun-IO/qqq-frontend-next/pull/136) (EDI mapper page on the qbit-edi edi extension, all calls on spec)). RC28 added zero off-spec UI API calls, so every backend call is a /qqq/v1 operation in the spec (minimum UI API 1.4.0) ([PR141](https://github.com/QRun-IO/qqq-frontend-next/pull/141) (zero off-spec UI API calls; OFF_SPEC_CEILING 0; minimum UI API 1.4.0)). RC27 added the secured-data UI (masked values, reveal, restricted filters, break-glass banner) and the live job view with a header Jobs tray and Jobs page ([PR124](https://github.com/QRun-IO/qqq-frontend-next/pull/124) (secured-data UI: masked values, reveal, restricted filters, break-glass banner), [PR155](https://github.com/QRun-IO/qqq-frontend-next/pull/155) (live job view, header Jobs tray and Jobs page), [PR156](https://github.com/QRun-IO/qqq-frontend-next/pull/156) (large-table acceptance fixture declares its default columns)). RC26 added tables that open with a short default column set instead of every field ([PR142](https://github.com/QRun-IO/qqq-frontend-next/pull/142) (tables open with a short default column set)). RC25 added an assistant panel that never blocks asking, re-attaches after refresh, shows live progress and can always Stop ([PR151](https://github.com/QRun-IO/qqq-frontend-next/pull/151) (assistant: never block asking, re-attach after refresh, live progress and Stop)). RC24 added the tablet record-tab fix, empty tables that explain themselves, a Full access checkbox in the policy editor and homes without duplicate card grids ([PR149](https://github.com/QRun-IO/qqq-frontend-next/pull/149) (record section tabs wrap instead of folding into More; tablet regression from PR144), [PR150](https://github.com/QRun-IO/qqq-frontend-next/pull/150) (empty tables explain what they are for and how rows get created), [PR148](https://github.com/QRun-IO/qqq-frontend-next/pull/148) (Full access checkbox in the authentik policy editor), [PR145](https://github.com/QRun-IO/qqq-frontend-next/pull/145) (no automatic card grids on homes that declare widgets)). RC23 added help everywhere, the Preferences page, contingent possible values in forms, search typeahead and System/Light/Dark theming ([PR127](https://github.com/QRun-IO/qqq-frontend-next/pull/127) (help everywhere: help icons, long-help panel, help search, author links), [PR121](https://github.com/QRun-IO/qqq-frontend-next/pull/121) (Preferences page, notification settings drawer and section actions), [PR120](https://github.com/QRun-IO/qqq-frontend-next/pull/120) (contingent possible values across forms, bulk edit, processes and filters), [PR143](https://github.com/QRun-IO/qqq-frontend-next/pull/143) (typeahead suggestions from qbit-quick-search in the global search box), [PR140](https://github.com/QRun-IO/qqq-frontend-next/pull/140) (authentik policy editor feature permissions and member preview), [PR138](https://github.cLine truncated
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Next UI 1.0.0-RC.12 — preparation
|
||||
|
||||
At this preparation checkpoint, RC12 is being prepared for application testing and **is not yet published**. [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.12` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.12`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `b0b0d469feb0b1bd473fbf189540e712ffc09807` (merge of [PR46](https://github.com/QRun-IO/qqq-frontend-next/pull/46)). Merged since RC11: PR25, PR27, PR28, PR29, PR30, PR32, PR35, PR36, PR38, PR40, PR42, PR43, PR44 and PR46. The SQL Workbench work in draft PR47 is not included.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
RC12 runs on a published QQQ 4.1 candidate: **4.1.0-RC.1** or later. Standalone branding images (below) need **4.1.0-RC.2** or later, which contains QQQ #1009's `/qqq/branding/logo` and `/qqq/branding/icon` endpoints. RC12 does not publish QQQ 4.1.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Always show the record view's Actions menu when it has entries. Tables without processes or a custom `VIEW_SCREEN_ACTIONS` menu, and read-only tables, again reach Developer Mode and Audit (Material parity); Copy and Delete move into that menu instead of also appearing as separate buttons. [#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)
|
||||
- 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)
|
||||
|
||||
## Standalone branding and CSP (QQQ #1009, #1017)
|
||||
|
||||
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.
|
||||
|
||||
## 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 checkpoint
|
||||
|
||||
PR checks on the RC12 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the full real-backend acceptance workflow) are recorded on the preparation PR. Tag acceptance and public-artifact verification are recorded in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) after delivery. No RC11 test count or artifact digest is attributed to RC12.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC11 limits carry forward unchanged. The RC11 tag acceptance recorded 2,990 passes and 2 owner-accepted failures (Firefox NAV-014 font diagnostic, tablet NAV-056 access-control diagnostics) under the documented secondary-browser/tablet exception ([#985](https://github.com/QRun-IO/qqq/issues/985), [#798](https://github.com/QRun-IO/qqq/issues/798)); it does not waive new product defects or authorize GA. Historical REC-054/PRC-061 observations remain under [#954](https://github.com/QRun-IO/qqq/issues/954) and [#970](https://github.com/QRun-IO/qqq/issues/970). 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), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Next UI 1.0.0-RC.13 — preparation
|
||||
|
||||
At this preparation checkpoint, RC13 is being prepared for application testing and **is not yet published**. [RC12](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.13` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.13`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `84c95e524a93056864e60863f5ab45357969e786` (merge of [PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53)). Merged since RC12: [PR47](https://github.com/QRun-IO/qqq-frontend-next/pull/47) (SQL Workbench), [PR49](https://github.com/QRun-IO/qqq-frontend-next/pull/49) (FILE_PREVIEW thumbnails and lightbox), [PR50](https://github.com/QRun-IO/qqq-frontend-next/pull/50) (responsive layouts, readable child lists, compact dates) and [PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53) (instance home app). The single page scroll container ([PR54](https://github.com/QRun-IO/qqq-frontend-next/pull/54), its WebKit acceptance not yet green) and widget width readability ([PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55)) are not included; they follow in RC14.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
RC13 runs on a published QQQ 4.1 candidate: **4.1.0-RC.1** or later. Standalone branding images need **4.1.0-RC.2** or later (QQQ #1009). **FILE_PREVIEW thumbnails and the instance home app need QQQ 4.1.0-RC.3 or later**, which carries the `FILE_PREVIEW` adornment ([qqq#1056](https://github.com/QRun-IO/qqq/issues/1056)) and `QInstance.homeAppName` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)); on older backends neither appears and the rest of RC13 works unchanged. The SQL Workbench page needs the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit, **1.0.0-RC.1** or later, on the backend. RC13 does not publish QQQ 4.1.
|
||||
|
||||
## 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).
|
||||
|
||||
## Features
|
||||
|
||||
- Support an instance home app. When the backend designates one with `QInstance.homeAppName` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)), and the user may open it, `/app` shows that app's dashboard (a client-side redirect, matching backends that redirect full page loads of `/app/`), the sidebar logo and the breadcrumb Home crumb link there, and the sidebar drops its separate Dashboard entry. Without a home app, or with one the user may not open, `/app` remains Next's overview.
|
||||
- Show `FILE_PREVIEW`-adorned fields as thumbnails, and open a preview on click or Enter. Thumbnails appear in the query grid and card view, the record view (a larger thumbnail with the file name), associated-record tables, child-record list widgets and record-grid widgets. They load lazily at a fixed size, so loading does not shift the layout, with a loading pulse and a file-type icon fallback. The preview is an accessible dialog: focus is trapped and restored, Escape closes it, and Left/Right/Home/End step through the other previews on the same screen. It offers zoom and fit, open-original and download links, and shows type, size, dimensions and caption. PDFs served by the backend (same-origin `/qqq/` paths) and video/audio play inline; other types, and other or cross-origin PDFs (frames are limited to `'self'`), get a file card or open in a new tab. Thumbnails are shown only for explicitly adorned fields; plain URL values are never auto-detected. URLs are limited to http(s), same-origin paths and inline images. The lightbox loads on demand (about 3.5 KB gzip). Requires the QQQ backend adornment from [qqq#1056](https://github.com/QRun-IO/qqq/issues/1056).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Size record-view cards, dashboard widgets and form fields by the width of their own grid (container queries) rather than the viewport: table panels sit side by side only when each is readable and otherwise stack, cards keep their own height, fields share a row only when they fit, and very wide screens use the room. No horizontal page scroll from 360px up.
|
||||
- Make child-record lists in record-view panels readable: they show the columns that matter most and fit (the record name, status and boolean columns, the most recently changed date; the key and long text last), with `+N more columns` and View All for the rest. Cells align to the top and long text is clamped with the full text on hover. A widget's `onlyIncludeFieldNames` order is respected.
|
||||
- Show dates compactly everywhere: `6:33 PM` today, `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before, on one line with the full zoned timestamp on hover, in the viewer's zone and locale. DATE values are no longer shifted a day west of UTC. Exports are unchanged.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
The record developer route `/app/_/_/dev` and the table developer route `/app/_/dev` each have a 360 KB first-load cap (measured 354.9 KB and 350.8 KB on the combined tree); every other route keeps the 350 KB default. The SQL Workbench editor (CodeMirror 6) and formatter load on demand under separate `lazyChunks` budgets; the FILE_PREVIEW lightbox is a lazy chunk.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC13 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the full real-backend acceptance workflow) are recorded on the preparation PR. During integration the WebKit/tablet NAV-016, NAV-023 and NAV-056 access-control diagnostics and one Firefox image-decode diagnostic (SEC-026) recurred intermittently and passed on rerun; they belong to the tracked [#985](https://github.com/QRun-IO/qqq/issues/985) / [#951](https://github.com/QRun-IO/qqq/issues/951) investigations and no assertion was relaxed. Tag acceptance and public-artifact verification are recorded in the [RC13 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) after delivery. No RC12 test count or artifact digest is attributed to RC13.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC12 limits carry forward unchanged: 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), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.14 — preparation
|
||||
|
||||
At this preparation checkpoint, RC14 is being prepared as an urgent fix for application testing and **is not yet published**. [RC13](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.14` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.14`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `b699204d8736f5cfcc61358c38e86d89ab3d5541` (merge of [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57)). Merged since RC13: [PR52](https://github.com/QRun-IO/qqq-frontend-next/pull/52) (Next.js 16.3.8 security update and sharp) and [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57) (record search tolerates results without a label). RC14 is cut early because global record search is broken in production on RC13. The README adoption guide (PR58), the record developer view (PR61), the single page scroll container (PR54), widget width readability (PR55) and the NAV-056 diagnostic fix are not included; they follow in RC15.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC13: a published QQQ 4.1 candidate, **4.1.0-RC.1** or later; **4.1.0-RC.2** or later for standalone branding images (QQQ #1009); **4.1.0-RC.3** or later for FILE_PREVIEW thumbnails ([qqq#1056](https://github.com/QRun-IO/qqq/issues/1056)) and the instance home app ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)); [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) **1.0.0-RC.1** or later for the SQL Workbench page. The search fix needs no backend change; the server-side companion [qqq#1068](https://github.com/QRun-IO/qqq/pull/1068) stops emitting blank labels. RC14 does not publish QQQ 4.1.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Record search no longer fails when one result has no label. QQQ serializes JSON without empty values, so a record whose label is empty arrived without `recordLabel`, and the strict response schema rejected the whole response ("Record search failed. Try again."). The response envelope is still checked strictly, but each result is now validated on its own: `tableName` and `recordId` are required (a numeric `recordId` is accepted), a result without a label is shown as "{table label} {record id}", and a result that cannot link to a record is skipped with a console warning while the others render.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Next.js 16.3.6 to 16.3.8 (security fixes, including the image-optimization SSRF advisory GHSA-cjq9-62q9-8jv4) and the matching `sharp` update ([PR52](https://github.com/QRun-IO/qqq-frontend-next/pull/52)).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC14 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the full real-backend acceptance workflow) are recorded on the preparation PR. During integration the WebKit/tablet NAV-023 diagnostic recurred intermittently and passed on rerun. **Known failure accepted for this release:** the WebKit NAV-056 diagnostic (appearance preferences explain an application theme override) failed on [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57) and also fails on main without RC14's changes; the owner explicitly approved merging and releasing RC14 with it because production record search was broken. It is tracked under [#985](https://github.com/QRun-IO/qqq/issues/985), a fix is in progress for RC15, and no assertion was relaxed. Tag acceptance and public-artifact verification are recorded in the [RC14 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14) after delivery. No RC13 test count or artifact digest is attributed to RC14.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC13 limits carry forward unchanged: 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), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Next UI 1.0.0-RC.15 — preparation
|
||||
|
||||
At this preparation checkpoint RC15 **is not yet published**. [RC14](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.15` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.15`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `5de69be`. Merged since RC14: [PR58](https://github.com/QRun-IO/qqq-frontend-next/pull/58) (README adoption guide), [PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55) (readable dashboard widget widths), [PR54](https://github.com/QRun-IO/qqq-frontend-next/pull/54) (one page scroll container), [PR77](https://github.com/QRun-IO/qqq-frontend-next/pull/77) (Dependabot limited to three open PRs per ecosystem), [PR78](https://github.com/QRun-IO/qqq-frontend-next/pull/78) (SQL Workbench dark mode and standard controls), [PR61](https://github.com/QRun-IO/qqq-frontend-next/pull/61) (record developer view), [PR60](https://github.com/QRun-IO/qqq-frontend-next/pull/60) (QQQ Assistant panel, phase 1), [PR59](https://github.com/QRun-IO/qqq-frontend-next/pull/59) (search-as-you-type quick search, richer results and a search page).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
A published QQQ 4.1 candidate, **4.1.0-RC.1** or later; **4.1.0-RC.3** or later for FILE_PREVIEW and the instance home app; **4.1.0-RC.4** or later for record developer panels ([qqq#1076](https://github.com/QRun-IO/qqq/pull/1076)); [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) 1.0.0-RC.1 or later for the SQL Workbench page (1.0.0-RC.4 or later opens developer-panel SQL in the Runner); [qbit-assistant](https://github.com/QRun-IO/qbit-assistant) 0.1.0-RC.3 or later for the assistant panel (without it nothing renders). RC15 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **QQQ Assistant panel (Phase 1: read-only questions and navigation).** When the `qbit-assistant` backend is installed and `GET /qqq/v1/assistant/config` enables it for the user, a header button (with unread badge), `Ctrl/⌘+J` and a command palette "Ask Assistant" item open a chat panel. It docks beside the page from 1024 px (resizable 360-640 px, its own scrolling message list) and is a full-height sheet below that. Replies stream over SSE with resume after a dropped connection and a Stop button; tool steps, record chips, records/record/count/history renders and chart widgets come from structured payloads, and markdown renders without raw HTML or images, with links only to same-origin `/app` paths. `?assistant=open&assistantPrompt=...` opens a new conversation with the prompt pre-filled. Without the backend nothing renders and the shortcut is inert. The panel code loads on first open (about 1.7 KB added to first load).
|
||||
- **Search** ([PR59](https://github.com/QRun-IO/qqq-frontend-next/pull/59)): search-as-you-type quick search with richer results (highlights, details, per-table status from QQQ 4.1.0-RC.4 record search) and a URL-addressable search page; the search page and previews load on demand.
|
||||
- **SQL Workbench dark mode and standard controls** ([PR78](https://github.com/QRun-IO/qqq-frontend-next/pull/78)).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- **Readable dashboard widget widths** ([PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55)): per-widget minimum widths, ultra-wide caps and a table card layout.
|
||||
- **Record developer view** (`/app/{table}/{id}/dev`): the record screen's navigation (back to the record, record title, Edit, Actions, Audit, breadcrumbs) and a sticky section bar (a section picker on phones, wrapping links elsewhere) over Fields (display and raw values, types, possible-value sources and adornments, filterable), **API Access** (the v1 routes this screen uses and every application API that exposes the table, read from its OpenAPI spec and limited to what the user may do, with copyable curl, fetch and Java examples built from the record's values, placeholder credentials only, and links to the API docs and specs), **developer panels** from QQQ's provider contract ([qqq#1076](https://github.com/QRun-IO/qqq/pull/1076): record security, ESB events, and any qbit's panel such as quick search, SQL workbench or the assistant, shown only when installed and active for the table; QQQ before 4.1.0-RC.4 has no panels endpoint, so the view shows none and asks for it only once per session), Relations, recent Audit, associated scripts, raw JSON and a searchable Table Metadata explorer. Readable from 360 to 3200px with no sideways scrolling. The view loads on demand, keeping the route's first-load JavaScript at 333.5 KB (budget 360 KB).
|
||||
- One scroll container per page: on long pages the document no longer grows a second scrollbar beside the content area's (and no longer slides the header off-screen). The app shell and `<main>` are now positioned, so absolutely positioned descendants such as `sr-only` text (the company footer's "opens in a new tab") stay inside the scrolling area instead of stretching the document. Covered by `tests/e2e/single-scroll-container.spec.ts` at 390-3200px.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Dependabot keeps at most three open PRs per ecosystem ([PR77](https://github.com/QRun-IO/qqq-frontend-next/pull/77)).
|
||||
- The WebKit NAV-056 diagnostic race is fixed in the acceptance tests ([PR63](https://github.com/QRun-IO/qqq-frontend-next/pull/63)).
|
||||
- README: "Adopting in another app" ([PR58](https://github.com/QRun-IO/qqq-frontend-next/pull/58)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Owner decision 2026-10-12: the total JavaScript cap (every on-demand feature) is 1100 KB gzip and grows with features; first-load route caps, the 23 KB shared CSS cap and the lazy-chunk caps stay strict. See `perf-budget.json`.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC15 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the real-backend acceptance workflow) are recorded on the preparation PR. Tag acceptance and public-artifact verification are recorded in the RC15 GitHub release after delivery. No RC14 test count or artifact digest is attributed to RC15.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC14 limits carry forward unchanged: 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), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,35 @@
|
||||
# Next UI 1.0.0-RC.16 — preparation
|
||||
|
||||
At this preparation checkpoint RC16 **is not yet published**. [RC15](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.15) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.16` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.16`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `6dea1c3` (merge of [PR80](https://github.com/QRun-IO/qqq-frontend-next/pull/80)). Merged since RC15: [PR79](https://github.com/QRun-IO/qqq-frontend-next/pull/79) (resizable desktop navigation), [PR84](https://github.com/QRun-IO/qqq-frontend-next/pull/84) (search preview thumbnails), [PR86](https://github.com/QRun-IO/qqq-frontend-next/pull/86) (CSS budget 24 KB), [PR87](https://github.com/QRun-IO/qqq-frontend-next/pull/87) (range-error wording follows the backend), [PR85](https://github.com/QRun-IO/qqq-frontend-next/pull/85) (Alerts bell and record panel), [PR82](https://github.com/QRun-IO/qqq-frontend-next/pull/82) (date-first filters), [PR81](https://github.com/QRun-IO/qqq-frontend-next/pull/81) (quiet, metadata-driven form validation), [PR80](https://github.com/QRun-IO/qqq-frontend-next/pull/80) (record picker for large possible-value fields).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
A published QQQ 4.1 candidate, **4.1.0-RC.1** or later; **4.1.0-RC.4** or later for record developer panels; **4.1.0-RC.5** or later for the record picker ([qqq#1099](https://github.com/QRun-IO/qqq/pull/1099)), the form validation limits it reads from metadata ([qqq#1100](https://github.com/QRun-IO/qqq/pull/1100)) and the date filter `StartOfPeriod` presets ([qqq#1115](https://github.com/QRun-IO/qqq/pull/1115)); on earlier backends these features fall back to the RC15 behavior. The Alerts bell and record panel render only when the instance metadata includes the `alerts` app ([qbit-alerts](https://github.com/QRun-IO/qbit-alerts) 0.1.0-RC.1 or later); otherwise they send no requests. RC16 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Record picker** ([PR80](https://github.com/QRun-IO/qqq-frontend-next/pull/80)): possible-value fields over large tables open a searchable, filterable, paged picker dialog (lazy-loaded, with thumbnails where the table has images).
|
||||
- **Form validation** ([PR81](https://github.com/QRun-IO/qqq-frontend-next/pull/81)): quiet, metadata-driven validation (required fields, too-long behavior, rejecting value ranges) before any request; server messages are still shown once.
|
||||
- **Date-first filters** ([PR82](https://github.com/QRun-IO/qqq-frontend-next/pull/82)): date and date-time filters with presets, an include-time toggle, and the `X-QQQ-UserTimezone` header.
|
||||
- **Alerts bell and record panel** ([PR85](https://github.com/QRun-IO/qqq-frontend-next/pull/85)): a header bell and a record panel for qbit-alerts, gated on the `alerts` app in metadata.
|
||||
- **Resizable navigation** ([PR79](https://github.com/QRun-IO/qqq-frontend-next/pull/79)) and **search preview thumbnails** ([PR84](https://github.com/QRun-IO/qqq-frontend-next/pull/84)).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Range errors use the backend wording ([PR87](https://github.com/QRun-IO/qqq-frontend-next/pull/87)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Shared CSS cap 24 KB ([PR86](https://github.com/QRun-IO/qqq-frontend-next/pull/86), measured 23.07 KB after PR79); total JavaScript cap 1100 KB (owner decision 2026-10-12). Route and lazy-chunk caps are unchanged.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
Each merged PR passed real-backend acceptance (Chromium, Firefox, WebKit, mobile/tablet) on its own head against QQQ develop with the 4.1.0-RC.5 batch. PR checks on the RC16 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC16 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC15 limits carry forward unchanged: 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), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.17 — preparation
|
||||
|
||||
At this preparation checkpoint RC17 **is not yet published**. [RC16](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.16) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.17` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.17`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `6ed2575` (merge of [PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89)). Merged since RC16: [PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89) (notification center).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC16 (QQQ **4.1.0-RC.5** or later for the record picker, validation limits and date presets). The notification center renders only when the instance metadata includes the `alerts` app, and needs [qbit-alerts](https://github.com/QRun-IO/qbit-alerts) **0.1.0-RC.2** or later (user notifications); without it nothing renders and no requests are sent. RC17 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Notification center** ([PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89)): a bell inbox and notification list with read state and preferences for qbit-alerts user notifications. The center loads on demand (lazy chunk `notificationCenter`, 15.2 KB gzip).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
**Both the total JavaScript and the first-load budgets are within 2 KB of their caps:** total JS 1098.0 / 1100 KB and `/app` first load 349.2 / 350 KB. The next Next feature needs a measured cap raise (under the owner decision of 2026-10-12) or a code-splitting pass first.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR89 passed real-backend acceptance on Chromium, Firefox, WebKit and mobile/tablet. PR checks on the RC17 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC17 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC16 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,32 @@
|
||||
# Next UI 1.0.0-RC.18 — preparation
|
||||
|
||||
At this preparation checkpoint RC18 **is not yet published**. [RC17](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.17) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.18` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.18`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `0282328` (merge of [PR94](https://github.com/QRun-IO/qqq-frontend-next/pull/94)). Merged since RC17: [PR91](https://github.com/QRun-IO/qqq-frontend-next/pull/91) (bundle budget report-only (owner decision)), [PR97](https://github.com/QRun-IO/qqq-frontend-next/pull/97) (faster sharded acceptance CI), [PR98](https://github.com/QRun-IO/qqq-frontend-next/pull/98) (budget script honors "enforce": false), [PR95](https://github.com/QRun-IO/qqq-frontend-next/pull/95) (Authentik policy editor widget), [PR94](https://github.com/QRun-IO/qqq-frontend-next/pull/94) (assistant pending-action card, persona and refresh after a write).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC17 (QQQ **4.1.0-RC.5** or later). The Authentik policy editor needs [qbit-authentik](https://github.com/QRun-IO/qbit-authentik) **0.1.0-RC.2** or later (cut-over tooling in RC.3). The assistant pending-action card and persona need [qbit-assistant](https://github.com/QRun-IO/qbit-assistant) **0.1.0-RC.6** or later. Each renders only when its backend is installed. RC18 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentik policy editor** ([PR95](https://github.com/QRun-IO/qqq-frontend-next/pull/95)): the `AUTHENTIK_POLICY_EDITOR` widget for qbit-authentik policies.
|
||||
- **Assistant pending-action card and persona** ([PR94](https://github.com/QRun-IO/qqq-frontend-next/pull/94)): the assistant panel shows proposed actions as a pending-action card, adds a persona, and refreshes the page after a write.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Faster sharded real-backend acceptance with a quarantine lane and an acceptance gate ([PR97](https://github.com/QRun-IO/qqq-frontend-next/pull/97)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
The bundle budget is **report-only** (owner decision 2026-10-13, [PR91](https://github.com/QRun-IO/qqq-frontend-next/pull/91)): `perf-budget.json` has `"enforce": false`, and [PR98](https://github.com/QRun-IO/qqq-frontend-next/pull/98) makes the script honor it. Size overruns print as warnings with every number still reported, while structural problems (a lazy chunk on a first load, a missing chunk marker) still fail. Total JavaScript is now above the 1100 KB reference value.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR94 and PR95 passed real-backend acceptance. PR checks on the RC18 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC18 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC17 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Next UI 1.0.0-RC.19 — preparation
|
||||
|
||||
At this preparation checkpoint RC19 **is not yet published**. [RC18](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.18) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.19` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.19`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `a2f2318` (merge of [PR101](https://github.com/QRun-IO/qqq-frontend-next/pull/101)). Merged since RC18: [PR96](https://github.com/QRun-IO/qqq-frontend-next/pull/96) (related-record picker: single, multi-child and link modes), [PR101](https://github.com/QRun-IO/qqq-frontend-next/pull/101) (assistant Read-only / Write switch per conversation).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC18 (QQQ **4.1.0-RC.5** or later; the related-record picker builds on the RC.5 picker contract, qqq#1099). The Read-only / Write switch needs qbit-assistant **0.1.0-RC.6** or later. RC19 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Related-record picker** ([PR96](https://github.com/QRun-IO/qqq-frontend-next/pull/96)): pick related records in single, multi-child and link modes, built on the media picker dialog.
|
||||
- **Assistant Read-only / Write switch** ([PR101](https://github.com/QRun-IO/qqq-frontend-next/pull/101)): each conversation can be switched between read-only and write.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13): sizes are measured and reported; structural problems still fail.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR96 and PR101 passed real-backend acceptance. PR checks on the RC19 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC19 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC18 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Next UI 1.0.0-RC.20 — preparation
|
||||
|
||||
At this preparation checkpoint RC20 **is not yet published**. [RC19](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.19) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.20` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.20`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `438eda3` (merge of [PR111](https://github.com/QRun-IO/qqq-frontend-next/pull/111)). Merged since RC19: [PR92](https://github.com/QRun-IO/qqq-frontend-next/pull/92) (UI API version contract), [PR104](https://github.com/QRun-IO/qqq-frontend-next/pull/104) (UI API contract picker paths), [PR109](https://github.com/QRun-IO/qqq-frontend-next/pull/109) (acceptance ESB extension fix), [PR103](https://github.com/QRun-IO/qqq-frontend-next/pull/103) (data-analysis column and table profile panels), [PR105](https://github.com/QRun-IO/qqq-frontend-next/pull/105) (favorites, server recents and command-palette favorites), [PR111](https://github.com/QRun-IO/qqq-frontend-next/pull/111) (readable assistant answers: GFM markdown and presentation blocks).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
QQQ **4.1.0-RC.5** or later for everything in RC19. The new screens use the UI API 1.2 contract and the collaboration and data-analysis cores from QQQ **4.1.0-RC.6**: the profile panels need the data-analysis aggregates (qqq#1139, qqq#1145), and favorites and server recents need the collaboration core (qqq#1138 and follow-ups). Assistant presentation blocks need qbit-assistant **0.1.0-RC.9** or later. RC20 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Readable assistant answers** ([PR111](https://github.com/QRun-IO/qqq-frontend-next/pull/111)): GFM markdown (tables, task lists) and presentation blocks from qbit-assistant 0.1.0-RC.9.
|
||||
- **Data-analysis profile panels** ([PR103](https://github.com/QRun-IO/qqq-frontend-next/pull/103)): column and table profiles with drill-down.
|
||||
- **Favorites and recents** ([PR105](https://github.com/QRun-IO/qqq-frontend-next/pull/105)): favorites, server-side recents and palette favorites.
|
||||
- **UI API version contract** ([PR92](https://github.com/QRun-IO/qqq-frontend-next/pull/92), [PR104](https://github.com/QRun-IO/qqq-frontend-next/pull/104)).
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Acceptance ESB extension fix ([PR109](https://github.com/QRun-IO/qqq-frontend-next/pull/109)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13): sizes are measured and reported; structural problems still fail.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
The merged PRs passed real-backend acceptance. PR checks on the RC20 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC20 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC19 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,29 @@
|
||||
# Next UI 1.0.0-RC.21 — preparation
|
||||
|
||||
> **RC21 was never published.** Its tag build failed the UI API off-spec ceiling test, so the Maven Central and GHCR publishes were cancelled. Its changes ship in [RC22](1.0.0-RC.22.md).
|
||||
|
||||
At this preparation checkpoint RC21 **is not yet published**. [RC20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.21` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.21`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `a6c95b1` (merge of [PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125)). Merged since RC20: [PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125) (attach a file in the assistant panel for bulk load).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
QQQ **4.1.0-RC.6** or later for everything in RC20. The assistant file attach feeds assisted bulk load: it uses the bulk-load UI API operations from QQQ **4.1.0-RC.7** (qqq#1179) and the bulk-load tools in qbit-assistant **0.1.0-RC.10** (qbit-assistant#38). The upload itself uses `POST /qqq/v1/uploads` from [qqq#1181](https://github.com/QRun-IO/qqq/pull/1181), which is not yet in a QQQ release; until it ships, an attach attempt fails with a toast saying the file could not be attached, and nothing else changes. RC21 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Assistant file attach** ([PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125)): attach a file in the assistant panel to start an assisted bulk load.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13): sizes are measured and reported; structural problems still fail.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC21 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC21 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC20 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Next UI 1.0.0-RC.22 — preparation
|
||||
|
||||
At this preparation checkpoint RC22 **is not yet published**. [RC20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20) remains the latest verified public candidate and is immutable. RC21 was tagged but never published (its tag build failed the off-spec ceiling test and publishing was cancelled); its content is included here. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.22` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.22`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `48e81b4` (merge of [PR128](https://github.com/QRun-IO/qqq-frontend-next/pull/128)). Merged since RC20: [PR113](https://github.com/QRun-IO/qqq-frontend-next/pull/113) (collaboration record panel: comments, files, activity, tags and follow), [PR128](https://github.com/QRun-IO/qqq-frontend-next/pull/128) (collaboration: realtime presence, conflict review, sharing, tasks and My work), [PR106](https://github.com/QRun-IO/qqq-frontend-next/pull/106) (data-analysis pivot / group-by explorer), [PR118](https://github.com/QRun-IO/qqq-frontend-next/pull/118) (data-analysis compare, correlation, crosstab and shift explorers), [PR123](https://github.com/QRun-IO/qqq-frontend-next/pull/123) (data-analysis save, share, star, export and watch), [PR126](https://github.com/QRun-IO/qqq-frontend-next/pull/126) (run processes in background, background-job banner, widget refresh), [PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125) (attach a file in the assistant panel for bulk load), [PR132](https://github.com/QRun-IO/qqq-frontend-next/pull/132) (vendored alerts and sql-workbench extension documents, off-spec 46 to 21), [PR131](https://github.com/QRun-IO/qqq-frontend-next/pull/131) (off-spec ceiling for POST /uploads), [PR129](https://github.com/QRun-IO/qqq-frontend-next/pull/129) (chart data table overflow and doubled widget section headings).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
QQQ **4.1.0-RC.6** or later. The collaboration screens need qbit-collaboration **0.1.0-RC.1** or later. The data-analysis explorers need qbit-data-analysis **0.1.0-RC.1** or later (extension 1.4.0). Assistant file attach uses the bulk-load UI API operations in QQQ **4.1.0-RC.7** and the tools in qbit-assistant **0.1.0-RC.10**; the upload endpoint (qqq#1181) is not yet released, so an attach attempt fails with a toast until it ships. Background process runs appear only when the server advertises `durableJobEligible` (qqq#1178 and qqq#1181); against older servers nothing new shows. RC22 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Collaboration** ([PR113](https://github.com/QRun-IO/qqq-frontend-next/pull/113), [PR128](https://github.com/QRun-IO/qqq-frontend-next/pull/128)): record panel (comments, files, activity), tags and follow; realtime presence, conflict review, sharing, tasks and My work.
|
||||
- **Data analysis** ([PR106](https://github.com/QRun-IO/qqq-frontend-next/pull/106), [PR118](https://github.com/QRun-IO/qqq-frontend-next/pull/118), [PR123](https://github.com/QRun-IO/qqq-frontend-next/pull/123)): pivot / group-by, compare, correlation, crosstab and shift explorers; save, share, star, export and watch.
|
||||
- **Background process runs** ([PR126](https://github.com/QRun-IO/qqq-frontend-next/pull/126)): run in background, background-job banner, widget refresh.
|
||||
- **Assistant file attach** ([PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125)) for assisted bulk load.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Chart data table overflow and doubled widget section headings ([PR129](https://github.com/QRun-IO/qqq-frontend-next/pull/129)).
|
||||
|
||||
## UI API contract
|
||||
|
||||
- Vendored alerts and sql-workbench extension documents; the off-spec list drops from 46 to 21 ([PR132](https://github.com/QRun-IO/qqq-frontend-next/pull/132), [PR131](https://github.com/QRun-IO/qqq-frontend-next/pull/131)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13): sizes are measured and reported; structural problems still fail.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC22 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC22 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC20 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). Collaboration task assignees show the user id until qbit-collaboration adds a display name.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Next UI 1.0.0-RC.23 — preparation
|
||||
|
||||
At this preparation checkpoint RC23 **is not yet published**. [RC22](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.22) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.23` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.23`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `39b8f8b` (merge of [PR127](https://github.com/QRun-IO/qqq-frontend-next/pull/127)). Merged since RC22: [PR127](https://github.com/QRun-IO/qqq-frontend-next/pull/127) (help everywhere: help icons, long-help panel, help search, author links), [PR121](https://github.com/QRun-IO/qqq-frontend-next/pull/121) (Preferences page, notification settings drawer and section actions), [PR120](https://github.com/QRun-IO/qqq-frontend-next/pull/120) (contingent possible values across forms, bulk edit, processes and filters), [PR143](https://github.com/QRun-IO/qqq-frontend-next/pull/143) (typeahead suggestions from qbit-quick-search in the global search box), [PR140](https://github.com/QRun-IO/qqq-frontend-next/pull/140) (authentik policy editor feature permissions and member preview), [PR138](https://github.com/QRun-IO/qqq-frontend-next/pull/138) (theme defaults to the OS color scheme), [PR144](https://github.com/QRun-IO/qqq-frontend-next/pull/144) (record section tabs fold into a More menu), [PR139](https://github.com/QRun-IO/qqq-frontend-next/pull/139) (breadcrumb without inner horizontal scroll), [PR146](https://github.com/QRun-IO/qqq-frontend-next/pull/146) (app-nested URLs redirect to their canonical path), [PR137](https://github.com/QRun-IO/qqq-frontend-next/pull/137) (realtime client isolated between tests), [PR68](https://github.com/QRun-IO/qqq-frontend-next/pull/68) (rapidoc 10.1.0), [PR99](https://github.com/QRun-IO/qqq-frontend-next/pull/99) (GitHub Actions updates).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Everything in RC22 needs QQQ **4.1.0-RC.6** or later. The new screens use QQQ **4.1.0-RC.7** features: help everywhere needs core typed help and help bundles (qqq#1177, qqq#1198); the Preferences page needs `/qqq/v1/preferences` (qqq#1162); contingent possible values need qqq#1175. Against RC.6 these screens stay hidden or fall back to the previous behaviour. Search typeahead needs qbit-quick-search **1.1.0-RC.8** or later (the `suggest` endpoint); without it the global search box behaves as before. The authentik policy editor additions need qbit-authentik 0.1.0-RC.4 or later. RC23 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Help everywhere** ([PR127](https://github.com/QRun-IO/qqq-frontend-next/pull/127)): help icons, a long-help panel, help search and author links, driven by core typed help.
|
||||
- **Preferences** ([PR121](https://github.com/QRun-IO/qqq-frontend-next/pull/121)): Preferences page, notification settings drawer and section actions.
|
||||
- **Contingent possible values** ([PR120](https://github.com/QRun-IO/qqq-frontend-next/pull/120)) across forms, bulk edit, processes and filters.
|
||||
- **Search typeahead** ([PR143](https://github.com/QRun-IO/qqq-frontend-next/pull/143)) from the qbit-quick-search `suggest` endpoint.
|
||||
- **Theme** ([PR138](https://github.com/QRun-IO/qqq-frontend-next/pull/138)): defaults to the OS color scheme (System / Light / Dark).
|
||||
- **Authentik** ([PR140](https://github.com/QRun-IO/qqq-frontend-next/pull/140)): policy editor feature permissions and member preview.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Record section tabs fold into a More menu instead of scrolling ([PR144](https://github.com/QRun-IO/qqq-frontend-next/pull/144)).
|
||||
- Breadcrumb without inner horizontal scroll ([PR139](https://github.com/QRun-IO/qqq-frontend-next/pull/139)).
|
||||
- App-nested URLs redirect to their canonical path ([PR146](https://github.com/QRun-IO/qqq-frontend-next/pull/146)).
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- The realtime client is isolated between tests, which fixes the metadata dedupe flake ([PR137](https://github.com/QRun-IO/qqq-frontend-next/pull/137)).
|
||||
- Dependency updates: rapidoc 10.1.0 ([PR68](https://github.com/QRun-IO/qqq-frontend-next/pull/68)), GitHub Actions ([PR99](https://github.com/QRun-IO/qqq-frontend-next/pull/99)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13): sizes are measured and reported; structural problems still fail.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC23 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC23 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC22 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,37 @@
|
||||
# Next UI 1.0.0-RC.24 — preparation
|
||||
|
||||
At this preparation checkpoint RC24 **is not yet published**. [RC23](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.23) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.24` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.24`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `3ab4815` (merge of [PR150](https://github.com/QRun-IO/qqq-frontend-next/pull/150)). Merged since RC23: [PR149](https://github.com/QRun-IO/qqq-frontend-next/pull/149) (record section tabs wrap instead of folding into More; tablet regression from PR144), [PR150](https://github.com/QRun-IO/qqq-frontend-next/pull/150) (empty tables explain what they are for and how rows get created), [PR148](https://github.com/QRun-IO/qqq-frontend-next/pull/148) (Full access checkbox in the authentik policy editor), [PR145](https://github.com/QRun-IO/qqq-frontend-next/pull/145) (no automatic card grids on homes that declare widgets).
|
||||
|
||||
## Why RC24
|
||||
|
||||
RC23's tag real-backend acceptance failed on two tablet shards in both attempts (REC-004 record sections by touch and keyboard, REC-019 zoned date-times, NAV-015 document title). [PR149](https://github.com/QRun-IO/qqq-frontend-next/pull/149) restores wrapping section tabs (the More menu from PR144 is removed), deflakes NAV-015 and adds tablet to the full-acceptance label.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC23: QQQ **4.1.0-RC.6** or later for everything in RC22, and **4.1.0-RC.7** for help everywhere, Preferences and contingent possible values. The Full access checkbox needs qbit-authentik **0.1.0-RC.5** or later (full-access `*` statement). Empty-table help uses core typed help (QQQ 4.1.0-RC.7); against older cores the table shows the previous empty state. RC24 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Empty tables explain themselves** ([PR150](https://github.com/QRun-IO/qqq-frontend-next/pull/150)): what the table is for and how rows get created, from core help.
|
||||
- **Full access** ([PR148](https://github.com/QRun-IO/qqq-frontend-next/pull/148)): a Full access checkbox in the authentik policy editor.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Record section tabs wrap instead of folding into a More menu; fixes the RC23 tablet regression ([PR149](https://github.com/QRun-IO/qqq-frontend-next/pull/149)).
|
||||
- Homes that declare widgets no longer get automatic card grids ([PR145](https://github.com/QRun-IO/qqq-frontend-next/pull/145)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC24 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC24 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC23 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.25 — preparation
|
||||
|
||||
At this preparation checkpoint RC25 **is not yet published**. [RC24](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.24) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.25` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.25`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `70594c4` (merge of [PR151](https://github.com/QRun-IO/qqq-frontend-next/pull/151)). Merged since RC24: [PR151](https://github.com/QRun-IO/qqq-frontend-next/pull/151) (assistant: never block asking, re-attach after refresh, live progress and Stop).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC24. The assistant changes pair with qbit-assistant **0.1.0-RC.13** (durable turns: lease, heartbeat, cross-pod Stop); with older assistants the panel behaves as before. RC25 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Assistant panel** ([PR151](https://github.com/QRun-IO/qqq-frontend-next/pull/151)): asking is never blocked while a turn runs, the panel re-attaches to a running turn after refresh, shows live progress, and Stop is always available.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC25 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC25 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC24 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.26 — preparation
|
||||
|
||||
At this preparation checkpoint RC26 **is not yet published**. [RC25](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.25) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.26` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.26`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `caa89dc` (merge of [PR142](https://github.com/QRun-IO/qqq-frontend-next/pull/142)). Merged since RC25: [PR142](https://github.com/QRun-IO/qqq-frontend-next/pull/142) (tables open with a short default column set).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC25. RC26 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Default list columns** ([PR142](https://github.com/QRun-IO/qqq-frontend-next/pull/142)): with no saved view, a table opens with a short default column set (primary key, then the first-tier section fields, capped at 6, skipping heavy, text, HTML, BLOB and JSON fields) instead of every field, so the grid no longer scrolls sideways. Fixes [#114](https://github.com/QRun-IO/qqq-frontend-next/issues/114) (kof22 admin UX audit, High).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC26 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC26 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC25 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,29 @@
|
||||
# Next UI 1.0.0-RC.27 — preparation
|
||||
|
||||
At this preparation checkpoint RC27 **is not yet published**. [RC26](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.26) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.27` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.27`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `427a037` (merge of [PR155](https://github.com/QRun-IO/qqq-frontend-next/pull/155)). Merged since RC26: [PR124](https://github.com/QRun-IO/qqq-frontend-next/pull/124) (secured-data UI: masked values, reveal, restricted filters, break-glass banner), [PR155](https://github.com/QRun-IO/qqq-frontend-next/pull/155) (live job view, header Jobs tray and Jobs page), [PR156](https://github.com/QRun-IO/qqq-frontend-next/pull/156) (large-table acceptance fixture declares its default columns).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC26 for existing screens. The Jobs tray and live job view need QQQ **4.1.0-RC.8** (jobs UI API, UI API 1.4.0); against older cores the Jobs button stays hidden. The secured-data UI renders what QQQ **4.1.0-RC.7** field access tiers enforce and needs qbit-secured-data **0.1.0-RC.1** for reveal and break-glass. RC27 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Secured-data UI** ([PR124](https://github.com/QRun-IO/qqq-frontend-next/pull/124)): masked values shown with a lock chip, reveal with reason, restricted filters, and a break-glass banner. The server masks values; the client never receives the real value.
|
||||
- **Jobs** ([PR155](https://github.com/QRun-IO/qqq-frontend-next/pull/155)): a header Jobs button next to alerts, a live job view with progress, phases and log, the ability to leave a job running, and a Jobs page.
|
||||
- Acceptance: the large-table fixture declares its 40 default columns after PR142 ([PR156](https://github.com/QRun-IO/qqq-frontend-next/pull/156)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC27 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC27 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC26 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.28 — preparation
|
||||
|
||||
At this preparation checkpoint RC28 **is not yet published**. [RC27](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.27) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.28` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.28`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `16699ae` (merge of [PR141](https://github.com/QRun-IO/qqq-frontend-next/pull/141)). Merged since RC27: [PR141](https://github.com/QRun-IO/qqq-frontend-next/pull/141) (zero off-spec UI API calls; OFF_SPEC_CEILING 0; minimum UI API 1.4.0).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
**Requires QQQ 4.1.0-RC.9 or later** (UI API 1.4.0 operations; RC.8's `GET /table/{t}/apis` was shadowed by the record route, fixed in qqq#1216). RC28 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Zero off-spec calls** ([PR141](https://github.com/QRun-IO/qqq-frontend-next/pull/141)): the form adjuster, table API discovery, table API documents and the remaining legacy routes now use spec'd `/qqq/v1` operations. `OFF_SPEC_CEILING` is 0.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC28 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC28 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC27 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.29 — preparation
|
||||
|
||||
At this preparation checkpoint RC29 **is not yet published**. [RC28](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.28) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.29` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.29`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `abfbbb2` (merge of [PR136](https://github.com/QRun-IO/qqq-frontend-next/pull/136)). Merged since RC28: [PR136](https://github.com/QRun-IO/qqq-frontend-next/pull/136) (EDI mapper page on the qbit-edi edi extension, all calls on spec).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Requires QQQ **4.1.0-RC.9** or later (as RC28). The EDI mapper needs qbit-edi with the `edi` UI API extension (qbit-edi#15); without it the Open Mapper action stays hidden. RC29 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **EDI mapper** ([PR136](https://github.com/QRun-IO/qqq-frontend-next/pull/136)): an Open Mapper action on `edi_map` records opens a mapper page built on the vendored qbit-edi `edi` UI API extension, with generated types and an API client. Every call is on spec.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC29 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC29 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC28 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Next UI 1.0.0-RC.30 — preparation
|
||||
|
||||
At this preparation checkpoint RC30 **is not yet published**. [RC29](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.29) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.30` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.30`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main after [PR160](https://github.com/QRun-IO/qqq-frontend-next/pull/160), which this preparation depends on. Merged since RC29: [PR160](https://github.com/QRun-IO/qqq-frontend-next/pull/160) (optional Jam QA capture on the qbit-qa-capture `qa-capture` extension, all calls on spec).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
RC30 needs QQQ **4.1.0-RC.9** or later, the same as RC29. QA capture also needs qbit-qa-capture with the `qa-capture` 1.0.0 UI API extension. Without it the UI makes no QA capture call and loads no Jam code. The vendored `qa-capture.json` must match the published QBit fragment before tagging. RC30 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **QA capture (Jam)** ([PR160](https://github.com/QRun-IO/qqq-frontend-next/pull/160)), shown only to users the QBit enables:
|
||||
- Jam's Recording Links SDK is loaded on demand and initialized with sanitizers built from the server's rules (headers, JSON and form fields, query parameters, value patterns, dropped paths, fail closed).
|
||||
- Jam receives metadata with the page context and the last request ids.
|
||||
- "Report a bug" and "QA reports" are in the user menu, and a "QA mode on" badge appears in the header.
|
||||
- Password, reveal and secured-data values are blurred in recordings.
|
||||
- Recording Link `jam-*` parameters are kept through sign-in.
|
||||
- The standalone image adds the Jam CSP origins when `QQQ_QA_CAPTURE_CSP=true`.
|
||||
- Dependencies: `@jam.dev/recording-links` 0.4.0 and `@jam.dev/sdk` 1.0.1, pinned. See [QA capture](../QA-CAPTURE.md).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13). PR160 measured +3.4 to +4.2 KB gzip first-load JS per route and +9.2 KB gzip total JS. The Jam chunks load only for enabled users.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC30 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC30 GitHub release after delivery. Acceptance row SEC-050 (QA capture) is excluded pending owner approval until qbit-qa-capture has a real-backend fixture; Vitest covers it meanwhile.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC29 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.31 — preparation
|
||||
|
||||
At this preparation checkpoint RC31 **is not yet published**. [RC30](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.30) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.31` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.31`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `9aff198` (merge of [PR162](https://github.com/QRun-IO/qqq-frontend-next/pull/162)). Merged since RC30: [PR162](https://github.com/QRun-IO/qqq-frontend-next/pull/162) (adopt QQQ UI API 1.6.0: clientCapabilities, field validation, error schema, v1 branding).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
**Requires QQQ 4.1.0-RC.15 or later** (UI API 1.6.0). RC31 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **UI API 1.6.0** ([PR162](https://github.com/QRun-IO/qqq-frontend-next/pull/162)): vendors the core 1.6.0 and API-aware documents and the updated qbit extensions (alerts 1.2.0, assistant 1.3.0, collaboration 1.1.0, data-analysis 1.5.0, edi 1.1.0, esb 1.1.0, quick-search 1.1.0, secured-data 1.1.0, sql-workbench 1.1.0); uses table client capabilities, server field validation, the single error schema and v1 branding. The off-spec ceiling stays 0.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC31 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC31 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC30 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.32 — preparation
|
||||
|
||||
At this preparation checkpoint RC32 **is not yet published**. [RC31](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.31) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.32` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.32`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `b24b74e` (merge of [PR165](https://github.com/QRun-IO/qqq-frontend-next/pull/165)). Merged since RC31: [PR165](https://github.com/QRun-IO/qqq-frontend-next/pull/165) (What's new page and user-menu entry, release-notes UI API 1.0.0).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC31). What's new needs qbit-release-notes **0.1.0-RC.1** (release-notes UI API extension 1.0.0); without it the feature hides itself. RC32 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **What's new** ([PR165](https://github.com/QRun-IO/qqq-frontend-next/pull/165)): an `/app/whats-new` page and a user-menu entry with an unread count, on the release-notes UI API extension 1.0.0 (vendored). The page is a separate lazy route; off-spec calls stay at 0.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC32 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC32 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC31 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,31 @@
|
||||
# Next UI 1.0.0-RC.33 — preparation
|
||||
|
||||
At this preparation checkpoint RC33 **is not yet published**. [RC32](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.32) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.33` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.33`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `b4b59ce` (merge of [PR170](https://github.com/QRun-IO/qqq-frontend-next/pull/170)). Merged since RC32: [PR168](https://github.com/QRun-IO/qqq-frontend-next/pull/168) (sidebar sections and favorites, one search, new header, single-key shortcut switch), [PR170](https://github.com/QRun-IO/qqq-frontend-next/pull/170) (empty assistant query results no longer crash the page).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC32). The navigation works against any 1.x server (it infers section roles when absent); QQQ **4.1.0-RC.16** (UI API 1.7.0) adds the server-declared section roles, section apps and search keywords. RC33 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Navigation** ([PR168](https://github.com/QRun-IO/qqq-frontend-next/pull/168)): sidebar sections and favorites, one search, a header with Search, Ask, Jobs, Notifications, Help and Account, a single-key shortcut switch and Shift+D.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Empty assistant query results no longer crash the page ([PR170](https://github.com/QRun-IO/qqq-frontend-next/pull/170)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC33 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC33 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC32 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.34 — preparation
|
||||
|
||||
At this preparation checkpoint RC34 **is not yet published**. [RC33](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.33) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.34` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.34`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `68a2b9d` (merge of [PR172](https://github.com/QRun-IO/qqq-frontend-next/pull/172)). Merged since RC33: [PR172](https://github.com/QRun-IO/qqq-frontend-next/pull/172) (layered error boundaries, zod payload repair, streaming can no longer lock the assistant panel).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC33). The navigation works against any 1.x server (it infers section roles when absent); QQQ **4.1.0-RC.16** (UI API 1.7.0) adds the server-declared section roles, section apps and search keywords. RC34 does not publish QQQ 4.1.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- **Fail soft** ([PR172](https://github.com/QRun-IO/qqq-frontend-next/pull/172)): layered error boundaries keep a failing widget, section or panel from taking down the page; assistant payloads are validated and repaired with zod; a stream can no longer lock the assistant panel. Pairs with qbit-assistant 0.1.0-RC.22 (fail-soft server, extension 1.5.0).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC34 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC34 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC33 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.35 — preparation
|
||||
|
||||
At this preparation checkpoint RC35 **is not yet published**. [RC34](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.34) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.35` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.35`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `ba38367` (merge of [PR164](https://github.com/QRun-IO/qqq-frontend-next/pull/164)). Merged since RC34: [PR164](https://github.com/QRun-IO/qqq-frontend-next/pull/164) (profile photo uploader and avatars everywhere, UI API 1.8.0).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC34). Profiles and avatars need QQQ **4.1.0-RC.17** (UI API 1.8.0). RC35 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **Profiles and avatars** ([PR164](https://github.com/QRun-IO/qqq-frontend-next/pull/164)): a profile photo uploader on the Profile section and user avatars shown throughout the UI.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC35 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC35 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC34 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.36 — preparation
|
||||
|
||||
At this preparation checkpoint RC36 **is not yet published**. [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) remains the latest public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.36` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.36`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `81a03ea` (merge of [PR175](https://github.com/QRun-IO/qqq-frontend-next/pull/175)). Merged since RC35: [PR175](https://github.com/QRun-IO/qqq-frontend-next/pull/175) (a11y: readable link text, 24px clear target, h2 widget titles, skip link focus).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Requires QQQ **4.1.0-RC.15** or later for the UI API 1.6.0 features (as RC35). Profiles and avatars need QQQ **4.1.0-RC.17** (UI API 1.8.0); against older servers avatars fall back to initials and the uploader stays hidden. RC36 does not publish QQQ 4.1.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- **Accessibility** ([PR175](https://github.com/QRun-IO/qqq-frontend-next/pull/175)): link text meets 4.5:1 contrast through a new derived link color, clear buttons have 24px targets, widget titles are h2 headings, and the skip link takes focus correctly.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Report-only (owner decision 2026-10-13).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC36 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC36 GitHub release after delivery.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC35 limits carry forward unchanged; see [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -1,62 +1,23 @@
|
||||
# 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).
|
||||
[RC36 preparation](1.0.0-RC.36.md) collects every change merged since [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35). RC35 is published and immutable. Changes merged after the RC36 preparation commit are listed below. 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
|
||||
## 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).
|
||||
- **UI API 1.6.0 (QQQ 4.1.0-RC.15).** Next reads the typed `clientCapabilities` of table metadata for saved views, sharing, bulk processes and audits, applies `field.validation` (required, length, inclusive and exclusive bounds, patterns) before a request, reads client-neutral `uiSettings` setting by setting with the deprecated `materialDashboard` values as fallback, and shows the process error `traceId`. The full `materialDashboardTheme` still wins over the `uiSettings.theme` subset. Error messages keep their order (`userFacingError`, `error`, then the new `message`). Audits fall back to the permission-filtered instance metadata when a table's capability is unavailable, since RC.15 marks unaudited tables unavailable although explicit audits can exist. Off-spec calls stay at 0.
|
||||
- **What's new:** a paged release history with new badges, safe links and screenshots, notification deep links, and an unread count in the user menu. Uses UI API extension `release-notes` 1.0.0; requires qbit-release-notes 0.1.0-RC.1+.
|
||||
- **Profile photo and avatars everywhere** (QQQ 4.1.0-RC.17, UI API 1.8.0 `avatars`):
|
||||
- The core **Profile** preferences section renders generically. Its photo is a `FILE_UPLOAD` field with `valueIsUploadRef`, so you can drag and drop or choose a picture, crop it square (drag, arrow keys or the zoom slider), preview it and remove it. The cropped PNG goes to `POST /uploads`, and the ref saves with the form.
|
||||
- One `UserAvatar` (photo via one batched `POST /avatars/lookup` and `GET /avatars/{avatarId}`, otherwise initials) shows people in the user menu, presence, comments, mention candidates, the people picker, activity and your own assistant messages. A missing avatar is remembered for the session, so it isn't requested again.
|
||||
- **Privacy:** branding `gravatarDefault` no longer sends your email hash to Gravatar. A Gravatar appears only if you turn it on in your Profile, and the server serves it.
|
||||
- Image fields take a full form row at every width. e2e coverage is `tests/e2e/profile-avatars.spec.ts`, including the 6-width light/dark screenshot matrix.
|
||||
|
||||
## 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)
|
||||
- **Logout no longer races an in-flight request.** Logout ends the API client's session generation first: every request still in flight (axios and the realtime and assistant streams) is aborted, and any response of the old generation that still arrives is discarded, so none can refresh the `sessionId` cookie the logout expires or change client auth state (SEC-037, SEC-048).
|
||||
|
||||
## 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.
|
||||
None yet.
|
||||
|
||||
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,22 @@
|
||||
# Jobs screenshots
|
||||
|
||||
The mocked browser spec is `tests/e2e/jobs-tray.spec.ts`. Capture the actual UI with:
|
||||
|
||||
```powershell
|
||||
$env:QQQ_E2E_SERVER='production'
|
||||
$env:QQQ_JOBS_SCREENSHOTS='docs/screenshots/jobs-tray'
|
||||
$env:QQQ_E2E_PORT='3117'
|
||||
pnpm exec playwright test tests/e2e/jobs-tray.spec.ts --workers=1 --reporter=list
|
||||
```
|
||||
|
||||
Captured 2026-10-14 from the production build (`QQQ_E2E_SERVER=production`), 1000 px tall:
|
||||
|
||||
| View | 390 light | 390 dark | 1440 light | 1440 dark |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Live job |  |  |  |  |
|
||||
| Open tray |  |  |  |  |
|
||||
| Jobs page |  |  |  |  |
|
||||
|
||||
The spec also checks horizontal overflow, popover bounds, keyboard focus restoration, axe at 1440
|
||||
light, and reduced motion; its lifecycle case runs an inline process, detaches it, watches it
|
||||
finish in the tray and reopens the finished view.
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.11",
|
||||
"version": "1.0.0-RC.36",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
@@ -21,6 +21,7 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:acceptance": "node scripts/acceptance.mjs",
|
||||
"perf:budget": "node scripts/perf-budget.mjs",
|
||||
"ui-api:check": "node scripts/check-ui-api-spec.mjs",
|
||||
"tsc": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "git config core.hooksPath .githooks || true",
|
||||
@@ -35,6 +36,8 @@
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@jam.dev/recording-links": "0.4.0",
|
||||
"@jam.dev/sdk": "1.0.1",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -54,12 +57,16 @@
|
||||
"dompurify": "^3.4.16",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.485.0",
|
||||
"next": "16.3.6",
|
||||
"rapidoc": "9.3.8",
|
||||
"next": "16.3.8",
|
||||
"rapidoc": "10.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-markdown": "10.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"remark-breaks": "4.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "15.6.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -108,7 +115,11 @@
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"@humanfs/node": ">=0.16.8",
|
||||
"@babel/core": ">=7.29.6 <8"
|
||||
}
|
||||
"@babel/core": ">=7.29.6 <8",
|
||||
"source-map-js": ">=1.2.2"
|
||||
},
|
||||
"ignoredOptionalDependencies": [
|
||||
"electron"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"$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.",
|
||||
"$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. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). 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) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Readable widget widths (feature/widget-width-readability, on PR #50): the gridMinWidth container-query ladder (about 80 @container rules) and the table-widget card layout raise shared CSS from 20.4 KB (PR #50 CI) to 21.1 KB gzip, over the 21 KB cap, so the CSS cap becomes 22 KB (about 4% headroom); JS routes are unchanged. Raise a cap only with a measured reason in the commit. QQQ Assistant panel (2026-10-12): its Tailwind utilities (panel, sheet, markdown typography) add 0.9 KB gzip to the shared CSS (21.3 KB measured against the 21 KB cap); merged with the widget width ladder the shared CSS measured 21.3 KB (2026-10-12 local export), inside the 22 KB cap; the panel's JS is budgeted under lazyChunks.assistant. Assistant panel plus SQL Workbench dark mode (#78, 2026-10-12): main with #78 measured 21.5 KB of shared CSS, and merging the assistant panel measured 22.3 KB (local export), over the 22 KB cap, so the CSS cap becomes 23 KB (about 3% headroom); JS routes and lazy chunks are unchanged. Owner decision 2026-10-12 (\"raise the limits on size as needed, but still keeping things healthy and small as possible\"): totalJsGzipKB is 1100. The total includes every on-demand feature and grows with features; first-load route caps (255/350/360/390), the 23 KB CSS cap and the lazy-chunk caps stay strict because they govern page speed. Any other cap raise still needs a measured reason, and feature PRs keep code-splitting and report their bundle delta. Resizable navigation (#79, 2026-10-12): main measured 23.07 KB of shared CSS after #79 (CI and local export), over the 23 KB cap; the RC.16 candidates (#80 record picker, #81 form validation, #82 date filters, #84 search thumbnails, #85 alerts bell) each add small amounts, so under the owner decision the CSS cap becomes 24 KB (about 4% headroom). Search preview thumbnails (2026-10-12, owner-approved total cap 1100 KB): restoring FILE_PREVIEW thumbnails, CHIP values and dates in the lazy search record preview measured total JS 1052.0 KB against main's 1042.7 KB (+9.3 KB: Turbopack re-bundles the shared file-preview, gallery, adornment and date modules into more on-demand screen chunks), with every route first load and the CSS (23.0 KB) unchanged; the total cap moves to 1100 KB, first-load and CSS caps stay. Owner decision 2026-10-13 (\"we can remove the size limits\"): the caps below are no longer enforced; \"enforce\": false makes scripts/perf-budget.mjs report sizes and over-cap warnings without failing CI. The numbers stay as reference points so growth remains visible in every build log and in test-results/performance/bundle.json.",
|
||||
"enforce": false,
|
||||
"firstLoadJsGzipKB": 350,
|
||||
"routes": {
|
||||
"/": 255,
|
||||
@@ -12,13 +13,14 @@
|
||||
"/app/_": 350,
|
||||
"/app/_/_": 350,
|
||||
"/app/_/_/copy": 390,
|
||||
"/app/_/_/dev": 360,
|
||||
"/app/_/_/edit": 350,
|
||||
"/app/_/dev": 360,
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350,
|
||||
"/app/_/_/dev": 355
|
||||
"/app/_/savedView/_": 350
|
||||
},
|
||||
"cssGzipKB": 21,
|
||||
"totalJsGzipKB": 1050,
|
||||
"cssGzipKB": 24,
|
||||
"totalJsGzipKB": 1100,
|
||||
"largestChunkGzipKB": 100,
|
||||
"lazyChunks": {
|
||||
"rapidoc": {
|
||||
@@ -31,10 +33,40 @@
|
||||
"marker": "qqq-sql-workbench-editor",
|
||||
"gzipKB": 135
|
||||
},
|
||||
"recordPicker": {
|
||||
"$comment": "The record picker dialog (RecordPickerDialog: search, filters, thumbnail grid and list, preview, upload) for fields whose possible value source declares a picker, loaded when Browse/Change is clicked: import() in PossibleValueSelect. Turbopack emits one variant of the async chunk per parent context that bundles PossibleValueSelect (record forms, process forms, bulk-load mapping), so the budget covers all variants; a session loads one (about 5.9 KB gzip, with the dialog-only record-picker-query helpers). Measured 17.0 KB gzip across the three variants at introduction; budget about 6% above. The inline field (thumbnails, Browse/Preview/View) adds 2.5 KB to total JS (main 1005.4 KB, this branch 1007.9 KB on 2026-10-12) and nothing to any route first load or the shared CSS.",
|
||||
"marker": "qqq-record-picker-grid",
|
||||
"gzipKB": 18
|
||||
},
|
||||
"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
|
||||
},
|
||||
"assistant": {
|
||||
"$comment": "The QQQ Assistant panel (qbit-assistant): chat UI, SSE streaming client, react-markdown + rehype-sanitize, loaded on first open through import() in AssistantMount; the shell keeps only the /assistant/config probe, header button and Ctrl/⌘+J (about 1.7 KB first load). Measured 50.8 KB gzip at introduction; budget about 8% above.",
|
||||
"marker": "assistant-composer-form",
|
||||
"gzipKB": 55
|
||||
},
|
||||
"searchPage": {
|
||||
"$comment": "The search results page (/app/search: filters, sort, result cards, split preview pane, side sheet), loaded on demand: import() in app/search/page.tsx. Measured 13.4 KB gzip at introduction (QRun-IO/qqq-frontend-next#59); budget about 8% above.",
|
||||
"marker": "search-result-card",
|
||||
"gzipKB": 14.5
|
||||
},
|
||||
"commandRecordResults": {
|
||||
"$comment": "The command palette's record groups, loaded when the palette searches records: import() in CommandMenu. Measured 7.5 KB gzip at introduction (#59); budget about 8% above.",
|
||||
"marker": "command-menu-searching",
|
||||
"gzipKB": 8.1
|
||||
},
|
||||
"notificationCenter": {
|
||||
"$comment": "The notification center (qbit-alerts phase b): the bell popover (import() in AlertsBell on first open), the sticky-notification banners (import() in NotificationBannerSlot when the summary has banners) and the /app/notifications page (next/dynamic in its route). Turbopack bundles the shared parts (severity icons, per-item actions, the optimistic inbox mutation, MarkdownView) into each of the three async chunks, so the budget covers all three; the marker is the per-item read/unread button they share. Measured 15.2 KB gzip across the three (popover 4.3, banners 4.3, page 6.7) on 2026-10-13; budget about 8% above. The shell keeps the badge, poller with backoff, summary normalizer and banner slot: +0.7 KB first load on the /app routes (/app/_/_ 348.5 -> 349.2 KB, inside its 350 KB cap); the new /app/notifications route measures 339.5 KB; total JS 1092.9 -> 1098.0 KB excluding these chunks; CSS unchanged at 23.2 KB.",
|
||||
"marker": "notification-toggle-read",
|
||||
"gzipKB": 16.5
|
||||
},
|
||||
"recordPreview": {
|
||||
"$comment": "The search result record preview (first-tier fields with file thumbnails, chips and dates; quick actions), loaded when a preview first shows: import() in SearchPage. It runs its own two queries (the useTableMetaData and useRecord keys) rather than importing those hooks, which measured +12.4 KB more total JS. Measured 7.1 KB gzip with thumbnails and chips (3.7 KB without); budget about 8% above.",
|
||||
"marker": "search-record-preview",
|
||||
"gzipKB": 7.7
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
// The acceptance sample does not install qbit-collaboration. Exercise the real browser
|
||||
// MSW handlers instead, retaining all five acceptance device profiles and zero retries.
|
||||
// The main e2e suite answers API calls with page.route mocks and never starts MSW, so this
|
||||
// spec needs its own NEXT_PUBLIC_MOCK_API=true server.
|
||||
// localhost, not 127.0.0.1: `next dev` serves dev resources only to its own host name and
|
||||
// blocks them for 127.0.0.1, so the client never hydrates and MSW never starts.
|
||||
const port = Number(process.env.QQQ_E2E_PORT || 3107)
|
||||
const baseURL = `http://localhost:${port}`
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: ['collaboration-favorites.spec.ts', 'collaboration-record-panel.spec.ts', 'collaboration-presence-share-tasks.spec.ts'],
|
||||
outputDir: './test-results/collaboration/artifacts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [['list'], ['json', { outputFile: 'test-results/collaboration/report.json' }]],
|
||||
use: {
|
||||
baseURL,
|
||||
serviceWorkers: 'allow',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
|
||||
{ name: 'tablet', use: { ...devices['iPad (gen 7)'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: `pnpm dev --port ${port}`,
|
||||
url: baseURL,
|
||||
env: { NEXT_PUBLIC_MOCK_API: 'true' },
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
})
|
||||
@@ -11,6 +11,8 @@ const production = process.env.QQQ_E2E_SERVER === 'production'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
// This feature needs MSW and the full browser matrix; use playwright.collaboration.config.ts.
|
||||
testIgnore: 'collaboration-*.spec.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
// No retries: a test that needs one is flaky and must be fixed, not re-run.
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.11</revision>
|
||||
<revision>1.0.0-RC.36</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
|
||||
@@ -25,6 +25,17 @@ const report = JSON.parse(readFileSync('test-results/acceptance/report.json', 'u
|
||||
const rows = new Map(matrix.rows.map((row) => [row.id, row]))
|
||||
const results = new Map()
|
||||
const problems = []
|
||||
const quarantine = JSON.parse(readFileSync('tests/acceptance/quarantine.json', 'utf8'))
|
||||
const quarantined = new Set()
|
||||
for (const entry of quarantine) {
|
||||
if (!rows.has(entry.id)) problems.push(`Unknown quarantine ID ${entry.id}`)
|
||||
if (typeof entry.issue !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/.test(entry.issue)) {
|
||||
problems.push(`${entry.id} quarantine entry needs an issue link`)
|
||||
}
|
||||
if (quarantined.has(entry.id)) problems.push(`Duplicate quarantine ID ${entry.id}`)
|
||||
quarantined.add(entry.id)
|
||||
}
|
||||
for (const error of report.errors ?? []) problems.push(`Playwright error: ${error.message ?? JSON.stringify(error)}`)
|
||||
const byProject = Object.fromEntries((report.config?.projects ?? []).map((project) => [project.name, { passed: 0, failed: 0, skipped: 0, flaky: 0 }]))
|
||||
|
||||
function walk(suite, titles = []) {
|
||||
@@ -50,8 +61,12 @@ function walk(suite, titles = []) {
|
||||
}
|
||||
for (const suite of report.suites ?? []) walk(suite)
|
||||
|
||||
const summary = { passed: 0, failed: 0, missing: 0, excluded: 0 }
|
||||
const summary = { passed: 0, failed: 0, missing: 0, excluded: 0, quarantined: 0 }
|
||||
for (const row of matrix.rows) {
|
||||
if (quarantined.has(row.id)) {
|
||||
summary.quarantined++
|
||||
continue
|
||||
}
|
||||
if (row.required === false) {
|
||||
summary.excluded++
|
||||
if (!row.approval) problems.push(`${row.id} is excluded without a recorded approval`)
|
||||
@@ -76,7 +91,7 @@ for (const row of matrix.rows) {
|
||||
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.required === false || quarantined.has(row.id)) 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++
|
||||
@@ -97,8 +112,8 @@ if (!partial) for (const [project, counts] of Object.entries(byProject)) {
|
||||
if (Object.values(counts).every((count) => count === 0)) problems.push(`Project ${project} ran no tests`)
|
||||
}
|
||||
|
||||
const status = { partial, generatedAt: new Date().toISOString(), summary, byProject, problems,
|
||||
rows: matrix.rows.map((row) => ({ id: row.id, results: results.get(row.id) ?? [] })) }
|
||||
const status = { partial, generatedAt: new Date().toISOString(), summary, byProject, problems, quarantine,
|
||||
rows: matrix.rows.map((row) => ({ id: row.id, quarantined: quarantined.has(row.id), results: results.get(row.id) ?? [] })) }
|
||||
writeFileSync('test-results/acceptance/gate.json', JSON.stringify(status, null, 2) + '\n')
|
||||
console.log(`Acceptance gate${partial ? ' (partial run, not an acceptance result)' : ''}:`, JSON.stringify(summary), JSON.stringify(byProject))
|
||||
for (const problem of problems) console.log(` FAIL ${problem}`)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export const AREAS = ['esb', 'help', 'navigation', 'performance', 'processes', 'query', 'records', 'security', 'widgets']
|
||||
export const SMOKE = [
|
||||
'navigation/landing.spec.ts',
|
||||
'navigation/navigation.spec.ts',
|
||||
'records/crud.spec.ts',
|
||||
'query/query-grid.spec.ts',
|
||||
'widgets/sample-dashboard.spec.ts',
|
||||
].map((file) => `tests/acceptance/specs/${file}`)
|
||||
|
||||
const sourceAreas = [
|
||||
[/help|commandmenu/, 'help'],
|
||||
[/widget|dashboard|report|chart|pivot/, 'widgets'],
|
||||
[/query|grid|table|filter|search/, 'query'],
|
||||
[/record|form|field|input|validation|attachment|preview/, 'records'],
|
||||
[/process|bulk/, 'processes'],
|
||||
[/navigation|\bnav\b|sidebar|theme|branding|layout/, 'navigation'],
|
||||
[/security|auth|login|session|permission/, 'security'],
|
||||
[/esb/, 'esb'],
|
||||
[/performance/, 'performance'],
|
||||
]
|
||||
|
||||
/** Unknown production/configuration paths expand coverage instead of silently losing it. */
|
||||
export function selectSpecs(paths) {
|
||||
const selected = new Set()
|
||||
for (const raw of paths) {
|
||||
const file = raw.replaceAll('\\', '/')
|
||||
if (file.startsWith('docs/') || /\.md$/i.test(file)) continue
|
||||
const specArea = file.match(/^tests\/acceptance\/specs\/([^/]+)\//)?.[1]
|
||||
if (specArea && AREAS.includes(specArea)) {
|
||||
selected.add(specArea)
|
||||
continue
|
||||
}
|
||||
if (/^(package\.json|pnpm-lock\.yaml|next\.config\.[^/]+)$/.test(file)
|
||||
|| /^tests\/acceptance\/(playwright\.config\.|support\/|fixture\/|matrix\/|quarantine\.json)/.test(file)
|
||||
|| /^scripts\/acceptance/.test(file)
|
||||
|| file === '.github/workflows/acceptance.yml'
|
||||
|| /^src\/lib\/(api\/|core\/|client|query-client|context\/)/.test(file)) return allSpecs()
|
||||
if (file.startsWith('src/')) {
|
||||
const matches = sourceAreas.filter(([pattern]) => pattern.test(file.toLowerCase()))
|
||||
if (!matches.length) return allSpecs()
|
||||
for (const [, area] of matches) selected.add(area)
|
||||
continue
|
||||
}
|
||||
// Unit/mocked tests don't change the real backend; smoke still runs.
|
||||
if (/^(tests\/|\.storybook\/)/.test(file)) continue
|
||||
return allSpecs()
|
||||
}
|
||||
return [...AREAS.filter((area) => selected.has(area)).map((area) => `tests/acceptance/specs/${area}`),
|
||||
...SMOKE.filter((file) => !selected.has(file.split('/')[3]))]
|
||||
}
|
||||
|
||||
/** Directory filters include new specs automatically. */
|
||||
function allSpecs() {
|
||||
return AREAS.map((area) => `tests/acceptance/specs/${area}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded matrix; each cell owns one browser engine and one backend.
|
||||
* @param {{ eventName: string, labels?: string[], paths?: string[] }} input
|
||||
*/
|
||||
export function planAcceptance({ eventName, labels = [], paths = [] }) {
|
||||
const full = eventName !== 'pull_request' || labels.includes('full-acceptance')
|
||||
const counts = full ? { chromium: 3, firefox: 3, webkit: 4, mobile: 4, tablet: 4 } : { chromium: 2, mobile: 2 }
|
||||
const include = Object.entries(counts).flatMap(([project, total]) => Array.from({ length: total }, (_, index) => ({
|
||||
project, browser: project === 'mobile' ? 'chromium' : project === 'tablet' ? 'webkit' : project,
|
||||
shard: index + 1, total,
|
||||
})))
|
||||
return { mode: full ? 'full' : 'pr', matrix: { include }, specs: full ? [] : selectSpecs(paths) }
|
||||
}
|
||||
|
||||
// Diff both sides of renames, from the PR merge-base (not the current main tip).
|
||||
// Event data is passed as argv, never interpolated into a shell command.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
|
||||
const eventName = process.env.GITHUB_EVENT_NAME
|
||||
const labels = (event.pull_request?.labels ?? []).map((label) => label.name)
|
||||
const paths = eventName === 'pull_request' && !labels.includes('full-acceptance')
|
||||
? execFileSync('git', ['diff', '--name-only', '--no-renames', '-z', `${event.pull_request.base.sha}...${event.pull_request.head.sha}`, '--'], { encoding: 'utf8' }).split('\0').filter(Boolean)
|
||||
: []
|
||||
const plan = planAcceptance({ eventName, labels, paths })
|
||||
for (const [key, value] of Object.entries(plan)) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}\n`)
|
||||
}
|
||||
console.log(JSON.stringify(plan, null, 2))
|
||||
}
|
||||
@@ -16,7 +16,11 @@ import { stageStandalone } from './acceptance-standalone.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const skipBuild = args.includes('--skip-build')
|
||||
const passthrough = args.filter((arg) => arg !== '--skip-build')
|
||||
const selectedSpecs = JSON.parse(process.env.QQQ_ACCEPTANCE_SPEC_PATHS ?? '[]')
|
||||
if (!Array.isArray(selectedSpecs) || selectedSpecs.some((spec) => typeof spec !== 'string' || !spec.startsWith('tests/acceptance/specs/'))) {
|
||||
throw new Error('QQQ_ACCEPTANCE_SPEC_PATHS must be a JSON array of acceptance spec paths')
|
||||
}
|
||||
const passthrough = [...selectedSpecs, ...args.filter((arg) => arg !== '--skip-build')]
|
||||
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
|
||||
resolveSampleJar()
|
||||
|
||||
@@ -50,6 +54,8 @@ if (!skipBuild && ACCEPTANCE_MODE === 'standalone') {
|
||||
writeFileSync(BUILD_MARKER, backend + '\n')
|
||||
}
|
||||
const tests = run('pnpm', ['exec', 'playwright', 'test', '-c', 'tests/acceptance/playwright.config.ts', ...passthrough])
|
||||
const filtered = passthrough.some((arg) => !arg.startsWith('--project'))
|
||||
// CI shards emit blobs only. The single gate evaluates the merged evidence.
|
||||
if (process.env.QQQ_ACCEPTANCE_BLOB === '1') process.exit(tests)
|
||||
const filtered = process.env.QQQ_ACCEPTANCE_QUARANTINE === 'only' || passthrough.some((arg) => !arg.startsWith('--project'))
|
||||
const gate = run('node', ['scripts/acceptance-gate.mjs', ...(filtered ? ['--partial'] : [])])
|
||||
process.exit(tests || gate)
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
// Checks the UI API document bundled with this app (src/lib/api/openapi/qqq-v1.json) against the
|
||||
// server's: the committed openapi.yaml of the QQQ checkout being tested, or a running server.
|
||||
//
|
||||
// node scripts/check-ui-api-spec.mjs --server-spec qqq-backend/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml
|
||||
// node scripts/check-ui-api-spec.mjs --server-url http://127.0.0.1:18765
|
||||
//
|
||||
// Fails when the majors differ, when the bundled document is newer than the server's, or when the
|
||||
// bundled document has an operation the server lacks. A server that is newer within the major is
|
||||
// fine (it only adds), and is reported so the document can be re-vendored.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']
|
||||
// operations the app works without (it checks for them), so an older server may lack them
|
||||
const OPTIONAL_OPERATIONS = new Set(['GET /qqq/v1/version', 'GET /qqq/v1/help/search', 'GET /qqq/v1/help/coverage'])
|
||||
const bundled = JSON.parse(readFileSync(path.resolve('src/lib/api/openapi/qqq-v1.json'), 'utf-8'))
|
||||
|
||||
/** @param {string} version @returns {number[] | null} */
|
||||
function semver(version) {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '').trim())
|
||||
return match ? match.slice(1, 4).map(Number) : null
|
||||
}
|
||||
|
||||
/** @param {number[]} a @param {number[]} b @returns {number} */
|
||||
function compare(a, b) {
|
||||
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
|
||||
}
|
||||
|
||||
/** Operations of the bundled document as `METHOD /path`. */
|
||||
function bundledOperations() {
|
||||
return Object.entries(bundled.paths).flatMap(([p, ops]) => Object.keys(ops).filter((m) => HTTP_METHODS.includes(m)).map((m) => `${m.toUpperCase()} ${p}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads version and operations from a committed openapi.yaml (sorted, as PublishAPI writes it)
|
||||
* without a YAML parser: `info.version`, and the `/qqq/v1/...` keys and their method keys.
|
||||
* @param {string} yaml
|
||||
*/
|
||||
function readServerYaml(yaml) {
|
||||
const lines = yaml.split(/\r?\n/)
|
||||
let version = null
|
||||
const operations = []
|
||||
let inPaths = false
|
||||
let currentPath = null
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === 'info:') {
|
||||
for (let j = i + 1; j < lines.length && lines[j].startsWith(' '); j++) {
|
||||
const v = /^ {2}version: "?([^"]+)"?$/.exec(lines[j])
|
||||
if (v) version = v[1]
|
||||
}
|
||||
}
|
||||
if (/^\S/.test(line)) inPaths = line === 'paths:'
|
||||
if (!inPaths) continue
|
||||
const pathMatch = /^ {2}"?(\/[^"]+)"?:$/.exec(line)
|
||||
if (pathMatch) currentPath = pathMatch[1]
|
||||
const methodMatch = /^ {4}(get|post|put|patch|delete):$/.exec(line)
|
||||
if (methodMatch && currentPath) operations.push(`${methodMatch[1].toUpperCase()} ${currentPath}`)
|
||||
}
|
||||
return { version, operations }
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const option = (name) => {
|
||||
const index = args.indexOf(name)
|
||||
return index >= 0 ? args[index + 1] : undefined
|
||||
}
|
||||
|
||||
let server
|
||||
if (option('--server-spec')) {
|
||||
server = readServerYaml(readFileSync(option('--server-spec'), 'utf-8'))
|
||||
} else if (option('--server-url')) {
|
||||
const base = option('--server-url').replace(/\/+$/, '')
|
||||
const response = await fetch(`${base}/qqq/v1/openapi.json`)
|
||||
if (!response.ok) throw new Error(`GET ${base}/qqq/v1/openapi.json: ${response.status}`)
|
||||
const document = await response.json()
|
||||
server = {
|
||||
version: document.info?.version,
|
||||
operations: Object.entries(document.paths ?? {}).flatMap(([p, ops]) => Object.keys(ops).filter((m) => HTTP_METHODS.includes(m)).map((m) => `${m.toUpperCase()} ${p}`)),
|
||||
}
|
||||
} else {
|
||||
console.error('Usage: check-ui-api-spec.mjs --server-spec <openapi.yaml> | --server-url <base url>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// servers before semver (QQQ 4.1.0-RC.5 and earlier) publish "v1": the 1.0.0 surface
|
||||
const serverVersion = semver(server.version) ?? (server.version === 'v1' ? [1, 0, 0] : null)
|
||||
const bundledVersion = semver(bundled.info.version)
|
||||
const problems = []
|
||||
if (!serverVersion || !bundledVersion) problems.push(`unrecognised versions: bundled ${bundled.info.version}, server ${server.version}`)
|
||||
else {
|
||||
if (serverVersion[0] !== bundledVersion[0]) problems.push(`major mismatch: bundled ${bundled.info.version}, server ${server.version}`)
|
||||
else if (compare(bundledVersion, serverVersion) > 0) {
|
||||
const missing = bundledOperations().filter((op) => !server.operations.includes(op) && !OPTIONAL_OPERATIONS.has(op))
|
||||
if (missing.length) problems.push(`bundled document ${bundled.info.version} is newer than the server's ${server.version} and has operations the server lacks: ${missing.join(', ')}`)
|
||||
else console.log(`Bundled UI API ${bundled.info.version} is newer than the server's ${server.version}; every bundled operation exists on the server.`)
|
||||
} else if (compare(bundledVersion, serverVersion) < 0) {
|
||||
console.log(`Server UI API ${server.version} is newer than the bundled ${bundled.info.version}: re-vendor src/lib/api/openapi/qqq-v1.json to use its additions.`)
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
for (const problem of problems) console.error(`UI API spec check: ${problem}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`UI API spec check passed: bundled ${bundled.info.version}, server ${server.version}.`)
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 QRun.IO, Inc.
|
||||
# Licensed under the Apache License, Version 2.0.
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 || ! $1 =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo 'Usage: retry.sh <attempts> <command...>' >&2
|
||||
exit 2
|
||||
fi
|
||||
attempts=$1
|
||||
shift
|
||||
delays=(15 45 120)
|
||||
for ((attempt = 1; attempt <= attempts; attempt++)); do
|
||||
# stderr keeps command substitution (e.g. Maven version output) clean.
|
||||
echo "Attempt $attempt/$attempts" >&2
|
||||
if "$@"; then
|
||||
exit 0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
if ((attempt < attempts)); then
|
||||
index=$((attempt - 1))
|
||||
if ((index > 2)); then index=2; fi
|
||||
sleep "${delays[$index]}"
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
// Vendor the qbit YAML as lossless JSON, like the other extension documents, then run
|
||||
// node scripts/generate-edi-types.mjs. No runtime schema or generator dependency ships.
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const document = JSON.parse(readFileSync(new URL('../src/lib/api/openapi/extensions/edi.json', import.meta.url), 'utf8'))
|
||||
const header = readFileSync(new URL('../src/lib/api/tables.ts', import.meta.url), 'utf8').split('/**')[0]
|
||||
function typeOf(schema) {
|
||||
let type = schema.$ref ? schema.$ref.split('/').at(-1)
|
||||
: schema.enum ? schema.enum.map(JSON.stringify).join(' | ')
|
||||
: schema.type === 'array' ? `Array<${typeOf(schema.items)}>`
|
||||
: schema.type === 'object' ? schema.properties
|
||||
? `{\n${Object.entries(schema.properties).map(([name, value]) => ` ${name}${schema.required?.includes(name) ? '' : '?'}: ${typeOf(value)}`).join('\n')}\n}`
|
||||
: `Record<string, ${typeof schema.additionalProperties === 'object' ? typeOf(schema.additionalProperties) : 'unknown'}>`
|
||||
: ['integer', 'number'].includes(schema.type) ? 'number'
|
||||
: ['string', 'boolean'].includes(schema.type) ? schema.type : 'unknown'
|
||||
return type + (schema.nullable && type !== 'unknown' ? ' | null' : '')
|
||||
}
|
||||
writeFileSync(new URL('../src/types/edi.ts', import.meta.url), header + '/** @file Generated from the vendored edi.json. Run scripts/generate-edi-types.mjs; do not edit. */\n\n' + Object.entries(document.components.schemas).map(([name, schema]) => `/** ${schema.description ?? name} */\nexport type ${name} = ${typeOf(schema)}\n`).join('\n'))
|
||||
@@ -10,6 +10,9 @@
|
||||
// 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
|
||||
//
|
||||
// With "enforce": false in perf-budget.json the script still measures and reports every size and
|
||||
// every cap it is over, but exits 0: the caps are reference points, not gates.
|
||||
//
|
||||
// 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.
|
||||
@@ -147,9 +150,18 @@ 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- ')}`)
|
||||
// Structural problems (a lazy chunk on a first load, or a marker no chunk contains) always fail.
|
||||
// Size problems fail only while perf-budget.json enforces the caps ("enforce": false makes them warnings).
|
||||
const structural = problems.filter((p) => p.includes(': no chunk contains its marker') || p.includes('is loaded on first load of a route'))
|
||||
const sizeProblems = problems.filter((p) => !structural.includes(p))
|
||||
const enforce = budget.enforce !== false
|
||||
if (sizeProblems.length && !enforce) {
|
||||
console.warn(`\nPerformance budget (report-only, "enforce": false): over the reference sizes (${sizeProblems.length}):\n- ${sizeProblems.join('\n- ')}`)
|
||||
}
|
||||
const failures = enforce ? problems : structural
|
||||
if (failures.length) {
|
||||
console.error(`\nPerformance budget exceeded (${failures.length}):\n- ${failures.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')
|
||||
console.log(enforce ? '\nPerformance budget: OK' : '\nPerformance budget: OK (report-only)')
|
||||
@@ -22,6 +22,7 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
@@ -32,6 +33,10 @@ import { launchTableName } from '@/lib/utils/process-utils'
|
||||
import { NotFoundState } from '@/components/layout/NotFoundState'
|
||||
import { RouteRedirect } from '@/components/layout/RouteRedirect'
|
||||
|
||||
const EdiMapperPage = dynamic(() => import('@/components/edi-mapper/EdiMapperPage'), {
|
||||
loading: () => <p role="status">Loading mapper…</p>,
|
||||
})
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -42,6 +47,8 @@ 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 (slug === 'edi_map' && action === 'mapper') return <EdiMapperPage key={recordId} mapId={Number(recordId)} />
|
||||
|
||||
if (isError) {
|
||||
return <div role="alert" className="py-12 text-center text-destructive">Failed to load application metadata.</div>
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the record developer view (raw values and associated scripts)
|
||||
// Tests for the record developer view (header parity, sections, API access, provider panels, raw values and associated scripts)
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@@ -52,9 +52,19 @@ vi.mock('@/lib/api/developer', () => ({
|
||||
storeRecordAssociatedScript: vi.fn(),
|
||||
storeScriptRevision: vi.fn(),
|
||||
testScript: vi.fn(),
|
||||
getTableApis: vi.fn(async () => []),
|
||||
getApiVersions: vi.fn(),
|
||||
getApiTableSpec: vi.fn(),
|
||||
apiTableSpecUrl: () => '/qqq/v1/table/person/apis/siteApi/v1/openapi',
|
||||
apiMiddlewareBaseURL: () => '',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/developer-panels', () => ({
|
||||
getRecordDeveloperPanels: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
import { getRecordDeveloperData } from '@/lib/api/developer'
|
||||
import { getRecordDeveloperPanels, type DeveloperPanel } from '@/lib/api/developer-panels'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
|
||||
const record = { tableName: 'person', recordLabel: 'Ada Lovelace', values: { id: 1, firstName: 'Ada', email: null } }
|
||||
@@ -119,28 +129,28 @@ describe('RecordDeveloperViewPage', () => {
|
||||
serveInstance(['storeScriptRevision', 'testScript'])
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' }, { timeout: 5000 })).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
|
||||
})
|
||||
}, { timeout: 5000 })
|
||||
expect(within(card).getByRole('heading', { name: 'First Name' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).not.toBeNull())
|
||||
await waitFor(() => expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).not.toBeNull(), { timeout: 5000 })
|
||||
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')
|
||||
})
|
||||
}, 15_000) // three waits of up to 5 s each: the 5 s default could expire before they do
|
||||
|
||||
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()
|
||||
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' }, { timeout: 5000 })).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()
|
||||
const row = document.querySelector('[data-qqq-id="record-dev-field-table"] [data-qqq-id="dev-field-firstName"]') as HTMLElement
|
||||
expect(row.querySelectorAll('td')[3]).toHaveTextContent('101')
|
||||
expect(getRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -155,7 +165,7 @@ describe('RecordDeveloperViewPage', () => {
|
||||
const element = document.querySelector('[data-qqq-id="associated-script-firstName"]')
|
||||
if (!element) throw new Error('no card')
|
||||
return element as HTMLElement
|
||||
})
|
||||
}, { timeout: 5000 })
|
||||
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()
|
||||
})
|
||||
@@ -165,7 +175,90 @@ describe('RecordDeveloperViewPage', () => {
|
||||
vi.mocked(getRecordDeveloperData).mockRejectedValue(new Error('Permission denied.'))
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Failed to load record: Permission denied.')).toHaveAttribute('role', 'alert')
|
||||
expect(await screen.findByText('Failed to load record: Permission denied.', {}, { timeout: 5000 })).toHaveAttribute('role', 'alert')
|
||||
expect(screen.queryByRole('heading', { name: 'Record Raw Values as JSON' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has the record screen header: back link, record title, actions and audit (no view-mode toggle)', async () => {
|
||||
serveInstance([])
|
||||
renderPage()
|
||||
|
||||
const back = await screen.findByRole('link', { name: 'Back to Ada Lovelace' }, { timeout: 5000 })
|
||||
expect(back).toHaveAttribute('href', '/app/person/1')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Ada Lovelace' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('radiogroup', { name: 'View mode' })).not.toBeInTheDocument()
|
||||
expect(document.querySelector('[data-qqq-id="record-view-desktop-actions"]')).not.toBeNull()
|
||||
expect(screen.getByText(/Developer view/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates sections from a sticky section nav', async () => {
|
||||
serveInstance([])
|
||||
renderPage()
|
||||
|
||||
const nav = await screen.findByRole('navigation', { name: 'Developer view sections' }, { timeout: 5000 })
|
||||
await waitFor(() => expect(within(nav).getAllByRole('link').map((link) => link.textContent)).toEqual(
|
||||
expect.arrayContaining(['Fields', 'API', 'Scripts', 'JSON', 'Table Metadata'])))
|
||||
expect(screen.getByRole('heading', { name: 'API Access' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists the v1 operations the user may perform, with curl examples using placeholder credentials', async () => {
|
||||
serveInstance([])
|
||||
renderPage()
|
||||
|
||||
const api = await waitFor(() => {
|
||||
const element = document.querySelector('[data-qqq-id="dev-api-access"]')
|
||||
if (!element) throw new Error('no api section')
|
||||
return element as HTMLElement
|
||||
})
|
||||
await waitFor(() => expect(within(api).getByText('Get this record')).toBeInTheDocument())
|
||||
for (const label of ['Query by primary key', 'Count matching records', 'Insert a copy', 'Update this record', 'Delete this record']) {
|
||||
expect(within(api).getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
const snippet = api.querySelector('[data-qqq-id="dev-api-snippet-get"]') as HTMLElement
|
||||
expect(snippet.textContent).toContain('curl -X GET')
|
||||
expect(snippet.textContent).toContain('/qqq/v1/table/person/1')
|
||||
expect(snippet.textContent).toContain('sessionUUID=<your-session-uuid>')
|
||||
})
|
||||
|
||||
it('renders no provider panels when none are available (qbits absent)', async () => {
|
||||
serveInstance([])
|
||||
renderPage()
|
||||
|
||||
await screen.findByRole('heading', { name: 'API Access' }, { timeout: 5000 })
|
||||
await waitFor(() => expect(getRecordDeveloperPanels).toHaveBeenCalledWith('person', '1'))
|
||||
expect(document.querySelector('[data-qqq-id^="dev-section-panel-"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders each provider panel (ESB, quick search, SQL workbench, assistant) with a nav entry', async () => {
|
||||
const panels: DeveloperPanel[] = [
|
||||
{ id: 'esb', title: 'ESB Events', order: 300, statusLabel: 'Publishing', statusTone: 'SUCCESS', blocks: [
|
||||
{ type: 'KEY_VALUES', items: [{ label: 'type', value: 'qqq.table.person.updated', monospace: true }] },
|
||||
{ type: 'CODE', label: 'Sample event', language: 'json', code: '{"subject":"1"}' },
|
||||
] },
|
||||
{ id: 'quickSearch', title: 'Quick Search', order: 400, statusLabel: 'Indexed', blocks: [
|
||||
{ type: 'ACTIONS', actions: [{ label: 'Reindex this record', processName: 'quickSearchReindexRecords_person' }] },
|
||||
] },
|
||||
{ id: 'sqlWorkbench', title: 'SQL Workbench', order: 450, blocks: [
|
||||
{ type: 'LINKS', links: [{ label: 'Open in SQL Workbench', href: '/app/sqlWorkbench?sql=SELECT+1' }, { label: 'Bad', href: 'javascript:alert(1)' }] },
|
||||
] },
|
||||
{ id: 'assistant', title: 'Assistant', order: 600, error: 'This panel could not be built.', blocks: [] },
|
||||
]
|
||||
vi.mocked(getRecordDeveloperPanels).mockResolvedValue(panels)
|
||||
serveInstance([])
|
||||
renderPage()
|
||||
|
||||
const nav = await screen.findByRole('navigation', { name: 'Developer view sections' }, { timeout: 5000 })
|
||||
await waitFor(() => expect(within(nav).getByRole('link', { name: 'ESB Events' })).toBeInTheDocument())
|
||||
for (const title of ['Quick Search', 'SQL Workbench', 'Assistant']) {
|
||||
expect(within(nav).getByRole('link', { name: title })).toBeInTheDocument()
|
||||
expect(within(nav).getByRole('option', { name: title })).toBeInTheDocument()
|
||||
}
|
||||
|
||||
expect(screen.getByRole('heading', { name: /ESB Events/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('qqq.table.person.updated')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Reindex this record/ }).getAttribute('href')).toContain('/app/quickSearchReindexRecords_person')
|
||||
expect(screen.getByRole('link', { name: 'Open in SQL Workbench' })).toHaveAttribute('href', '/app/sqlWorkbench?sql=SELECT+1')
|
||||
expect(screen.queryByRole('link', { name: 'Bad' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('This panel could not be built.')).toHaveAttribute('role', 'alert')
|
||||
})
|
||||
})
|
||||
@@ -15,232 +15,26 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RecordDeveloperView page — raw record values and table metadata as JSON, plus the
|
||||
* record's associated scripts (versions, code, editing, logs, testing, docs).
|
||||
* @file RecordDeveloperView page — serves `/app/[slug]/[recordId]/dev`. The view (record header,
|
||||
* fields, API access, developer panels, relations, audit, scripts, JSON and table metadata) is
|
||||
* loaded on demand, as the record view is, so the route's first-load JavaScript stays small.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import React, { lazy, Suspense } from 'react'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { SCRIPT_LOG_TABLE, getRecordDeveloperData } from '@/lib/api/developer'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
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'
|
||||
const RecordDeveloperView = lazy(() => import('@/components/developer/RecordDeveloperView').then((module) => ({ default: module.RecordDeveloperView })))
|
||||
|
||||
/**
|
||||
* Renders a developer debug view for a single record, showing all field values
|
||||
* in a table and the raw record and table metadata as collapsible JSON blocks.
|
||||
* Renders the record developer view once its code has loaded.
|
||||
*
|
||||
* @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 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
|
||||
* @returns The view, with a loading status meanwhile.
|
||||
*/
|
||||
export default function RecordDeveloperViewPage() {
|
||||
const params = useRouteParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: metaData, isLoading: metaLoading } = useQuery({
|
||||
queryKey: queryKeys.tableMetadata(slug),
|
||||
queryFn: () => loadTableMetaData(slug),
|
||||
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}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Record Developer View:{' '}
|
||||
<span className="font-mono text-primary">{slug}</span>
|
||||
{' '}
|
||||
<span className="font-mono text-muted-foreground">#{recordId}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordError && (
|
||||
<div
|
||||
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
Failed to load record: {getErrorMessage(recordError)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && record && (
|
||||
<div className="space-y-4">
|
||||
{/* Field values summary */}
|
||||
{metaData && (
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="min-w-full text-sm" data-qqq-id="record-dev-field-table">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Field
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(record.values).map(([fieldName, value]) => {
|
||||
const field = metaData.fields[fieldName]
|
||||
return (
|
||||
<tr
|
||||
key={fieldName}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs text-primary">
|
||||
{fieldName}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">
|
||||
{field?.type ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-foreground">
|
||||
{value === null || value === undefined
|
||||
? <span className="text-muted-foreground italic">null</span>
|
||||
: String(value)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JsonBlock label="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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible card that renders a JSON value as a formatted `<pre>` block.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.label - Heading displayed in the toggle button.
|
||||
* @param props.value - The value to serialize and display as JSON.
|
||||
* @param props.defaultOpen - Whether the panel starts expanded. Defaults to `true`.
|
||||
* @returns A collapsible JSON inspector card.
|
||||
*/
|
||||
function JsonBlock({
|
||||
label,
|
||||
value,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
label: string
|
||||
value: unknown
|
||||
defaultOpen?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<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"
|
||||
data-qqq-id="json-output"
|
||||
>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<Suspense fallback={<div role="status" aria-label="Loading developer view" className="py-12 text-center">Loading developer view…</div>}>
|
||||
<RecordDeveloperView />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { deserializeFilter } from '@/lib/utils/filter-utils'
|
||||
import apiClient from '@/lib/api/client'
|
||||
@@ -61,6 +61,12 @@ function renderPage() {
|
||||
}
|
||||
|
||||
describe('RecordViewPage independent association loading', () => {
|
||||
beforeAll(async () => {
|
||||
// Keep cold compilation of the lazy RecordView outside the first assertion's deadline,
|
||||
// so any case passes alone (-t) and under load.
|
||||
await import('@/components/records/RecordView')
|
||||
}, 30_000)
|
||||
|
||||
beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); navigation.search = 'tab=related' })
|
||||
|
||||
it('keeps the base and child Add, and requests no expansion, when the child table is listed without read permission (QRun-IO/qqq#671)', async () => {
|
||||
@@ -228,9 +234,9 @@ describe('RecordViewPage independent association loading', () => {
|
||||
expect(within(dialog).queryByLabelText('Person')).not.toBeInTheDocument()
|
||||
fireEvent.change(within(dialog).getByLabelText(/Name/), { target: { value: 'Created child' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalled())
|
||||
expect(post.mock.calls[0][0]).toBe('/table/pet')
|
||||
const values = post.mock.calls[0][1] as FormData
|
||||
// the record view also records a recent view (collaboration) through POST; find the insert
|
||||
await waitFor(() => expect(post.mock.calls.some((call) => call[0] === '/table/pet')).toBe(true))
|
||||
const values = post.mock.calls.find((call) => call[0] === '/table/pet')![1] as FormData
|
||||
expect(values.get('personId')).toBe('2')
|
||||
expect(values.get('birthDate')).toBe('1990-01-15')
|
||||
expect(values.get('name')).toBe('Created child')
|
||||
|
||||
@@ -33,6 +33,7 @@ import React, { lazy, Suspense, useEffect } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { AlertsRecordPanelSlot } from '@/components/alerts/AlertsRecordPanelSlot'
|
||||
import { addRecentRecord, removeRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { recordAnalytics } from '@/lib/analytics'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
@@ -58,7 +59,7 @@ const RecordView = lazy(() => import('@/components/records/RecordView').then((mo
|
||||
*
|
||||
* Loads the base record and optional whole-association expansion independently. Once
|
||||
* the record loads it is registered in the recently-viewed history (via
|
||||
* `addRecentRecord`) so it surfaces in the GlobalSearch and SearchDialog.
|
||||
* `addRecentRecord`) so it surfaces in the search box (recently viewed records).
|
||||
* The page header in QContext is updated to the record label.
|
||||
*
|
||||
* @returns A composed view that assembles:
|
||||
@@ -199,7 +200,9 @@ export default function RecordViewPage() {
|
||||
{record && associations.isError && (
|
||||
<p role="alert" className="mb-4 text-destructive">Related records could not be loaded.</p>
|
||||
)}
|
||||
<AlertsRecordPanelSlot metaData={metaData} tableName={slug} recordId={recordId} />
|
||||
<Suspense fallback={<div role="status" aria-label="Loading record view" className="py-12 text-center">Loading record…</div>}><RecordView
|
||||
requestedPrimaryKey={recordId}
|
||||
instance={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
record={displayRecord}
|
||||
@@ -215,7 +218,7 @@ export default function RecordViewPage() {
|
||||
allTables={metaData?.tables}
|
||||
widgetMetaDataMap={metaData?.widgets}
|
||||
associationTables={associationTables}
|
||||
auditSource={auditSource(metaData)}
|
||||
auditSource={auditSource(metaData, tableMetaData.clientCapabilities)}
|
||||
/></Suspense>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('TableDeveloperViewPage API Docs & Playground', () => {
|
||||
window.localStorage.clear()
|
||||
getTableApisMock.mockReset()
|
||||
getApiVersionsMock.mockReset()
|
||||
getApiVersionsMock.mockImplementation(async (path: string) => versions[path])
|
||||
getApiVersionsMock.mockImplementation((api) => versions[api.path])
|
||||
})
|
||||
|
||||
it('selects the first API and its current version and embeds RapiDoc for the table spec', async () => {
|
||||
@@ -108,7 +108,7 @@ describe('TableDeveloperViewPage API Docs & Playground', () => {
|
||||
|
||||
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('spec-url', '/qqq/v1/table/person/apis/person-api/2026.Q2/openapi?format=json')
|
||||
expect(rapiDoc).toHaveAttribute('primary-color', '#6366f1')
|
||||
expect(rapiDoc).toHaveAttribute('show-header', 'false')
|
||||
expect(rapiDoc).toHaveAttribute('render-style', 'view')
|
||||
@@ -128,7 +128,7 @@ describe('TableDeveloperViewPage API Docs & Playground', () => {
|
||||
|
||||
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'))
|
||||
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/qqq/v1/table/person/apis/admin-api/v2/openapi?format=json'))
|
||||
})
|
||||
|
||||
it('falls back to the first API and the current version when the remembered ones are gone', async () => {
|
||||
@@ -151,7 +151,7 @@ describe('TableDeveloperViewPage API Docs & Playground', () => {
|
||||
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'))
|
||||
await waitFor(() => expect(container.querySelector('rapi-doc')).toHaveAttribute('spec-url', '/qqq/v1/table/person/apis/admin-api/v2/openapi?format=json'))
|
||||
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiName')).toBe('admin-api')
|
||||
expect(window.localStorage.getItem('qqq.tableDeveloperView.lastApiVersion')).toBe('v2')
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { NestedAppRedirect } from '@/components/layout/NestedAppRedirect'
|
||||
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
|
||||
|
||||
/**
|
||||
@@ -23,12 +24,12 @@ export function generateStaticParams() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through layout that exists to carry generateStaticParams.
|
||||
* Resolves app-prefixed URLs before rendering the resource route.
|
||||
*
|
||||
* @param props - Layout props.
|
||||
* @param props.children - Route content.
|
||||
* @returns The children unchanged.
|
||||
* @returns The route content or its canonical redirect.
|
||||
*/
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return children
|
||||
return <NestedAppRedirect>{children}</NestedAppRedirect>
|
||||
}
|
||||
@@ -46,7 +46,7 @@ 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 })))
|
||||
import { ProcessRun } from '@/components/process/ProcessRunLazy'
|
||||
const AppHome = lazy(() => import('@/components/widgets/AppHome').then((module) => ({ default: module.AppHome })))
|
||||
const ReportRun = lazy(() => import('@/components/reports/ReportRun').then((module) => ({ default: module.ReportRun })))
|
||||
|
||||
|
||||
@@ -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
|
||||
*
|
||||
* 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 Shared access-request deep link; never fetches the referenced record. */
|
||||
'use client'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Suspense } from 'react'
|
||||
const Request = dynamic(
|
||||
() => import('@/components/collaboration/RequestAccess').then((m) => m.RequestAccessPage),
|
||||
{ ssr: false }
|
||||
)
|
||||
/** Renders the opaque request form.
|
||||
* @returns Access page.
|
||||
*/
|
||||
export default function AccessRequestRoute() {
|
||||
return (
|
||||
<Suspense>
|
||||
<Request />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 Static export placeholder for job detail routes. */
|
||||
import type { ReactNode } from 'react'
|
||||
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
|
||||
|
||||
/** Generates the export placeholder.
|
||||
* @returns One job path.
|
||||
*/
|
||||
export function generateStaticParams() { return [{ jobId: EXPORT_PLACEHOLDER }] }
|
||||
|
||||
/** Pass-through layout.
|
||||
* @param props - Children.
|
||||
* @returns Children unchanged.
|
||||
*/
|
||||
export default function Layout({ children }: { children: ReactNode }) { return children }
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 Live job route, separate from metadata table records. */
|
||||
'use client'
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { JobDetailView } from '@/components/jobs/JobDetailView'
|
||||
|
||||
/** Renders a job by id.
|
||||
* @returns Job view or invalid-id message.
|
||||
*/
|
||||
export default function JobRoute() {
|
||||
const { jobId } = useRouteParams<{ jobId: string }>()
|
||||
const id = Number(jobId)
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return <p role="alert">Invalid job identifier.</p>
|
||||
return <div className="w-full min-w-0 rounded-xl border border-border bg-card"><JobDetailView key={id} jobId={id} /></div>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Static Jobs page, taking precedence over the metadata slug route. */
|
||||
'use client'
|
||||
import dynamic from 'next/dynamic'
|
||||
const JobsPage = dynamic(() => import('@/components/jobs/JobsPage').then((module) => module.JobsPage))
|
||||
|
||||
/** Renders the Jobs page.
|
||||
* @returns Page content.
|
||||
*/
|
||||
export default function JobsRoute() { return <JobsPage /> }
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 Static My work route, loaded like the notifications page. */
|
||||
'use client'
|
||||
import dynamic from 'next/dynamic'
|
||||
const MyWork = dynamic(() => import('@/components/collaboration/MyWork').then((m) => m.MyWork), {
|
||||
ssr: false,
|
||||
})
|
||||
/** Renders personal work.
|
||||
* @returns Lazy page.
|
||||
*/
|
||||
export default function MyWorkRoute() {
|
||||
return <MyWork />
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Notifications page — the notification center's full list (qbit-alerts), with filters
|
||||
* and paging in the URL (`status`, `category`, `severity`, `offset`). A fixed page under `/app`
|
||||
* like `/app/search`, so the static export emits it and the dashboard layout wraps it.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
/** The page loads on demand, so its code stays out of every route's first load. */
|
||||
const NotificationsPage = dynamic(() => import('@/components/alerts/NotificationsPage').then((module) => module.NotificationsPage), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-page-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading notifications…
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders the notifications page at `/app/notifications`.
|
||||
*
|
||||
* @returns The page.
|
||||
*/
|
||||
export default function NotificationsRoute() {
|
||||
return <NotificationsPage />
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* 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 /app home page: Next's overview, or the instance's home app (QRun-IO/qqq#1059)
|
||||
|
||||
import React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const replace = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/app',
|
||||
useRouter: () => ({ push: vi.fn(), replace }),
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
|
||||
vi.mock('@/lib/auth/use-auth', () => ({ useAuth: () => ({ user: { name: 'Avery Example' } }) }))
|
||||
const metaData = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
|
||||
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn(async () => metaData.value) }))
|
||||
|
||||
import DashboardPage from './page'
|
||||
|
||||
/**
|
||||
* Instance metadata with two apps, optionally designating a home app.
|
||||
*
|
||||
* @param homeAppName - The home app.
|
||||
* @returns The metadata.
|
||||
*/
|
||||
function instance(homeAppName?: string): Record<string, unknown> {
|
||||
return {
|
||||
apps: { home: { name: 'home', label: 'Home' }, cms: { name: 'cms', label: 'CMS' } },
|
||||
appTree: [{ name: 'home', label: 'Home', type: 'APP', children: [] }, { name: 'cms', label: 'CMS', type: 'APP', children: [] }],
|
||||
tables: {}, processes: {}, reports: {}, widgets: {},
|
||||
...(homeAppName ? { homeAppName } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders the page with a fresh query client. */
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><DashboardPage /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
replace.mockReset()
|
||||
})
|
||||
|
||||
describe('/app home page', () => {
|
||||
it('shows the overview when the instance designates no home app', async () => {
|
||||
metaData.value = instance()
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('CMS')).toBeInTheDocument())
|
||||
expect(document.querySelector('[data-qqq-id="dashboard-home"]')).toBeInTheDocument()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('goes to the home app dashboard instead of the overview when the instance designates one', async () => {
|
||||
metaData.value = instance('home')
|
||||
renderPage()
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith('/app/home'))
|
||||
expect(document.querySelector('[data-qqq-id="dashboard-home"]')).not.toBeInTheDocument()
|
||||
expect(document.querySelector('[data-qqq-id="dashboard-home-redirect"]')).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
|
||||
it('keeps the overview when the designated home app is not one the user may open', async () => {
|
||||
metaData.value = { ...instance(), homeAppName: 'secret' }
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('CMS')).toBeInTheDocument())
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Workflow,
|
||||
@@ -44,6 +45,7 @@ import { canInsertRecords, canRunProcess } from '@/lib/auth/permissions'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import type { SidebarRoute } from '@/lib/hooks/use-routes'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import { DEFAULT_HOME_PATH } from '@/lib/utils/home-app'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
@@ -102,7 +104,15 @@ export default function DashboardPage() {
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
|
||||
const { sidebarRoutes, navTargets } = useAppTreeRoutes(metaData)
|
||||
const { sidebarRoutes, navTargets, homePath } = useAppTreeRoutes(metaData)
|
||||
const router = useRouter()
|
||||
const hasHomeApp = homePath !== DEFAULT_HOME_PATH
|
||||
|
||||
// An instance with a home app (homeAppName) shows that app's dashboard as its home page
|
||||
// (the backend redirects full page loads of /app; this covers client-side navigation).
|
||||
useEffect(() => {
|
||||
if (hasHomeApp) router.replace(homePath)
|
||||
}, [hasHomeApp, homePath, router])
|
||||
const appRoutes = sidebarRoutes.filter((route) => route.nodeType === 'APP')
|
||||
|
||||
const appName = metaData?.branding?.appName || 'QQQ Admin'
|
||||
@@ -145,6 +155,10 @@ export default function DashboardPage() {
|
||||
{ id: 'widgets', label: 'Widgets', value: widgetNames.size, hint: 'Dashboard components', icon: <TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
|
||||
]
|
||||
|
||||
if (hasHomeApp) {
|
||||
return <div aria-busy="true" data-qqq-id="dashboard-home-redirect" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8" data-qqq-id="dashboard-home">
|
||||
{/* Welcome header */}
|
||||
@@ -226,6 +240,7 @@ export default function DashboardPage() {
|
||||
<Link
|
||||
key={target.key}
|
||||
href={`${target.path}/create`}
|
||||
prefetch={false}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
data-qqq-id={`dashboard-create-${target.key}`}
|
||||
>
|
||||
|
||||
@@ -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 Preferences page entry point, compatible with the dashboard static export. */
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const PreferencesPage = dynamic(
|
||||
() => import('@/components/preferences/PreferencesPage').then((module) => module.PreferencesPage),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p role="status" className="p-6 text-muted-foreground">
|
||||
Loading preferences…
|
||||
</p>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
/** Renders current-user preferences.
|
||||
* @returns The dynamically loaded preferences page.
|
||||
*/
|
||||
export default function PreferencesRoute() {
|
||||
return <PreferencesPage />
|
||||
}
|
||||
@@ -15,179 +15,33 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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.
|
||||
* @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. URL state: `q`, `tables`, `view=pages`, `sort=recent`.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { Search } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
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
|
||||
/** The results page loads on demand, so its code stays out of every route's first load. */
|
||||
const SearchPage = dynamic(() => import('@/components/search/SearchPage').then((module) => module.SearchPage), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="search-page-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading search…
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders the search page at `/app/search`.
|
||||
*
|
||||
* 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.
|
||||
* @returns The search page.
|
||||
*/
|
||||
export default function SearchResultsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { setPageHeader } = useQContext()
|
||||
const term = searchParams.get('q') ?? ''
|
||||
const [inputValue, setInputValue] = useState(term)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
const { navTargets } = useAppTreeRoutes(metaData)
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(term)
|
||||
}, [term])
|
||||
|
||||
useEffect(() => {
|
||||
setRecentRecords(getRecentRecords())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
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, 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.
|
||||
*
|
||||
* @param event - Form submit event.
|
||||
*/
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const next = inputValue.trim()
|
||||
router.replace(next ? `/app/search?q=${encodeURIComponent(next)}` : '/app/search')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id="search-results-page">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{term ? <>Search results for “{term}”</> : 'Search'}
|
||||
</h1>
|
||||
|
||||
<form role="search" onSubmit={handleSubmit} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
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"
|
||||
/>
|
||||
</form>
|
||||
|
||||
{!term && (
|
||||
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-prompt-state">
|
||||
Enter text above to find apps, tables, processes and recently viewed records.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{recordSearch.available ? 'No pages or records match' : 'No pages or recent records match'} “{term}”.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pages.length > 0 && (
|
||||
<section aria-label="Pages" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-pages">
|
||||
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Pages</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{pages.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-page-${item.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={item.icon} kind={item.nodeType ? ICON_KIND[item.nodeType] : 'app'} className="text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">{item.label}</span>
|
||||
{item.context && <span className="text-xs text-muted-foreground">{item.context}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{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" 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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <SearchPage />
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/** @file What's new route, split from the dashboard's first load. */
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
/** The page loads on demand, so its code stays out of every route's first load. */
|
||||
const WhatsNewPage = dynamic(() => import('@/components/release-notes/WhatsNewPage').then((module) => module.WhatsNewPage), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="whats-new-page-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading release notes...
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders the whats-new page at `/app/whats-new`.
|
||||
*
|
||||
* @returns The page.
|
||||
*/
|
||||
export default function WhatsNewRoute() {
|
||||
return <WhatsNewPage />
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { resetClientErrorsForTesting, setClientErrorSink } from '@/lib/errors/client-errors'
|
||||
import PageError from './error'
|
||||
|
||||
describe('page error boundary', () => {
|
||||
afterEach(() => {
|
||||
resetClientErrorsForTesting()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports the failure and offers Try again inside the shell', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const sink = vi.fn().mockResolvedValue(undefined)
|
||||
setClientErrorSink(sink)
|
||||
const reset = vi.fn()
|
||||
render(<PageError error={new Error("Cannot read properties of undefined (reading 'length')")} reset={reset} />)
|
||||
expect(screen.getByText('This page hit a problem')).toBeVisible()
|
||||
expect(sink).toHaveBeenCalledWith(expect.objectContaining({ scope: 'page' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
expect(reset).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 Page error boundary — a page that throws is replaced inside the app shell, so the
|
||||
* navigation, header and assistant keep working.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { clientErrorDetails, reportClientError, type ClientErrorReport } from '@/lib/errors/client-errors'
|
||||
|
||||
/**
|
||||
* Whether the error means the session ended (the root boundary sends the user to log in).
|
||||
*
|
||||
* @param error - The thrown error.
|
||||
* @returns True for 401 / unauthorized / session expired.
|
||||
*/
|
||||
function isSessionError(error: Error): boolean {
|
||||
const message = error.message?.toLowerCase() ?? ''
|
||||
return message.includes('401') || message.includes('unauthorized') || message.includes('session expired')
|
||||
}
|
||||
|
||||
/**
|
||||
* The page-level fallback, rendered by Next.js inside the dashboard layout.
|
||||
*
|
||||
* @param props - The Next.js error boundary props.
|
||||
* @param props.error - The thrown error.
|
||||
* @param props.reset - Renders the page again.
|
||||
* @returns A card with Try again, Go home and the error's details.
|
||||
*/
|
||||
export default function PageError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const [report, setReport] = useState<ClientErrorReport | null>(null)
|
||||
const sessionExpired = isSessionError(error)
|
||||
useEffect(() => {
|
||||
if (!sessionExpired) setReport(reportClientError(error, { scope: 'page' }))
|
||||
}, [error, sessionExpired])
|
||||
// the root boundary owns the "Session Expired" screen
|
||||
if (sessionExpired) throw error
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-lg rounded-xl border border-border bg-card p-6 text-center shadow-sm" role="alert" data-qqq-id="page-error">
|
||||
<AlertCircle className="mx-auto mb-3 h-10 w-10 text-destructive" aria-hidden="true" />
|
||||
<h1 className="text-lg font-semibold text-foreground">This page hit a problem</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">The rest of the app still works. Try again, or go to another page.</p>
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
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 pointer-coarse:min-h-11"
|
||||
data-qqq-id="button-page-try-again"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
data-qqq-id="link-page-go-home"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
{report && (
|
||||
<details className="mt-4 text-left text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">Details</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono" data-qqq-id="page-error-details">{clientErrorDetails(report)}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,8 +22,10 @@
|
||||
|
||||
/** Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts. */
|
||||
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { SecuredDataProvider, SecuredDataBannerSlot } from '@/components/secured-data/SecuredDataProvider'
|
||||
import React, { useEffect, useLayoutEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useAuth } from '@/lib/auth/use-auth'
|
||||
@@ -31,14 +33,17 @@ 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 { ApplicationLoading } from '@/components/layout/ApplicationLoading'
|
||||
import { applyBrandingTheme } from '@/lib/theme/apply-branding'
|
||||
import { skipToFirstZone } from '@/lib/utils/skip-link'
|
||||
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 { Sparkles } from 'lucide-react'
|
||||
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import Header from '@/components/layout/Header'
|
||||
import BannerComponent from '@/components/layout/Banner'
|
||||
@@ -46,8 +51,18 @@ 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'
|
||||
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
|
||||
import { AccountMenu } from '@/components/layout/AccountMenu'
|
||||
import { HelpMenu, HelpMenuItems } from '@/components/layout/HelpMenu'
|
||||
import { HEADER_MENU_ITEM_CLASS } from '@/components/layout/HeaderMenu'
|
||||
import { singleKeyShortcutsEnabled } from '@/lib/keyboard/single-key-shortcuts'
|
||||
import { useSingleKeyShortcutsSync } from '@/lib/keyboard/use-single-key-shortcuts-sync'
|
||||
import { HelpPanelProvider } from '@/components/help/HelpPanelProvider'
|
||||
import { AssistantProvider, useAssistantController } from '@/components/assistant/AssistantProvider'
|
||||
import { AssistantHeaderButton } from '@/components/assistant/AssistantHeaderButton'
|
||||
import { AssistantMount } from '@/components/assistant/AssistantMount'
|
||||
import { AlertsBell, useAlertsInstalled } from '@/components/alerts/AlertsBell'
|
||||
import { NotificationBannerSlot } from '@/components/alerts/NotificationBannerSlot'
|
||||
|
||||
/**
|
||||
* Inner layout that renders the full dashboard chrome.
|
||||
@@ -60,7 +75,8 @@ import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcuts
|
||||
* - 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).
|
||||
* - Registers global keyboard shortcuts: Cmd+K and `/` (search), `?` (keyboard shortcuts); the
|
||||
* single-key ones only while the user has not turned single-key shortcuts off (WCAG 2.1.4).
|
||||
* - Renders the desktop sidebar, mobile sidebar drawer, banners, header, and main content slot.
|
||||
*
|
||||
* @param children - The authenticated page content to render in the main slot.
|
||||
@@ -75,15 +91,17 @@ 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
|
||||
// The one search box (pages, records, actions, help, Ask): Cmd/Ctrl+K, `/`, the header field
|
||||
const [commandOpen, setCommandOpen] = useState(false)
|
||||
// opened from Help > Search help: scoped to help articles
|
||||
const [commandHelpOnly, setCommandHelpOnly] = useState(false)
|
||||
const openSearch = useCallback((helpOnly = false) => {
|
||||
setCommandHelpOnly(helpOnly)
|
||||
setCommandOpen(true)
|
||||
}, [])
|
||||
|
||||
// Search dialog state (/ key)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
// Keyboard shortcuts help dialog state
|
||||
// Keyboard shortcuts sheet (`?`, or Help > Keyboard shortcuts)
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
|
||||
// Load full application metadata
|
||||
@@ -91,6 +109,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
data: metaData,
|
||||
isLoading: metaLoading,
|
||||
error: metaError,
|
||||
refetch: reloadMetaData,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
@@ -98,16 +117,29 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
// QQQ Assistant (qbit-assistant): hidden and inert unless its /assistant/config probe enables it
|
||||
const alertsInstalled = useAlertsInstalled()
|
||||
const assistant = useAssistantController(metaData, isAuthenticated)
|
||||
const { enabled: assistantEnabled, toggle: toggleAssistant } = assistant
|
||||
|
||||
// The user's single-key shortcut setting (server Keyboard section, or this browser's choice)
|
||||
useSingleKeyShortcutsSync(isAuthenticated)
|
||||
|
||||
// Generate sidebar routes and path map from app tree
|
||||
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets } = useAppTreeRoutes(metaData)
|
||||
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, homePath, sectionRedirects } = useAppTreeRoutes(metaData)
|
||||
|
||||
// A section heading is not a page: its name as a path (an old link to a group that used to be an
|
||||
// app) goes to the app that heads it, at the section
|
||||
useEffect(() => {
|
||||
const target = sectionRedirects[(pathname ?? '').replace(/\/+$/, '')]
|
||||
if (target) router.replace(target)
|
||||
}, [pathname, sectionRedirects, router])
|
||||
|
||||
// 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(() => {
|
||||
@@ -116,8 +148,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [pathToLabelMap, setPathToLabelMap])
|
||||
|
||||
// Sync branding to QContext and inject theme
|
||||
useEffect(() => {
|
||||
// Install branding before the first branded shell is painted.
|
||||
useLayoutEffect(() => {
|
||||
if (metaData?.branding) {
|
||||
setBranding(metaData.branding)
|
||||
// accent colors (validated, MED-3) and the favicon
|
||||
@@ -140,7 +172,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
// customCss; removed again when the dashboard unmounts (sign-out)
|
||||
const { theme, setTheme } = useTheme()
|
||||
const applicationTheme = useMemo(() => readMaterialTheme(metaData), [metaData])
|
||||
useEffect(() => { setTheme(applicationTheme) }, [applicationTheme, setTheme])
|
||||
useLayoutEffect(() => { setTheme(applicationTheme) }, [applicationTheme, setTheme])
|
||||
useEffect(() => () => setTheme(null), [setTheme])
|
||||
|
||||
// Analytics (QRun-IO/qqq#730): off unless configured; one page view per screen
|
||||
@@ -179,61 +211,57 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Global `keydown` handler that drives the application-level keyboard shortcuts.
|
||||
*
|
||||
* - Cmd+K / Ctrl+K — toggles the command palette.
|
||||
* - Escape — closes all overlays (command palette, search dialog, help dialog, mobile sidebar).
|
||||
* - `.` — opens the command palette (only when not in a text field).
|
||||
* - `/` — opens the search dialog (only when not in a text field).
|
||||
* - `?` — opens the keyboard shortcuts dialog (only when not in a text field).
|
||||
* - Cmd+K / Ctrl+K — toggles the search box.
|
||||
* - Cmd+J / Ctrl+J — toggles the assistant panel (when available; works in text fields too).
|
||||
* - Escape — closes all overlays (search box, shortcuts sheet, mobile sidebar).
|
||||
* - `/` — opens the search box (not in a text field; off with single-key shortcuts).
|
||||
* - `.` — the same, kept quietly for Material habits (off with single-key shortcuts).
|
||||
* - `?` — opens the keyboard shortcuts sheet (not in a text field; off with single-key shortcuts).
|
||||
*
|
||||
* @param e - The native DOM keyboard event.
|
||||
*/
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setCommandHelpOnly(false)
|
||||
setCommandOpen((prev) => !prev)
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'j' && assistantEnabled) {
|
||||
e.preventDefault()
|
||||
setCommandOpen(false)
|
||||
toggleAssistant()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setCommandOpen(false)
|
||||
setSearchOpen(false)
|
||||
setHelpOpen(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) {
|
||||
// Single-key shortcuts — only when not focused in a text field, and not turned off (WCAG 2.1.4)
|
||||
if (!isInputFocused() && !e.metaKey && !e.ctrlKey && !e.altKey && singleKeyShortcutsEnabled()) {
|
||||
if (e.key === '.') {
|
||||
e.preventDefault()
|
||||
recordAnalytics({ category: 'globalEvents', action: 'dotMenuKeyboardShortcut' })
|
||||
setCommandOpen(true)
|
||||
openSearch()
|
||||
}
|
||||
if (e.key === '/') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
openSearch()
|
||||
}
|
||||
if (e.key === '?') {
|
||||
e.preventDefault()
|
||||
setHelpOpen(true)
|
||||
}
|
||||
}
|
||||
}, [isInputFocused])
|
||||
}, [isInputFocused, assistantEnabled, toggleAssistant, openSearch])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
}, [handleGlobalKeyDown])
|
||||
|
||||
// Show loading state while auth initializes
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Use the same neutral screen through authentication and metadata loading.
|
||||
if (authLoading) return <ApplicationLoading />
|
||||
|
||||
// Auth loaded but not authenticated — redirect handled above
|
||||
if (!isAuthenticated) {
|
||||
@@ -245,17 +273,28 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive">Failed to load application metadata.</p>
|
||||
<p role="alert" className="text-destructive">Unable to load your workspace.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{metaError instanceof Error ? metaError.message : 'Unknown error'}
|
||||
</p>
|
||||
<button type="button" className="mt-4 rounded-md border px-4 py-2" onClick={() => { void reloadMetaData() }}>Try again</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!metaData) return <ApplicationLoading />
|
||||
|
||||
// One scroll container: the shell is exactly the viewport and only <main> scrolls. Both are
|
||||
// positioned (`relative`) so they contain absolutely positioned descendants (`sr-only` text,
|
||||
// tooltips): otherwise such an element's containing block is the viewport, `overflow-hidden`
|
||||
// cannot clip it, and one near the end of a long page (the company footer's "opens in a new
|
||||
// tab") makes the document taller than the window, a second scrollbar beside main's.
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background">
|
||||
<HelpPanelProvider instance={metaData}>
|
||||
<SecuredDataProvider enabled={isAuthenticated}><AssistantProvider value={assistant}>
|
||||
<div className="relative flex h-screen flex-col overflow-hidden bg-background" data-qqq-id="app-shell">
|
||||
<SecuredDataBannerSlot />
|
||||
{/* 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} />
|
||||
@@ -265,6 +304,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
href="#main-content"
|
||||
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"
|
||||
onClick={(event) => { if (skipToFirstZone()) event.preventDefault() }}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
@@ -273,9 +313,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
homePath={homePath}
|
||||
showFavorites={isAuthenticated}
|
||||
data-qqq-id="sidebar-container"
|
||||
/>
|
||||
|
||||
@@ -283,35 +322,54 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
homePath={homePath}
|
||||
showFavorites={isAuthenticated}
|
||||
onSearchOpen={() => openSearch()}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
returnFocusRef={menuButtonRef}
|
||||
data-qqq-id="sidebar-mobile"
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Main content area (min-w-0: the docked assistant panel never pushes it wider than the window) */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Header — breadcrumbs + search in one row */}
|
||||
<Header
|
||||
appName={metaData?.branding?.appName}
|
||||
onMenuOpen={() => setSidebarOpen(true)}
|
||||
menuOpen={sidebarOpen}
|
||||
menuButtonRef={menuButtonRef}
|
||||
onSearchOpen={() => setSearchOpen(true)}
|
||||
onCommandOpen={() => setCommandOpen(true)}
|
||||
onHelpOpen={() => setHelpOpen(true)}
|
||||
helpButtonRef={helpButtonRef}
|
||||
onSearchOpen={() => openSearch()}
|
||||
pathToLabelMap={pathToLabelMap}
|
||||
ancestorAppMap={ancestorAppMap}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
homePath={homePath}
|
||||
assistantButton={<AssistantHeaderButton />}
|
||||
notificationsButton={isAuthenticated && alertsInstalled ? <AlertsBell metaData={metaData} /> : null}
|
||||
helpMenu={<HelpMenu onSearchHelp={() => openSearch(true)} onShortcuts={() => setHelpOpen(true)} />}
|
||||
accountMenu={(
|
||||
<AccountMenu
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
logout={logout}
|
||||
compactItems={(close) => (
|
||||
<>
|
||||
{/* the compact header has no room for Help and Ask: they lead this menu (help stays in one place) */}
|
||||
<HelpMenuItems close={close} onSearchHelp={() => openSearch(true)} onShortcuts={() => setHelpOpen(true)} />
|
||||
{assistantEnabled && (
|
||||
<button type="button" role="menuitem" className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-ask"
|
||||
onClick={() => close(toggleAssistant)}>
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Ask the assistant
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
|
||||
<main className="relative flex-1 overflow-y-auto p-6 focus:outline-none" id="main-content" tabIndex={-1} data-qqq-id="main-content">
|
||||
{metaLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
@@ -324,23 +382,33 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
) : (
|
||||
<>
|
||||
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_BODY" className="mb-4 rounded-lg" />
|
||||
<NotificationBannerSlot metaData={metaData} enabled={isAuthenticated} />
|
||||
{children}
|
||||
<CompanyFooter branding={metaData?.branding} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Assistant panel: docked beside the page from 1024 px, a sheet below; lazily loaded */}
|
||||
<AssistantMount />
|
||||
</div>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} navTargets={navTargets} metaData={metaData} />
|
||||
<CommandMenu
|
||||
open={commandOpen}
|
||||
onClose={() => setCommandOpen(false)}
|
||||
navTargets={navTargets}
|
||||
metaData={metaData}
|
||||
helpOnly={commandHelpOnly}
|
||||
onAskAssistant={assistantEnabled ? (text) => assistant.openWith(text) : undefined}
|
||||
/>
|
||||
|
||||
{/* Search Dialog */}
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} navTargets={navTargets} searchTables={searchTables} />
|
||||
|
||||
{/* Keyboard Shortcuts Help Dialog */}
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} returnFocusRef={helpButtonRef} />
|
||||
{/* Keyboard Shortcuts Sheet */}
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} assistantEnabled={assistantEnabled} />
|
||||
</div>
|
||||
</AssistantProvider></SecuredDataProvider>
|
||||
</HelpPanelProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import Providers from './providers'
|
||||
import { themeScript } from '@/lib/theme/theme-script'
|
||||
import '@/styles/globals.css'
|
||||
|
||||
/** Inter font configuration with the CSS variable `--font-inter`. */
|
||||
@@ -71,6 +72,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||
<body className="font-sans antialiased" suppressHydrationWarning>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
import Link from 'next/link'
|
||||
import { SearchX } from 'lucide-react'
|
||||
|
||||
import { NestedAppRedirect } from '@/components/layout/NestedAppRedirect'
|
||||
import { LegacyPathRedirect } from '@/components/layout/LegacyPathRedirect'
|
||||
|
||||
/**
|
||||
@@ -39,26 +40,28 @@ import { LegacyPathRedirect } from '@/components/layout/LegacyPathRedirect'
|
||||
export default function NotFound() {
|
||||
return (
|
||||
// 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>
|
||||
<NestedAppRedirect>
|
||||
<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 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>
|
||||
</div>
|
||||
</LegacyPathRedirect>
|
||||
</LegacyPathRedirect>
|
||||
</NestedAppRedirect>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { AuthProvider } from '@/lib/auth/auth-provider'
|
||||
import { ThemeProvider } from '@/lib/theme/theme-provider'
|
||||
import { injectThemeTokens } from '@/lib/theme/tokens'
|
||||
import { AppToaster } from '@/components/feedback/Toast'
|
||||
import { QaCaptureLoader } from '@/components/qa-capture/QaCaptureLoader'
|
||||
|
||||
// Inject default theme tokens on first render
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -79,7 +80,11 @@ export default function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<AuthProvider>
|
||||
{/* QA capture (qa-capture extension): nothing is loaded unless the server enables it for this user */}
|
||||
<QaCaptureLoader />
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<AppToaster />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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 Alerts record panel: extension gating (no request without the extension) and the
|
||||
// panel's hidden / open / watch states. The bell, popover and banners are in
|
||||
// NotificationCenter.test.tsx.
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, 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 { axe } from 'jest-axe'
|
||||
|
||||
import { uiApiVersionHandler } from '@/mocks/ui-api-version'
|
||||
import { server } from '@/mocks/node'
|
||||
import type { AlertsRecordPanel } from '@/types/alerts'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { AlertsRecordPanelSlot } from './AlertsRecordPanelSlot'
|
||||
|
||||
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => router,
|
||||
usePathname: () => window.location.pathname,
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
}))
|
||||
|
||||
/** The inbox summary (the record panel shows only when it loads). */
|
||||
const SUMMARY = { unreadCount: 0, highestUnreadSeverity: null, notificationsHref: '/app/notifications', items: [], banners: [] }
|
||||
|
||||
const PANEL: AlertsRecordPanel = {
|
||||
visible: true,
|
||||
openCount: 1,
|
||||
open: [{ alertId: 9, title: 'Post has no excerpt', severity: 'WARNING', status: 'OPEN', group: 'Content', age: '1 d', href: '/app/alerts_alert/9', actions: [{ name: 'ack', label: 'Acknowledge', href: '/app/customAck?record=9' }, { name: 'later', label: 'Snooze', href: '/app/customSnooze/9' }, { name: 'api', label: 'API only', operationId: 'customOperation' }] }],
|
||||
resolvedLast30Days: 2,
|
||||
viewAllHref: '/app/alerts_alert?filter=x',
|
||||
canManage: false,
|
||||
watching: false,
|
||||
}
|
||||
|
||||
/** Instance metadata announcing qbit-alerts (its `alerts` app, present when the user holds `alerts.view`). */
|
||||
const WITH_ALERTS = { apps: { alerts: { name: 'alerts', label: 'Alerts' } }, tables: {}, processes: {} } as unknown as QInstance
|
||||
|
||||
/** Instance metadata without qbit-alerts (e.g. the QQQ sample app). */
|
||||
const WITHOUT_ALERTS = { apps: { home: { name: 'home', label: 'Home' } }, tables: {}, processes: {} } as unknown as QInstance
|
||||
|
||||
/**
|
||||
* Renders a component with a fresh query client.
|
||||
*
|
||||
* @param ui - The component.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderWithClient(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => server.use(uiApiVersionHandler('alerts')))
|
||||
|
||||
describe('AlertsRecordPanelSlot', () => {
|
||||
it('sends no request when the version does not announce Alerts', async () => {
|
||||
server.use(uiApiVersionHandler())
|
||||
let calls = 0
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json(SUMMARY)
|
||||
}),
|
||||
http.get('/qqq/v1/alerts/record-panel', () => {
|
||||
calls++
|
||||
return HttpResponse.json(PANEL)
|
||||
})
|
||||
)
|
||||
const { container } = renderWithClient(<AlertsRecordPanelSlot metaData={WITHOUT_ALERTS} tableName="blog_post" recordId="1" />)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('renders nothing when the backend still answers that Alerts is not available', async () => {
|
||||
let panelCalls = 0
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => new HttpResponse(null, { status: 403 })),
|
||||
http.get('/qqq/v1/alerts/record-panel', () => {
|
||||
panelCalls++
|
||||
return HttpResponse.json(PANEL)
|
||||
})
|
||||
)
|
||||
const { container } = renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="1" />)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(panelCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('renders nothing on the Alerts tables themselves, or when the record has nothing to show', async () => {
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
|
||||
http.get('/qqq/v1/alerts/record-panel', () => HttpResponse.json({ ...PANEL, visible: false, open: [], openCount: 0, resolvedLast30Days: 0 }))
|
||||
)
|
||||
const own = renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="alerts_alert" recordId="1" />)
|
||||
const none = renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="1" />)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(own.container).toBeEmptyDOMElement()
|
||||
expect(none.container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('shows open alerts with actions and recent resolutions; watching is read-only', async () => {
|
||||
let panel = PANEL
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
|
||||
http.get('/qqq/v1/alerts/record-panel', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
expect(url.searchParams.get('tableName')).toBe('blog_post')
|
||||
expect(url.searchParams.get('recordId')).toBe('5')
|
||||
return HttpResponse.json(panel)
|
||||
})
|
||||
)
|
||||
const view = renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="5" />)
|
||||
const section = await screen.findByRole('region', { name: 'Open alerts (1)' })
|
||||
expect(await axe(section)).toHaveNoViolations()
|
||||
expect(within(section).getByRole('link', { name: 'Post has no excerpt' })).toHaveAttribute('href', '/app/alerts_alert/9')
|
||||
expect(within(section).getByRole('link', { name: 'Acknowledge' })).toHaveAttribute('href', '/app/customAck?record=9')
|
||||
expect(within(section).getByRole('link', { name: 'Snooze' })).toHaveAttribute('href', '/app/customSnooze/9')
|
||||
expect(within(section).queryByRole('link', { name: 'API only' })).not.toBeInTheDocument()
|
||||
expect(within(section).getByText('2 resolved in the last 30 days')).toBeInTheDocument()
|
||||
|
||||
// the alerts extension (1.0.0) removed the watch operation: no button, only the legacy state
|
||||
expect(within(section).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(within(section).queryByText("You watch this record's alerts")).not.toBeInTheDocument()
|
||||
view.unmount()
|
||||
|
||||
panel = { ...PANEL, watching: true, open: PANEL.open.map(({ actions: _actions, ...alert }) => alert) }
|
||||
renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="5" />)
|
||||
expect(await screen.findByText("You watch this record's alerts")).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Acknowledge' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Snooze' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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 AlertsBell — the header bell of the notification center (qbit-alerts). Like the QQQ
|
||||
* Assistant, it is gated on the server's registered UI API extensions. Per-user access is
|
||||
* checked by the inbox summary (403/404 stops polling). It sends no inbox request
|
||||
* when the extension is absent. When enabled it polls the inbox summary every `pollSeconds` (60) while the tab is
|
||||
* visible (TanStack Query pauses interval refetches in a hidden tab and refetches when it shows
|
||||
* again), backing off exponentially to 5 minutes after errors. The popover's code loads on
|
||||
* first open, so the shell stays small.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense, lazy, useRef, useState } from 'react'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useUiApiExtensionInstalled, UI_API_EXTENSION } from '@/lib/api/ui-api-extensions'
|
||||
import { getAlertsInboxSummary } from '@/lib/api/alerts'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import type { AlertsInboxSummary } from '@/types/alerts'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
|
||||
|
||||
const AlertsBellPopover = lazy(() => import('./AlertsBellPopover'))
|
||||
|
||||
/** Whether the server registers the Alerts extension.
|
||||
* @returns True when Alerts is installed.
|
||||
*/
|
||||
export function useAlertsInstalled(): boolean {
|
||||
return useUiApiExtensionInstalled(UI_API_EXTENSION.alerts)
|
||||
}
|
||||
|
||||
/** The longest wait between polls after errors (5 minutes). */
|
||||
export const MAX_POLL_MS = 300_000
|
||||
|
||||
/** Consecutive failed summary loads (one shared query, so one counter). */
|
||||
let failures = 0
|
||||
|
||||
/**
|
||||
* The summary loader, counting consecutive failures for the backoff.
|
||||
*
|
||||
* @returns The summary.
|
||||
*/
|
||||
async function loadSummary(): Promise<AlertsInboxSummary | null> {
|
||||
try {
|
||||
const summary = await getAlertsInboxSummary()
|
||||
failures = 0
|
||||
return summary
|
||||
} catch (error) {
|
||||
failures++
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The wait before the next poll: `pollSeconds` normally, doubling per consecutive failure up to
|
||||
* {@link MAX_POLL_MS}; no polling once the backend has said Alerts is not for this user.
|
||||
*
|
||||
* @param data - The last summary (null: not available; undefined: none yet).
|
||||
* @param failed - Consecutive failures.
|
||||
* @returns Milliseconds, or false.
|
||||
*/
|
||||
export function pollDelay(data: AlertsInboxSummary | null | undefined, failed: number): number | false {
|
||||
if (data === null && failed === 0) return false
|
||||
const base = (data?.pollSeconds ?? 60) * 1000
|
||||
return failed > 0 ? Math.min(base * 2 ** failed, MAX_POLL_MS) : base
|
||||
}
|
||||
|
||||
/**
|
||||
* The bell's inbox summary (null when the backend still answers that Alerts is unavailable).
|
||||
* Shared with the record panel slot and the banner slot. It never fetches (nor polls) unless
|
||||
* `enabled`.
|
||||
*
|
||||
* @param enabled - Whether to load it: authenticated and {@link useAlertsInstalled}.
|
||||
* @returns The TanStack Query result.
|
||||
*/
|
||||
export function useAlertsInbox(enabled: boolean) {
|
||||
return useQuery<AlertsInboxSummary | null>({
|
||||
queryKey: queryKeys.alertsInbox(),
|
||||
queryFn: loadSummary,
|
||||
enabled,
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: (query) => pollDelay(query.state.data, failures),
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: (query) => (query.state.data === null ? false : 'always'),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The header bell with its unread badge (red when a CRITICAL alert is unread).
|
||||
*
|
||||
* @param _props - `metaData`: retained for caller compatibility; registration comes from the version endpoint.
|
||||
* @returns The bell, or null when Alerts is not available.
|
||||
*/
|
||||
export function AlertsBell(_props: { metaData: QInstance | undefined }) {
|
||||
const { data } = useAlertsInbox(useAlertsInstalled())
|
||||
const [open, setOpen] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
if (!data) return null
|
||||
|
||||
const unread = data.unreadCount
|
||||
const critical = data.highestUnreadSeverity === 'CRITICAL'
|
||||
// severity is in the name, not only in the badge's color
|
||||
const label = unread > 0 ? `Notifications, ${unread} unread${critical ? ', critical' : ''}` : 'Notifications, none unread'
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
aria-label={label}
|
||||
title="Notifications"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
data-qqq-id="button-notifications"
|
||||
>
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
className={`absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
|
||||
aria-hidden="true"
|
||||
data-qqq-id="alerts-unread-badge"
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="sr-only" aria-live="polite" data-qqq-id="alerts-unread-announcer">{label}</span>
|
||||
{open && (
|
||||
<Suspense fallback={null}>
|
||||
<AlertsBellPopover
|
||||
summary={data}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
buttonRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||