Compare commits
213
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e58db9d5e9
|
||
|
|
09d1c2b793
|
||
|
|
728dd2b559 | ||
|
|
bdc38f08e9 | ||
|
|
df9265e877 | ||
|
|
bb05d81cdb
|
||
|
|
5b27bb3989
|
||
|
|
89c35e9f2c
|
||
|
|
2e43dc23d8 | ||
|
|
5efb0782cd
|
||
|
|
bac9215e32 | ||
|
|
cc13b045f4 | ||
|
|
62d76af238
|
||
|
|
974407da47
|
||
|
|
bfe8a6e31c
|
||
|
|
a6c95b1ec7 | ||
|
|
5cafa460b0
|
||
|
|
4e00e7f644
|
||
|
|
db0b421678
|
||
|
|
01fb7f6965
|
||
|
|
1a6c126721
|
||
|
|
f833454c17
|
||
|
|
d01d470b6e
|
||
|
|
0997deb12c
|
||
|
|
196c899389
|
||
|
|
63dcf7e144
|
||
|
|
35cc9da17c
|
||
|
|
e2e0c984ce
|
||
|
|
8b9b14e2e5
|
||
|
|
60d0b0ffc7 | ||
|
|
805ce7d72c
|
||
|
|
690737fa46
|
||
|
|
1f4a38b9ff
|
||
|
|
1afd72ad32
|
||
|
|
7c130187b9
|
||
|
|
35de9ef8bb
|
||
|
|
1f624a2544
|
||
|
|
438eda36e3 | ||
|
|
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
|
||
|
|
9a905890a8
|
||
|
|
fe0077e86c
|
||
|
|
e540b6fcbd
|
||
|
|
4e17d720f4
|
||
|
|
d1eae03894
|
No files matched your search
+4
-28
@@ -48,12 +48,9 @@ jobs:
|
||||
- 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/*
|
||||
# Trunk-based: main is the only long-lived branch (see CONTRIBUTING.md).
|
||||
# test_only → every branch except main (the PR check ci/circleci: qqq-orb/node_app_test_only)
|
||||
# publish_release → main builds; v* version tags publish the dashboard jar
|
||||
|
||||
workflows:
|
||||
|
||||
@@ -62,24 +59,10 @@ workflows:
|
||||
- qqq-orb/node_app_test_only:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
|
||||
ignore: main
|
||||
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:
|
||||
@@ -95,10 +78,3 @@ workflows:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: /^v[0-9]+\.[0-9]+\.[0-9]+(-RC\.[0-9]+)?$/
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/node_app_build:
|
||||
filters:
|
||||
branches:
|
||||
only: [ /hotfix\/.*/ ]
|
||||
@@ -0,0 +1,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,11 +1,9 @@
|
||||
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.
|
||||
|
||||
# One production build feeds independent, serial-worker backends. Require "acceptance gate".
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
@@ -23,19 +21,29 @@ concurrency:
|
||||
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'
|
||||
MAVEN_ARGS: -Dmaven.wagon.http.retryHandler.count=5 -Daether.transport.http.retryHandler.count=5 -Daether.connector.http.retryHandler.count=5
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Chromium provides the primary workflow gate independently of touch coverage.
|
||||
browser: [chromium, 'mobile,tablet', firefox, webkit]
|
||||
plan:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
mode: ${{ steps.select.outputs.mode }}
|
||||
matrix: ${{ steps.select.outputs.matrix }}
|
||||
specs: ${{ steps.select.outputs.specs }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Select projects, shards and changed areas
|
||||
id: select
|
||||
run: node scripts/acceptance-select.mjs
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -43,44 +51,231 @@ jobs:
|
||||
repository: QRun-IO/qqq
|
||||
ref: ${{ env.QQQ_REF }}
|
||||
path: qqq-backend
|
||||
- name: Resolve QQQ source revision
|
||||
id: qqq
|
||||
run: echo "sha=$(git -C qqq-backend rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
cache: maven
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: qqq-cache
|
||||
with:
|
||||
path: ~/.m2/repository/com/kingsrook
|
||||
key: qqq-source-${{ runner.os }}-java21-v1-${{ steps.qqq.outputs.sha }}
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install --with-deps chromium firefox webkit
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Bundled UI API document matches the server's (src/lib/api/openapi/qqq-v1.json)
|
||||
run: node scripts/check-ui-api-spec.mjs --server-spec qqq-backend/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml
|
||||
- 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
|
||||
if: steps.qqq-cache.outputs.cache-hit != 'true'
|
||||
run: bash scripts/ci/retry.sh 4 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"
|
||||
# Equivalent to build:jar, with retries around its Maven invocation too.
|
||||
pnpm build:export
|
||||
bash scripts/ci/retry.sh 4 mvn -B -q clean package
|
||||
bash scripts/ci/retry.sh 4 mvn -B -q install
|
||||
# Failed-attempt Maven stdout must not become part of the version value.
|
||||
bash scripts/ci/retry.sh 4 mvn -B -q help:evaluate -Dexpression=project.version -Doutput="$RUNNER_TEMP/acceptance-next-version"
|
||||
next_version=$(cat "$RUNNER_TEMP/acceptance-next-version")
|
||||
echo "NEXT_JAR_VERSION=$next_version" >> "$GITHUB_ENV"
|
||||
- name: Build the sample application jar
|
||||
working-directory: qqq-backend
|
||||
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 }})
|
||||
# QQQ checkstyle/pmd paths are relative to this checkout.
|
||||
bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" 4 mvn -B -q -f qqq-sample-project/pom.xml package -DskipTests -Djacoco.skip=true \
|
||||
-Dqqq.frontend.next.version="$NEXT_JAR_VERSION"
|
||||
- name: Stage runtime artifacts
|
||||
run: |
|
||||
sample_jars=(qqq-backend/qqq-sample-project/target/*-jar-with-dependencies.jar)
|
||||
test "${#sample_jars[@]}" -eq 1
|
||||
sample_version="${sample_jars[0]##*/}"
|
||||
sample_version="${sample_version#qqq-sample-project-}"
|
||||
sample_version="${sample_version%-jar-with-dependencies.jar}"
|
||||
mkdir -p test-results/acceptance-jars
|
||||
cp "${sample_jars[0]}" test-results/acceptance-jars/
|
||||
# javac and the security fixture need the API JAR in addition to the fat sample JAR.
|
||||
cp "$HOME/.m2/repository/com/kingsrook/qqq/qqq-middleware-api/$sample_version/qqq-middleware-api-$sample_version.jar" \
|
||||
test-results/acceptance-jars/qqq-middleware-api.jar
|
||||
tar -cf test-results/acceptance-build.tar out test-results/acceptance-jars
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: acceptance-build
|
||||
path: test-results/acceptance-build.tar
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
shards:
|
||||
name: acceptance ${{ matrix.project }} ${{ matrix.shard }}/${{ matrix.total }}
|
||||
needs: [plan, build]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
cache: maven
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Installed Playwright version
|
||||
id: playwright
|
||||
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: browser-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-${{ matrix.browser }}
|
||||
- run: pnpm exec playwright install-deps ${{ matrix.browser }}
|
||||
- if: steps.browser-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install ${{ matrix.browser }}
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: acceptance-build
|
||||
path: test-results
|
||||
- name: Restore runtime artifacts
|
||||
run: |
|
||||
tar -xf test-results/acceptance-build.tar
|
||||
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/test-results/acceptance-jars/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
|
||||
echo "QQQ_MIDDLEWARE_API_JAR=$PWD/test-results/acceptance-jars/qqq-middleware-api.jar" >> "$GITHUB_ENV"
|
||||
- name: Run shard
|
||||
env:
|
||||
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
|
||||
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.project }}
|
||||
QQQ_ACCEPTANCE_SPEC_PATHS: ${{ needs.plan.outputs.specs }}
|
||||
QQQ_ACCEPTANCE_BLOB: '1'
|
||||
# Environment-selected projects must not produce colliding default blob names.
|
||||
QQQ_ACCEPTANCE_BLOB_NAME: report-${{ matrix.project }}-${{ matrix.shard }}.zip
|
||||
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).
|
||||
QQQ_ACCEPTANCE_BROWSER_TRACE: ${{ (matrix.project == 'webkit' || matrix.project == 'mobile' || matrix.project == 'tablet') && 'navigation' || '0' }}
|
||||
DEBUG: pw:browser
|
||||
run: node scripts/acceptance.mjs --skip-build --shard=${{ matrix.shard }}/${{ matrix.total }}
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: acceptance-blob-${{ matrix.project }}-${{ matrix.shard }}
|
||||
path: test-results/acceptance/blob-report/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
gate:
|
||||
name: acceptance gate
|
||||
if: always()
|
||||
needs: [plan, build, shards]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: acceptance-blob-*
|
||||
path: test-results/acceptance/blobs
|
||||
merge-multiple: true
|
||||
- name: Merge shard reports
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/acceptance/report.json
|
||||
PLAYWRIGHT_HTML_OUTPUT_DIR: test-results/acceptance/html
|
||||
PLAYWRIGHT_HTML_OPEN: never
|
||||
run: npx playwright merge-reports --reporter=json,html test-results/acceptance/blobs
|
||||
- name: Evaluate acceptance matrix
|
||||
if: always()
|
||||
env:
|
||||
ACCEPTANCE_MODE: ${{ needs.plan.outputs.mode }}
|
||||
run: |
|
||||
args=()
|
||||
if [[ "$ACCEPTANCE_MODE" == pr ]]; then args+=(--partial); fi
|
||||
node scripts/acceptance-gate.mjs "${args[@]}"
|
||||
- name: Require every planned shard and successful build
|
||||
if: always()
|
||||
env:
|
||||
PLAN_RESULT: ${{ needs.plan.result }}
|
||||
BUILD_RESULT: ${{ needs.build.result }}
|
||||
SHARDS_RESULT: ${{ needs.shards.result }}
|
||||
PLANNED_MATRIX: ${{ needs.plan.outputs.matrix }}
|
||||
run: |
|
||||
test "$PLAN_RESULT" = success
|
||||
test "$BUILD_RESULT" = success
|
||||
test "$SHARDS_RESULT" = success
|
||||
node --input-type=module -e 'import { existsSync } from "node:fs"; for (const { project, shard } of JSON.parse(process.env.PLANNED_MATRIX).include) { const file = "test-results/acceptance/blobs/report-" + project + "-" + shard + ".zip"; if (!existsSync(file)) throw new Error("Missing shard report: " + file); }'
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: acceptance
|
||||
path: test-results/acceptance/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
quarantine:
|
||||
name: acceptance quarantine (chromium)
|
||||
needs: [plan, build]
|
||||
if: needs.plan.outputs.mode == 'full'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
cache: maven
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Installed Playwright version
|
||||
id: playwright
|
||||
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: browser-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-chromium
|
||||
- run: pnpm exec playwright install-deps chromium
|
||||
- if: steps.browser-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install chromium
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: acceptance-build
|
||||
path: test-results
|
||||
- name: Restore runtime artifacts
|
||||
run: |
|
||||
tar -xf test-results/acceptance-build.tar
|
||||
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/test-results/acceptance-jars/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
|
||||
echo "QQQ_MIDDLEWARE_API_JAR=$PWD/test-results/acceptance-jars/qqq-middleware-api.jar" >> "$GITHUB_ENV"
|
||||
- name: Run quarantined scenarios
|
||||
env:
|
||||
QQQ_ACCEPTANCE_BROWSERS: chromium
|
||||
QQQ_ACCEPTANCE_QUARANTINE: only
|
||||
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
|
||||
QQQ_ACCEPTANCE_BROWSER_TRACE: '0'
|
||||
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 }}
|
||||
name: acceptance-quarantine
|
||||
path: test-results/acceptance/
|
||||
retention-days: 30
|
||||
@@ -7,7 +7,7 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,12 +27,13 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
@@ -45,14 +46,25 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
- name: Installed Playwright version
|
||||
id: playwright
|
||||
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: browser-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-chromium
|
||||
- run: pnpm exec playwright install-deps chromium
|
||||
- if: steps.browser-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install chromium
|
||||
- name: Production build
|
||||
run: pnpm build
|
||||
- name: Mocked e2e suite
|
||||
@@ -68,18 +80,55 @@ jobs:
|
||||
test-results/
|
||||
retention-days: 14
|
||||
|
||||
collaboration-e2e:
|
||||
name: Collaboration e2e (MSW; chromium, firefox, webkit, mobile, tablet)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Installed Playwright version
|
||||
id: playwright
|
||||
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: browser-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}-all
|
||||
- run: pnpm exec playwright install-deps chromium firefox webkit
|
||||
- if: steps.browser-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install chromium firefox webkit
|
||||
- name: Collaboration suite (mock API, every browser project)
|
||||
run: pnpm exec playwright test --config playwright.collaboration.config.ts
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: collaboration-e2e
|
||||
path: |
|
||||
test-results/collaboration/
|
||||
test-results/collab-shots/
|
||||
retention-days: 14
|
||||
|
||||
storybook:
|
||||
name: Storybook build
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- run: pnpm build-storybook --quiet
|
||||
|
||||
@@ -89,12 +138,13 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: corepack enable
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Static export
|
||||
run: pnpm build:export
|
||||
|
||||
@@ -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.12**, with the always-available record Actions menu, private query-state clearing between users, standalone branding and CSP, grid focus and other fixes. Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing; see the [RC12 notes](docs/releases/1.0.0-RC.12.md) for the preparation checkpoint and verification scope. [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) 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.19**, which adds the related-record picker (single, multi-child and link modes) and the assistant Read-only / Write switch to RC18. Confirm availability in the [RC19 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.19) before installing; see the [RC19 notes](docs/releases/1.0.0-RC.19.md) for the preparation checkpoint and verification scope. [RC18](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.18) 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,64 @@ 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.12</version>
|
||||
<version>1.0.0-RC.21</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
The explicit version overrides an older BOM-managed frontend. RC12 requires a published QQQ 4.1 candidate: 4.1.0-RC.1 or later, and 4.1.0-RC.2 or later for standalone branding images (QQQ #1009); see the release notes for the verified backend. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
|
||||
The explicit version overrides an older BOM-managed frontend. RC21 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); 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.
|
||||
|
||||
### 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 +103,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Next UI agent guide
|
||||
|
||||
> **This checkout targets RC12.** Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. [RC12 notes](../releases/1.0.0-RC.12.md) record the preparation checkpoint and verification scope; RC11 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
> **This checkout targets RC21.** Confirm availability in the [RC21 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.21) before installing. [RC21 notes](../releases/1.0.0-RC.21.md) record the preparation checkpoint and verification scope; RC20 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 RC12 preparation checkpoint was [1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) at `2853b9b`, 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 RC12.** Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. [RC12 notes](../releases/1.0.0-RC.12.md) record the preparation checkpoint and verification scope; RC11 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
> **This checkout targets RC21.** Confirm availability in the [RC21 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.21) before installing. [RC21 notes](../releases/1.0.0-RC.21.md) record the preparation checkpoint and verification scope; RC20 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 RC12 preparation checkpoint was [1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) (commit `2853b9b`); 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 RC21 preparation checkpoint was [1.0.0-RC.20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20) (commit `60d0b0f`); 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.12</version>
|
||||
<version>1.0.0-RC.21</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.12`, overriding an older BOM-managed frontend; confirm its availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) 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); see the release notes for the verified backend. 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 RC12 preparation checkpoint: **[1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC10 remain available and unchanged.
|
||||
Previously verified public candidate at the RC21 preparation checkpoint: **[1.0.0-RC.20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC19 remain available and unchanged.
|
||||
|
||||
## Fixes during testing
|
||||
|
||||
This checkout targets [RC12](1.0.0-RC.12.md); confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. The candidate adds the always-available record Actions menu ([#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)), private query-state clearing between users (QQQ #1006), standalone branding and CSP derivation (QQQ #1009, #1017), grid and report focus fixes, and the other changes listed in the RC12 notes. These changes are absent from immutable RC11. 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 [RC21](1.0.0-RC.21.md); confirm availability in the [RC21 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.21) before installing. RC21 adds file attach in the assistant panel (bulk load) ([PR125](https://github.com/QRun-IO/qqq-frontend-next/pull/125) (attach a file in the assistant panel for bulk load)). RC20 added readable assistant answers (GFM and presentation blocks), data-analysis profile panels, and favorites and recents ([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)). RC19 added the related-record picker (single, multi-child and link modes) and the assistant Read-only / Write switch ([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)). RC18 added the Authentik policy editor widget and the assistant pending-action card with persona ([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)). RC17 added the notification center (bell inbox, notification list and preferences for qbit-alerts user notifications) ([PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89) (notification center)). RC16 added the record picker for large possible-value fields, metadata-driven form validation, date-first filters with presets, the Alerts bell and record panel, resizable navigation and search preview thumbnails ([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)). RC15 added the QQQ Assistant panel, the record developer view, search-as-you-type quick search and a search page, readable dashboard widget widths, one page scroll container and SQL Workbench dark mode ([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)). RC14 was an urgent fix: record search no longer fails when one result has no label ([PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57)). RC13 added the SQL Workbench ([PR47](https://github.com/QRun-IO/qqq-frontend-next/pull/47)), FILE_PREVIEW thumbnails and lightbox ([PR49](https://github.com/QRun-IO/qqq-frontend-next/pull/49), QQQ #1056), responsive layouts with readable child lists and compact dates ([PR50](https://github.com/QRun-IO/qqq-frontend-next/pull/50)) and the instance home app ([PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53), QQQ #1059), and the other changes listed in the RC13 notes. These changes are absent from immutable RC12; the RC14 search fix isLine 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,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,27 @@
|
||||
# Next UI 1.0.0-RC.21 — preparation
|
||||
|
||||
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).
|
||||
@@ -1,25 +1,14 @@
|
||||
# Next UI — unreleased
|
||||
|
||||
[RC12 preparation](1.0.0-RC.12.md) collects every change merged since [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11): the always-available record Actions menu ([#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)), private query-state clearing between users, standalone branding and CSP derivation, grid and report focus fixes, and browser-testing hardening. RC11 is published and immutable. Changes merged after the RC12 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
|
||||
|
||||
- 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).
|
||||
[RC21 preparation](1.0.0-RC.21.md) collects every change merged since [RC20](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.20). RC20 is published and immutable. Changes merged after the RC21 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).
|
||||
|
||||
## Features
|
||||
|
||||
- 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).
|
||||
None yet.
|
||||
|
||||
## 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.
|
||||
None yet.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.12",
|
||||
"version": "1.0.0-RC.21",
|
||||
"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",
|
||||
@@ -54,12 +55,16 @@
|
||||
"dompurify": "^3.4.16",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.485.0",
|
||||
"next": "16.3.6",
|
||||
"next": "16.3.8",
|
||||
"rapidoc": "9.3.8",
|
||||
"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",
|
||||
|
||||
+39
-3
@@ -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. 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). Raise a cap only with a measured reason in the commit.",
|
||||
"$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,
|
||||
@@ -18,8 +19,8 @@
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350
|
||||
},
|
||||
"cssGzipKB": 21,
|
||||
"totalJsGzipKB": 1050,
|
||||
"cssGzipKB": 24,
|
||||
"totalJsGzipKB": 1100,
|
||||
"largestChunkGzipKB": 100,
|
||||
"lazyChunks": {
|
||||
"rapidoc": {
|
||||
@@ -32,10 +33,45 @@
|
||||
"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
|
||||
},
|
||||
"quickSearchPanel": {
|
||||
"$comment": "The quick search results panel of the header box and the '/' dialog (groups, highlights, completions, failure and fallback notices), loaded when the panel first opens: import() in GlobalSearch and SearchDialog. Measured 9.4 KB gzip at introduction (#59); budget about 8% above.",
|
||||
"marker": "search-results-status",
|
||||
"gzipKB": 10.2
|
||||
},
|
||||
"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'],
|
||||
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.
|
||||
|
||||
Generated
+1248
-291
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.12</revision>
|
||||
<revision>1.0.0-RC.21</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,95 @@
|
||||
/*
|
||||
* 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', '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 = [
|
||||
[/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'])
|
||||
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"
|
||||
+15
-3
@@ -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)')
|
||||
@@ -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: () => '/api/v1/person/openapi.json',
|
||||
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,14 +129,14 @@ 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')
|
||||
@@ -136,11 +146,11 @@ describe('RecordDeveloperViewPage', () => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -228,9 +228,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'
|
||||
@@ -199,6 +200,7 @@ 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
|
||||
instance={metaData}
|
||||
tableMetaData={tableMetaData}
|
||||
|
||||
@@ -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,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 */}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -39,6 +39,7 @@ 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 dynamic from 'next/dynamic'
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import Header from '@/components/layout/Header'
|
||||
import BannerComponent from '@/components/layout/Banner'
|
||||
@@ -48,6 +49,15 @@ import { buildBreadcrumbs, buildDocumentTitle } from '@/components/layout/Breadc
|
||||
import { CommandMenu } from '@/components/feedback/CommandMenu'
|
||||
import { SearchDialog } from '@/components/feedback/SearchDialog'
|
||||
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
|
||||
import { AssistantProvider, useAssistantController } from '@/components/assistant/AssistantProvider'
|
||||
import { AssistantHeaderButton } from '@/components/assistant/AssistantHeaderButton'
|
||||
import { AssistantMount } from '@/components/assistant/AssistantMount'
|
||||
import { AlertsBell, isAlertsInstalled } from '@/components/alerts/AlertsBell'
|
||||
import { NotificationBannerSlot } from '@/components/alerts/NotificationBannerSlot'
|
||||
|
||||
// The favorites menu checks for collaboration and loads after hydration, so neither it nor the
|
||||
// collaboration hooks are in the dashboard's first load (the slot stays empty until then).
|
||||
const LazyFavoritesMenu = dynamic(() => import('@/components/collaboration/FavoritesMenuSlot').then((module) => module.LazyFavoritesMenu), { ssr: false })
|
||||
|
||||
/**
|
||||
* Inner layout that renders the full dashboard chrome.
|
||||
@@ -98,8 +108,12 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
|
||||
// QQQ Assistant (qbit-assistant): hidden and inert unless its /assistant/config probe enables it
|
||||
const assistant = useAssistantController(metaData, isAuthenticated)
|
||||
const { enabled: assistantEnabled, toggle: toggleAssistant } = assistant
|
||||
|
||||
// Generate sidebar routes and path map from app tree
|
||||
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets } = useAppTreeRoutes(metaData)
|
||||
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, homePath } = useAppTreeRoutes(metaData)
|
||||
|
||||
// Every route change starts at the top of the page (Material App scrolls to 0 on each pathname)
|
||||
useEffect(() => {
|
||||
@@ -180,6 +194,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
* Global `keydown` handler that drives the application-level keyboard shortcuts.
|
||||
*
|
||||
* - Cmd+K / Ctrl+K — toggles the command palette.
|
||||
* - Cmd+J / Ctrl+J — toggles the assistant panel (when available; works in text fields too).
|
||||
* - 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).
|
||||
@@ -192,6 +207,11 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
e.preventDefault()
|
||||
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)
|
||||
@@ -215,7 +235,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
setHelpOpen(true)
|
||||
}
|
||||
}
|
||||
}, [isInputFocused])
|
||||
}, [isInputFocused, assistantEnabled, toggleAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
@@ -254,8 +274,14 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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">
|
||||
<AssistantProvider value={assistant}>
|
||||
<div className="relative flex h-screen flex-col overflow-hidden bg-background" data-qqq-id="app-shell">
|
||||
{/* 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} />
|
||||
@@ -273,6 +299,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
homePath={homePath}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
@@ -283,6 +310,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<Sidebar
|
||||
routes={sidebarRoutes}
|
||||
branding={metaData?.branding}
|
||||
homePath={homePath}
|
||||
logout={logout}
|
||||
userName={user?.name}
|
||||
userEmail={user?.email}
|
||||
@@ -292,8 +320,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
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}
|
||||
@@ -306,12 +334,16 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
helpButtonRef={helpButtonRef}
|
||||
pathToLabelMap={pathToLabelMap}
|
||||
ancestorAppMap={ancestorAppMap}
|
||||
homePath={homePath}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
assistantButton={<AssistantHeaderButton />}
|
||||
favoritesButton={isAuthenticated ? <LazyFavoritesMenu /> : null}
|
||||
notificationsButton={isAuthenticated && isAlertsInstalled(metaData) ? <AlertsBell metaData={metaData} /> : null}
|
||||
/>
|
||||
|
||||
{/* 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" id="main-content" data-qqq-id="main-content">
|
||||
{metaLoading ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-12"
|
||||
@@ -324,23 +356,34 @@ 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}
|
||||
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} />
|
||||
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} returnFocusRef={helpButtonRef} assistantEnabled={assistantEnabled} />
|
||||
</div>
|
||||
</AssistantProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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: metadata gating (no request without the qbit's app) 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, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
import type { AlertsRecordPanel } from '@/types/alerts'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { isAlertsInstalled } from './AlertsBell'
|
||||
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' }],
|
||||
resolvedLast30Days: 2,
|
||||
viewAllHref: '/app/alerts_alert?filter=x',
|
||||
canManage: true,
|
||||
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>)
|
||||
}
|
||||
|
||||
describe('isAlertsInstalled', () => {
|
||||
it('is true only when the metadata lists the alerts app', () => {
|
||||
expect(isAlertsInstalled(WITH_ALERTS)).toBe(true)
|
||||
expect(isAlertsInstalled(WITHOUT_ALERTS)).toBe(false)
|
||||
expect(isAlertsInstalled(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AlertsRecordPanelSlot', () => {
|
||||
it('sends no request when the metadata does not announce Alerts', async () => {
|
||||
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, recent resolutions, and watches the record', async () => {
|
||||
let watched: unknown = null
|
||||
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)
|
||||
}),
|
||||
http.post('/qqq/v1/alerts/record-panel/watch', async ({ request }) => {
|
||||
watched = await request.json()
|
||||
panel = { ...PANEL, watching: true }
|
||||
return HttpResponse.json({ watching: true, created: true })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
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/alertsAcknowledge?recordsParam=recordIds&recordIds=9&tableName=alerts_alert')
|
||||
expect(within(section).getByRole('link', { name: 'Snooze' })).toBeInTheDocument()
|
||||
expect(within(section).getByText('2 resolved in the last 30 days')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(section).getByRole('button', { name: 'Watch alerts for this record' }))
|
||||
await waitFor(() => expect(watched).toEqual({ tableName: 'blog_post', recordId: '5' }))
|
||||
expect(await within(section).findByText("You watch this record's alerts")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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 instance metadata: unless the metadata lists the qbit's `alerts`
|
||||
* app (which QQQ includes only when qbit-alerts is installed and the user holds `alerts.view`,
|
||||
* the same permission its `/alerts/*` routes require), it renders nothing and sends no request
|
||||
* at all. 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 { getAlertsInboxSummary } from '@/lib/api/alerts'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import type { AlertsInboxSummary } from '@/types/alerts'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
|
||||
const AlertsBellPopover = lazy(() => import('./AlertsBellPopover'))
|
||||
|
||||
/** The app qbit-alerts publishes (`AlertsQBitConfig.APP_NAME`), gated on `alerts.view`. */
|
||||
export const ALERTS_APP_NAME = 'alerts'
|
||||
|
||||
/**
|
||||
* Whether the instance metadata announces qbit-alerts to this user: its `alerts` app is
|
||||
* present. QQQ leaves an app out of the metadata when the qbit is not installed or the user
|
||||
* lacks its permission (`alerts.view`, which the `/alerts/*` routes also require), so this is
|
||||
* the precondition for any Alerts request.
|
||||
*
|
||||
* @param metaData - Instance metadata.
|
||||
* @returns True when the Alerts app is present.
|
||||
*/
|
||||
export function isAlertsInstalled(metaData: QInstance | undefined): boolean {
|
||||
const app = metaData?.apps?.[ALERTS_APP_NAME]
|
||||
return Boolean(app) && typeof app === 'object'
|
||||
}
|
||||
|
||||
/** 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 isAlertsInstalled}.
|
||||
* @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`: instance metadata, which must announce Alerts ({@link isAlertsInstalled}).
|
||||
* @returns The bell, or null when Alerts is not available.
|
||||
*/
|
||||
export function AlertsBell({ metaData }: { metaData: QInstance | undefined }) {
|
||||
const { data } = useAlertsInbox(isAlertsInstalled(metaData))
|
||||
const [open, setOpen] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
if (!data) return null
|
||||
|
||||
const unread = data.unreadCount
|
||||
const label = unread > 0 ? `Notifications, ${unread} unread` : 'Notifications, none unread'
|
||||
const critical = data.highestUnreadSeverity === 'CRITICAL'
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={label}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
data-qqq-id="button-notifications"
|
||||
>
|
||||
<Bell className="h-5 w-5 text-foreground/60" 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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 AlertsBellPopover — the bell's popover (loaded on first open): the latest
|
||||
* notifications, newest first (severity icon, title, source, relative time), each with mark
|
||||
* read/unread and dismiss when the backend sends its recipient id, plus "Mark all read" and
|
||||
* "View all". Opening an item marks it read. Focus stays inside while it is open; Escape or a
|
||||
* click outside closes it.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { NotificationSettingsButton } from './NotificationSettingsButton'
|
||||
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
|
||||
import type { AlertsInboxSummary, InboxItem } from '@/types/alerts'
|
||||
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest } from './NotificationParts'
|
||||
|
||||
/** Props for the popover. */
|
||||
export interface AlertsBellPopoverProps {
|
||||
/** The inbox summary to show. */
|
||||
summary: AlertsInboxSummary
|
||||
/** Close the popover (focus returns to the bell). */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The bell popover.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The popover dialog.
|
||||
*/
|
||||
export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopoverProps) {
|
||||
const router = useRouter()
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
// a dropdown under the bell from 640 px; full width under the header below that (inline, so no new CSS)
|
||||
const [wide, setWide] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(min-width: 640px)').matches)
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== 'function') return
|
||||
const query = window.matchMedia('(min-width: 640px)')
|
||||
const onChange = () => setWide(query.matches)
|
||||
query.addEventListener?.('change', onChange)
|
||||
return () => query.removeEventListener?.('change', onChange)
|
||||
}, [])
|
||||
const placement: React.CSSProperties = wide
|
||||
? { position: 'absolute', right: 0, top: '100%', marginTop: '0.5rem', width: '24rem', maxHeight: '70vh' }
|
||||
: { position: 'fixed', left: '0.5rem', right: '0.5rem', top: 'calc(var(--qqq-header-height) + 0.25rem)', maxHeight: '70vh' }
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsOpen) return
|
||||
const panel = panelRef.current
|
||||
panel?.querySelector<HTMLElement>('a,button')?.focus({ preventScroll: true })
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab' || !panel) return
|
||||
const focusable = Array.from(panel.querySelectorAll<HTMLElement>('a,button:not([disabled])'))
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
const onPointer = (event: PointerEvent) => {
|
||||
if (panel && !panel.contains(event.target as Node) && !(event.target as HTMLElement).closest?.('[data-qqq-id="button-notifications"]')) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('pointerdown', onPointer)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('pointerdown', onPointer)
|
||||
}
|
||||
}, [onClose, settingsOpen])
|
||||
|
||||
const actions = useInboxActions()
|
||||
|
||||
const openItem = (item: InboxItem) => {
|
||||
const read = readRequest(item)
|
||||
if (read) actions.mutate(read)
|
||||
onClose()
|
||||
if (item.href) router.push(item.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
hidden={settingsOpen}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label="Notifications"
|
||||
className="z-50 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
|
||||
style={placement}
|
||||
data-qqq-id="alerts-popover"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Notifications</h2>
|
||||
<NotificationSettingsButton sectionName={summary.preferencesSection} onOpenChange={setSettingsOpen} />
|
||||
<span className="text-xs text-muted-foreground">{summary.unreadCount > 0 ? `${summary.unreadCount} unread` : 'All read'}</span>
|
||||
</div>
|
||||
{summary.items.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground" data-qqq-id="alerts-popover-empty">You have no notifications.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{summary.items.map((item) => {
|
||||
const kind = linkKind(item.href)
|
||||
const title = <span className={`block break-words text-foreground ${item.unread ? 'font-semibold' : ''}`}>{item.title}</span>
|
||||
const meta = <span className="block text-xs text-muted-foreground">{itemMeta(item)}</span>
|
||||
const linkClass = 'block min-w-0 flex-1 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:underline'
|
||||
return (
|
||||
<li key={item.key} className={`flex items-start gap-3 py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-75'}`} data-qqq-id={`alerts-popover-item-${item.key}`}>
|
||||
<SeverityIcon severity={item.severity} />
|
||||
{kind === 'app' ? (
|
||||
<a
|
||||
href={item.href ?? undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openItem(item)
|
||||
}}
|
||||
className={linkClass}
|
||||
>
|
||||
{title}
|
||||
{meta}
|
||||
{item.unread && <span className="sr-only">(unread)</span>}
|
||||
</a>
|
||||
) : kind === 'external' ? (
|
||||
<a href={item.href ?? undefined} target="_blank" rel="noopener noreferrer" onClick={() => openItem({ ...item, href: null })} className={linkClass}>
|
||||
{title}
|
||||
{meta}
|
||||
{item.unread && <span className="sr-only">(unread)</span>}
|
||||
</a>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1">
|
||||
{title}
|
||||
{meta}
|
||||
{item.unread && <span className="sr-only">(unread)</span>}
|
||||
</span>
|
||||
)}
|
||||
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border px-4 py-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
disabled={summary.unreadCount === 0}
|
||||
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
|
||||
className="rounded-md px-2 py-1 text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
data-qqq-id="alerts-mark-all-read"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
<a
|
||||
href={summary.notificationsHref}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
router.push(summary.notificationsHref)
|
||||
}}
|
||||
className="rounded-md px-2 py-1 font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="alerts-view-all"
|
||||
>
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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 AlertsRecordPanel — the "Open alerts" panel on a record's view (loaded only when the
|
||||
* Alerts qbit is available): each open alert that references the record (severity dot, title,
|
||||
* age, status, Acknowledge / Snooze for managers), "N resolved in the last 30 days", and "Watch
|
||||
* alerts for this record". Hidden when there is nothing open and nothing resolved in 30 days.
|
||||
* One column, no inner scroll, at most five rows plus "View all".
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { getAlertsRecordPanel, watchAlertsRecord } from '@/lib/api/alerts'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { SeverityDot } from './SeverityDot'
|
||||
|
||||
/** Props for the panel. */
|
||||
export interface AlertsRecordPanelProps {
|
||||
/** The record's table. */
|
||||
tableName: string
|
||||
/** The record's primary key. */
|
||||
recordId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The record view's "Open alerts" panel.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The panel, or null when there is nothing to show.
|
||||
*/
|
||||
export default function AlertsRecordPanel({ tableName, recordId }: AlertsRecordPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [watchError, setWatchError] = useState<string | null>(null)
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.alertsRecordPanel(tableName, recordId),
|
||||
queryFn: () => getAlertsRecordPanel(tableName, recordId),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
if (!data || !data.visible) return null
|
||||
|
||||
const actionHref = (processName: string, alertId: number) =>
|
||||
`/app/${processName}?recordsParam=recordIds&recordIds=${alertId}&tableName=alerts_alert`
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="alerts-record-panel-title"
|
||||
className="mb-4 rounded-xl border border-border bg-card p-4"
|
||||
data-qqq-id="alerts-record-panel"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 id="alerts-record-panel-title" className="text-sm font-semibold text-foreground">
|
||||
{data.openCount > 0 ? `Open alerts (${data.openCount})` : 'Alerts'}
|
||||
</h2>
|
||||
{data.watching ? (
|
||||
<span className="text-xs text-muted-foreground" data-qqq-id="alerts-record-watching">You watch this record's alerts</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setWatchError(null)
|
||||
try {
|
||||
await watchAlertsRecord(tableName, recordId)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.alertsRecordPanel(tableName, recordId) })
|
||||
} catch {
|
||||
setWatchError('Could not subscribe. Try again.')
|
||||
}
|
||||
}}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="alerts-record-watch"
|
||||
>
|
||||
Watch alerts for this record
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{watchError && <p role="alert" className="mt-2 text-xs text-destructive">{watchError}</p>}
|
||||
|
||||
{data.open.length > 0 && (
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{data.open.map((alert) => (
|
||||
<li key={alert.alertId} className="flex items-start gap-3 py-2 text-sm" data-qqq-id={`alerts-record-alert-${alert.alertId}`}>
|
||||
<SeverityDot severity={alert.severity} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<Link href={alert.href} className="block break-words font-medium text-foreground underline-offset-2 hover:underline">{alert.title}</Link>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{alert.status.charAt(0) + alert.status.slice(1).toLowerCase()} · {alert.age === 'just now' ? 'just now' : `${alert.age} old`}
|
||||
</span>
|
||||
{data.canManage && alert.status !== 'RESOLVED' && (
|
||||
<span className="mt-1 flex flex-wrap gap-3 text-xs">
|
||||
{alert.status === 'OPEN' && (
|
||||
<Link href={actionHref('alertsAcknowledge', alert.alertId)} className="text-primary hover:underline">Acknowledge</Link>
|
||||
)}
|
||||
<Link href={actionHref('alertsSnooze', alert.alertId)} className="text-primary hover:underline">Snooze</Link>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{data.resolvedLast30Days > 0 && (
|
||||
<span data-qqq-id="alerts-record-resolved">
|
||||
{data.resolvedLast30Days === 1 ? '1 resolved' : `${data.resolvedLast30Days} resolved`} in the last 30 days
|
||||
</span>
|
||||
)}
|
||||
{data.viewAllHref && (
|
||||
<Link href={data.viewAllHref} className="text-primary hover:underline" data-qqq-id="alerts-record-view-all">View all</Link>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 AlertsRecordPanelSlot — where a record view's "Open alerts" panel goes. It renders
|
||||
* nothing (sends no request and loads no panel code) unless the instance metadata announces
|
||||
* Alerts and the bell's inbox summary loads, and never on the Alerts qbit's own tables.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
|
||||
|
||||
const AlertsRecordPanel = lazy(() => import('./AlertsRecordPanel'))
|
||||
|
||||
/**
|
||||
* The slot.
|
||||
*
|
||||
* @param props - Instance metadata (which must announce Alerts), and the record's table and primary key.
|
||||
* @returns The lazily loaded panel, or null.
|
||||
*/
|
||||
export function AlertsRecordPanelSlot({ metaData, tableName, recordId }: { metaData: QInstance | undefined; tableName: string; recordId: string | undefined }) {
|
||||
const { data } = useAlertsInbox(isAlertsInstalled(metaData))
|
||||
if (!data || !recordId || tableName.startsWith('alerts_')) return null
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AlertsRecordPanel tableName={tableName} recordId={recordId} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -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 NotificationBannerSlot — shows sticky notifications (the summary's `banners`) above the
|
||||
* page until dismissed. It shares the bell's summary query, so it sends nothing of its own and
|
||||
* nothing at all unless the metadata announces Alerts; the banner UI loads only when there is
|
||||
* a banner to show.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
|
||||
|
||||
const NotificationBanners = lazy(() => import('./NotificationBanners'))
|
||||
|
||||
/**
|
||||
* The banner slot.
|
||||
*
|
||||
* @param props - `metaData`: instance metadata; `enabled`: the user is signed in.
|
||||
* @returns The banners, or null.
|
||||
*/
|
||||
export function NotificationBannerSlot({ metaData, enabled }: { metaData: QInstance | undefined; enabled: boolean }) {
|
||||
const { data } = useAlertsInbox(enabled && isAlertsInstalled(metaData))
|
||||
if (!data?.banners.length) return null
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<NotificationBanners banners={data.banners} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 NotificationBanners — sticky notifications shown above the page (loaded on demand):
|
||||
* severity, title, markdown body (rendered without HTML), an optional link, and Dismiss
|
||||
* (`POST /alerts/inbox/dismiss`), which hides it at once.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
|
||||
import type { InboxItem } from '@/types/alerts'
|
||||
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
|
||||
import { ItemActions, SeverityIcon, linkKind, severityLabel } from './NotificationParts'
|
||||
|
||||
// the severity's accent as a left border, on the card surface so it reads in light and dark mode
|
||||
const ACCENT: Record<string, string> = { INFO: '#2563eb', SUCCESS: '#16a34a', WARNING: '#d97706', ERROR: '#ef4444', CRITICAL: '#dc2626' }
|
||||
|
||||
/**
|
||||
* The banners.
|
||||
*
|
||||
* @param props - `banners`: the summary's sticky items.
|
||||
* @returns A region with one banner per item.
|
||||
*/
|
||||
export default function NotificationBanners({ banners }: { banners: InboxItem[] }) {
|
||||
const actions = useInboxActions()
|
||||
return (
|
||||
<section aria-label="Notification banners" className="mb-4 space-y-2" data-qqq-id="notification-banners">
|
||||
{banners.map((item) => {
|
||||
const kind = linkKind(item.href)
|
||||
const label = item.linkLabel ?? 'Open'
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
role={item.severity === 'CRITICAL' || item.severity === 'ERROR' ? 'alert' : 'status'}
|
||||
aria-label={`${severityLabel(item.severity)}: ${item.title}`}
|
||||
className="flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm shadow-sm"
|
||||
style={{ borderLeftWidth: '4px', borderLeftColor: ACCENT[item.severity] ?? ACCENT.INFO }}
|
||||
data-qqq-id={`notification-banner-${item.key}`}
|
||||
>
|
||||
<SeverityIcon severity={item.severity} />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="break-words font-semibold text-foreground">{item.title}</p>
|
||||
{item.body && <MarkdownView source={item.body} />}
|
||||
{kind === 'app' && (
|
||||
<Link href={item.href ?? ''} className="inline-block font-medium text-primary underline underline-offset-2">{label}</Link>
|
||||
)}
|
||||
{kind === 'external' && (
|
||||
<a href={item.href ?? ''} target="_blank" rel="noopener noreferrer" className="inline-block font-medium text-primary underline underline-offset-2">{label}</a>
|
||||
)}
|
||||
</div>
|
||||
<ItemActions item={item} onAction={(request) => actions.mutate(request)} dismissOnly />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
* 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 notification center's bell, popover and banner slot: metadata gating (no request
|
||||
// without the qbit's app), polling with backoff and the hidden-tab pause, badge, a11y, per-item
|
||||
// and bulk actions (optimistic, rolled back on failure), and older backends.
|
||||
|
||||
import React from 'react'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { AlertsBell, MAX_POLL_MS, pollDelay } from './AlertsBell'
|
||||
import { NotificationBannerSlot } from './NotificationBannerSlot'
|
||||
|
||||
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 summary as the notification center backend sends it. */
|
||||
const SUMMARY = {
|
||||
unreadCount: 2,
|
||||
highestUnreadSeverity: 'CRITICAL',
|
||||
notificationsHref: '/app/notifications',
|
||||
pollSeconds: 60,
|
||||
items: [
|
||||
{ id: 71, notificationId: 7, title: 'Nightly backup did not run', severity: 'CRITICAL', category: 'ALERT', source: 'Backups', age: '3 h', href: '/app/alerts_alert/7', unread: true },
|
||||
{ id: 61, notificationId: 6, title: 'Release 4.1 is out', severity: 'SUCCESS', category: 'RELEASE', source: 'QQQ', age: '20 min', href: 'https://qrun.io/releases', unread: true },
|
||||
{ id: 21, notificationId: 2, title: 'Old warning', severity: 'WARNING', category: 'SYSTEM', source: null, age: '2 d', href: null, unread: false },
|
||||
],
|
||||
banners: [] as unknown[],
|
||||
}
|
||||
|
||||
/** The summary as qbit-alerts 0.1.0-RC.1 sends it (no `id`, `group` as the source). */
|
||||
const LEGACY_SUMMARY = {
|
||||
unreadCount: 1,
|
||||
highestUnreadSeverity: 'ERROR',
|
||||
alertsHref: '/app/alerts',
|
||||
items: [{ alertId: 6, title: '3 CDN purges failed', severity: 'ERROR', status: 'OPEN', group: 'CDN', age: '20 min', href: '/app/alerts_alert/6', unread: true }],
|
||||
}
|
||||
|
||||
/** 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>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the bodies posted to an inbox action.
|
||||
*
|
||||
* @param action - `read`, `unread` or `dismiss`.
|
||||
* @returns The bodies, filled as requests arrive.
|
||||
*/
|
||||
function captureAction(action: string): unknown[] {
|
||||
const bodies: unknown[] = []
|
||||
server.use(http.post(`/qqq/v1/alerts/inbox/${action}`, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
}))
|
||||
return bodies
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits.
|
||||
*
|
||||
* @param ms - Milliseconds.
|
||||
* @returns A promise.
|
||||
*/
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe('pollDelay', () => {
|
||||
it('polls every pollSeconds, backs off exponentially to 5 minutes, and stops when unavailable', () => {
|
||||
const summary = { unreadCount: 0, highestUnreadSeverity: null, items: [], banners: [], notificationsHref: '/app/notifications', pollSeconds: 30 }
|
||||
expect(pollDelay(summary, 0)).toBe(30_000)
|
||||
expect(pollDelay(undefined, 0)).toBe(60_000)
|
||||
expect(pollDelay(summary, 1)).toBe(60_000)
|
||||
expect(pollDelay(summary, 2)).toBe(120_000)
|
||||
expect(pollDelay(undefined, 3)).toBe(MAX_POLL_MS)
|
||||
expect(pollDelay(summary, 10)).toBe(MAX_POLL_MS)
|
||||
expect(pollDelay(null, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AlertsBell', () => {
|
||||
beforeEach(() => {
|
||||
router.push.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
focusManager.setFocused(undefined)
|
||||
})
|
||||
|
||||
it('sends no request and renders nothing when the metadata does not announce Alerts', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/*', () => {
|
||||
calls++
|
||||
return HttpResponse.json(SUMMARY)
|
||||
}))
|
||||
const without = renderWithClient(<AlertsBell metaData={WITHOUT_ALERTS} />)
|
||||
const loading = renderWithClient(<AlertsBell metaData={undefined} />)
|
||||
const banners = renderWithClient(<NotificationBannerSlot metaData={WITHOUT_ALERTS} enabled />)
|
||||
await sleep(100)
|
||||
expect(calls).toBe(0)
|
||||
expect(without.container).toBeEmptyDOMElement()
|
||||
expect(loading.container).toBeEmptyDOMElement()
|
||||
expect(banners.container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders and polls the inbox summary when the metadata announces Alerts', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
|
||||
}))
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(calls).toBeGreaterThanOrEqual(3))
|
||||
})
|
||||
|
||||
it('pauses polling while the tab is hidden and refetches when it shows again', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
|
||||
}))
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await screen.findByRole('button', { name: 'Notifications, 2 unread' })
|
||||
act(() => focusManager.setFocused(false))
|
||||
await sleep(100)
|
||||
const hidden = calls
|
||||
await sleep(250)
|
||||
expect(calls).toBe(hidden)
|
||||
act(() => focusManager.setFocused(true))
|
||||
await waitFor(() => expect(calls).toBeGreaterThan(hidden))
|
||||
})
|
||||
|
||||
it('renders nothing when the backend still answers that Alerts is not available (403 or 404)', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
}))
|
||||
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await waitFor(() => expect(calls).toBe(1))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders nothing for a body that is not a summary', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ hello: 'world' })
|
||||
}))
|
||||
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await waitFor(() => expect(calls).toBe(1))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('shows the unread count, red for critical, capped at 99+, with an accessible label', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const first = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
|
||||
expect(bell).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
|
||||
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
|
||||
first.unmount()
|
||||
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 140, highestUnreadSeverity: 'WARNING' })))
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await screen.findByRole('button', { name: 'Notifications, 140 unread' })
|
||||
expect(screen.getByText('99+', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-primary')
|
||||
})
|
||||
|
||||
it('opens the popover, opens an item (marking it read), and closes on Escape', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const reads = captureAction('read')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
|
||||
await user.click(bell)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
expect(await axe(dialog)).toHaveNoViolations()
|
||||
const items = within(dialog).getAllByRole('listitem')
|
||||
expect(items[0]).toHaveTextContent('Nightly backup did not run')
|
||||
expect(items[0]).toHaveTextContent('Backups · 3 h ago')
|
||||
expect(within(items[0]).getByText('Critical')).toBeInTheDocument()
|
||||
expect(within(items[1]).getByText('Success')).toBeInTheDocument()
|
||||
expect(within(items[1]).getByRole('link')).toHaveAttribute('target', '_blank')
|
||||
expect(within(items[2]).queryByRole('link')).not.toBeInTheDocument()
|
||||
const link = within(items[0]).getByRole('link')
|
||||
await waitFor(() => expect(link).toHaveFocus())
|
||||
|
||||
await user.click(link)
|
||||
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/7')
|
||||
await waitFor(() => expect(reads).toEqual([{ id: 71 }]))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(bell)
|
||||
await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(bell).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps focus inside the popover', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
const first = within(dialog).getAllByRole('link')[0]
|
||||
await waitFor(() => expect(first).toHaveFocus())
|
||||
await user.keyboard('{Shift>}{Tab}{/Shift}')
|
||||
expect(within(dialog).getByRole('link', { name: 'View all' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(first).toHaveFocus()
|
||||
})
|
||||
|
||||
it('marks one read and one unread, and dismisses one', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const reads = captureAction('read')
|
||||
const unreads = captureAction('unread')
|
||||
const dismissals = captureAction('dismiss')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Mark read: Nightly backup did not run' }))
|
||||
await waitFor(() => expect(reads).toEqual([{ id: 71 }]))
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Mark unread: Old warning' }))
|
||||
await waitFor(() => expect(unreads).toEqual([{ id: 21 }]))
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Release 4.1 is out' }))
|
||||
await waitFor(() => expect(dismissals).toEqual([{ id: 61 }]))
|
||||
})
|
||||
|
||||
it('applies an action at once, before the server answers', async () => {
|
||||
let summary = SUMMARY
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
|
||||
http.post('/qqq/v1/alerts/inbox/dismiss', async () => {
|
||||
await sleep(200)
|
||||
summary = { ...SUMMARY, unreadCount: 1, items: SUMMARY.items.slice(1) }
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Nightly backup did not run' }))
|
||||
expect(within(dialog).queryByText('Nightly backup did not run')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Notifications, 1 unread' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rolls back when an action fails', async () => {
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
|
||||
http.post('/qqq/v1/alerts/inbox/read', () => new HttpResponse(null, { status: 500 }))
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
|
||||
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks all read and opens the notifications page', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const reads = captureAction('read')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
|
||||
await waitFor(() => expect(reads).toEqual([{ all: true }]))
|
||||
await user.click(screen.getByRole('link', { name: 'View all' }))
|
||||
expect(router.push).toHaveBeenCalledWith('/app/notifications')
|
||||
})
|
||||
|
||||
it('shows older backends without per-item actions, marking read by alert id', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(LEGACY_SUMMARY)))
|
||||
const reads = captureAction('read')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 1 unread' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
expect(dialog).toHaveTextContent('CDN · 20 min ago')
|
||||
expect(within(dialog).queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('link', { name: /3 CDN purges failed/ }))
|
||||
await waitFor(() => expect(reads).toEqual([{ alertId: 6 }]))
|
||||
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/6')
|
||||
})
|
||||
|
||||
it('shows an empty state with no items', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 0, highestUnreadSeverity: null, items: [] })))
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, none unread' }))
|
||||
expect(await screen.findByText('You have no notifications.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Mark all read' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationBannerSlot', () => {
|
||||
it('shows sticky notifications until dismissed', async () => {
|
||||
const dismissals: unknown[] = []
|
||||
let summary = {
|
||||
...SUMMARY,
|
||||
banners: [{ id: 90, title: 'Maintenance tonight', body: 'The site is **read-only** from 22:00. [Details](/app/status)', severity: 'WARNING', category: 'SYSTEM', href: 'https://status.example.com', linkLabel: 'Status page', unread: true, sticky: true }] as unknown[],
|
||||
}
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
|
||||
http.post('/qqq/v1/alerts/inbox/dismiss', async ({ request }) => {
|
||||
dismissals.push(await request.json())
|
||||
summary = { ...summary, banners: [] }
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled />)
|
||||
const banner = await screen.findByRole('status', { name: 'Warning: Maintenance tonight' })
|
||||
expect(await axe(banner)).toHaveNoViolations()
|
||||
expect(within(banner).getByText('read-only').tagName).toBe('STRONG')
|
||||
expect(within(banner).getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/app/status')
|
||||
expect(within(banner).getByRole('link', { name: 'Status page' })).toHaveAttribute('target', '_blank')
|
||||
expect(within(banner).queryByRole('button', { name: /Mark/ })).not.toBeInTheDocument()
|
||||
await user.click(within(banner).getByRole('button', { name: 'Dismiss: Maintenance tonight' }))
|
||||
await waitFor(() => expect(screen.queryByText('Maintenance tonight')).not.toBeInTheDocument())
|
||||
expect(dismissals).toEqual([{ id: 90 }])
|
||||
})
|
||||
|
||||
it('renders nothing without banners or when signed out', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json(SUMMARY)
|
||||
}))
|
||||
const none = renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled />)
|
||||
await waitFor(() => expect(calls).toBe(1))
|
||||
expect(none.container).toBeEmptyDOMElement()
|
||||
const signedOut = renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled={false} />)
|
||||
await sleep(50)
|
||||
expect(calls).toBe(1)
|
||||
expect(signedOut.container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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 Notification parts shared by the popover, the notifications page and the banners (all
|
||||
* loaded on demand): the severity icon, the relative time, the safe link target, and the
|
||||
* per-item read/unread and dismiss buttons (shown only for items with a recipient `id`).
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, Mail, MailOpen, OctagonAlert, X } from 'lucide-react'
|
||||
|
||||
import type { InboxItem, NotificationSeverity } from '@/types/alerts'
|
||||
import type { InboxActionRequest } from '@/lib/hooks/use-inbox-actions'
|
||||
|
||||
// mid-tone colors that read on light and dark cards; inline so the icons add no CSS
|
||||
const SEVERITY: Record<NotificationSeverity, { label: string; color: string; Icon: typeof Info }> = {
|
||||
INFO: { label: 'Info', color: '#2563eb', Icon: Info },
|
||||
SUCCESS: { label: 'Success', color: '#16a34a', Icon: CheckCircle2 },
|
||||
WARNING: { label: 'Warning', color: '#d97706', Icon: AlertTriangle },
|
||||
ERROR: { label: 'Error', color: '#ef4444', Icon: AlertCircle },
|
||||
CRITICAL: { label: 'Critical', color: '#dc2626', Icon: OctagonAlert },
|
||||
}
|
||||
|
||||
/**
|
||||
* The severity's label ("Critical").
|
||||
*
|
||||
* @param severity - The severity.
|
||||
* @returns Its label.
|
||||
*/
|
||||
export function severityLabel(severity: string): string {
|
||||
return SEVERITY[severity as NotificationSeverity]?.label ?? severity
|
||||
}
|
||||
|
||||
/**
|
||||
* A severity icon with its name for screen readers.
|
||||
*
|
||||
* @param props - The severity.
|
||||
* @returns The icon.
|
||||
*/
|
||||
export function SeverityIcon({ severity }: { severity: NotificationSeverity }) {
|
||||
const { label, color, Icon } = SEVERITY[severity] ?? SEVERITY.INFO
|
||||
return (
|
||||
<span className="flex shrink-0 items-center pt-0.5" data-qqq-id={`notification-severity-${severity}`}>
|
||||
<Icon className="h-4 w-4" style={{ color }} aria-hidden="true" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The relative time of an item: the server's age ("3 h ago"), else from its delivery time.
|
||||
*
|
||||
* @param item - The item.
|
||||
* @param now - The current time (tests).
|
||||
* @returns The text, or empty.
|
||||
*/
|
||||
export function relativeTime(item: Pick<InboxItem, 'age' | 'deliveredAt'>, now: number = Date.now()): string {
|
||||
if (item.age) return item.age === 'just now' ? item.age : `${item.age} ago`
|
||||
const at = item.deliveredAt ? Date.parse(item.deliveredAt) : NaN
|
||||
if (Number.isNaN(at)) return ''
|
||||
const minutes = Math.round((now - at) / 60_000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes} min ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) return `${hours} h ago`
|
||||
return `${Math.round(hours / 24)} d ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies an item's link: a same-origin path starting with a single `/` (client navigation),
|
||||
* https (a new tab), or none (rendered as plain text). Backslashes, whitespace and control
|
||||
* characters are refused outright, since browsers normalize `\` to `/` (`/\evil.com` would
|
||||
* leave the app), as is a second character of `/` or `\`.
|
||||
*
|
||||
* @param href - The link.
|
||||
* @returns The kind.
|
||||
*/
|
||||
export function linkKind(href: string | null | undefined): 'app' | 'external' | null {
|
||||
if (!href || /[\\\s\u0000-\u001f\u007f]/.test(href) || href[1] === '/' || href[1] === '\\') return null
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(href, window.location.origin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (href.startsWith('/')) return url.origin === window.location.origin ? 'app' : null
|
||||
return url.protocol === 'https:' ? 'external' : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The secondary line: source and relative time (and the category on the page).
|
||||
*
|
||||
* @param item - The item.
|
||||
* @param withCategory - Include the category.
|
||||
* @returns The text.
|
||||
*/
|
||||
export function itemMeta(item: InboxItem, withCategory = false): string {
|
||||
const category = withCategory && item.category ? item.category.charAt(0) + item.category.slice(1).toLowerCase() : null
|
||||
return [category, item.source, relativeTime(item)].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** Props for {@link ItemActions}. */
|
||||
export interface ItemActionsProps {
|
||||
/** The item. */
|
||||
item: InboxItem
|
||||
/** Runs an action. */
|
||||
onAction: (request: InboxActionRequest) => void
|
||||
/** Hide the read/unread toggle (banners). */
|
||||
dismissOnly?: boolean
|
||||
}
|
||||
|
||||
const ICON_BUTTON = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
|
||||
/**
|
||||
* Mark read/unread and dismiss for one item; nothing for items without a recipient id (older
|
||||
* backends).
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The buttons, or null.
|
||||
*/
|
||||
export function ItemActions({ item, onAction, dismissOnly = false }: ItemActionsProps) {
|
||||
const id = item.id
|
||||
if (id === undefined) return null
|
||||
return (
|
||||
<span className="flex shrink-0 items-start gap-0.5">
|
||||
{!dismissOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BUTTON}
|
||||
onClick={() => onAction({ action: item.unread ? 'read' : 'unread', target: { id } })}
|
||||
aria-label={`${item.unread ? 'Mark read' : 'Mark unread'}: ${item.title}`}
|
||||
title={item.unread ? 'Mark read' : 'Mark unread'}
|
||||
data-qqq-id={`notification-toggle-read-${id}`}
|
||||
>
|
||||
{item.unread ? <MailOpen className="h-4 w-4" aria-hidden="true" /> : <Mail className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BUTTON}
|
||||
onClick={() => onAction({ action: 'dismiss', target: { id } })}
|
||||
aria-label={`Dismiss: ${item.title}`}
|
||||
title="Dismiss"
|
||||
data-qqq-id={`notification-dismiss-${id}`}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The read target for opening an item: its recipient id, else (older backends) its alert id.
|
||||
*
|
||||
* @param item - The item.
|
||||
* @returns The request, or null when it cannot be marked read.
|
||||
*/
|
||||
export function readRequest(item: InboxItem): InboxActionRequest | null {
|
||||
if (!item.unread) return null
|
||||
if (item.id !== undefined) return { action: 'read', target: { id: item.id } }
|
||||
if (item.alertId !== undefined) return { action: 'read', target: { alertId: item.alertId } }
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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 { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
import { server } from '@/mocks/node'
|
||||
import { NotificationSettingsButton } from './NotificationSettingsButton'
|
||||
import AlertsBellPopover from './AlertsBellPopover'
|
||||
import { AlertsBell } from './AlertsBell'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
|
||||
// The drawer and form are lazy chunks; give the first import time on a busy runner.
|
||||
vi.setConfig({ testTimeout: 15_000 })
|
||||
|
||||
const summary = {
|
||||
unreadCount: 0,
|
||||
highestUnreadSeverity: null,
|
||||
notificationsHref: '/app/notifications',
|
||||
preferencesSection: 'notifications',
|
||||
pollSeconds: 60,
|
||||
items: [],
|
||||
banners: [],
|
||||
}
|
||||
const renderQuery = (element: React.ReactNode) =>
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
{element}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
it('does not fetch preferences or show a gear without a summary section', () => {
|
||||
const request = vi.fn()
|
||||
server.use(
|
||||
http.get('/qqq/v1/preferences', () => {
|
||||
request()
|
||||
return HttpResponse.json({ sections: [] })
|
||||
})
|
||||
)
|
||||
renderQuery(<NotificationSettingsButton />)
|
||||
expect(screen.queryByRole('button', { name: 'Notification settings' })).not.toBeInTheDocument()
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the gear when the advertised section is absent', async () => {
|
||||
const request = vi.fn()
|
||||
server.use(
|
||||
http.get('/qqq/v1/preferences', () => {
|
||||
request()
|
||||
return HttpResponse.json({ sections: [] })
|
||||
})
|
||||
)
|
||||
renderQuery(<NotificationSettingsButton sectionName="notifications" />)
|
||||
await waitFor(() => expect(request).toHaveBeenCalled())
|
||||
expect(screen.queryByRole('button', { name: 'Notification settings' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens an accessible drawer, traps focus, links to all preferences and returns focus to its gear', async () => {
|
||||
renderQuery(<NotificationSettingsButton sectionName="notifications" />)
|
||||
const user = userEvent.setup()
|
||||
const gear = await screen.findByRole('button', { name: 'Notification settings' })
|
||||
await user.click(gear)
|
||||
const drawer = await screen.findByRole('dialog', { name: 'Notifications' }, { timeout: 5000 })
|
||||
await within(drawer).findByRole('checkbox', { name: 'Email alerts' })
|
||||
const manage = within(drawer).getByRole('link', { name: 'Manage all preferences' })
|
||||
expect(manage).toHaveAttribute('href', '/app/preferences?section=notifications')
|
||||
manage.focus()
|
||||
await user.tab()
|
||||
expect(within(drawer).getByRole('button', { name: 'Close settings' })).toHaveFocus()
|
||||
expect(await axe(drawer)).toHaveNoViolations()
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(gear).toHaveFocus())
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the popover before showing the drawer and restores its gear on close', async () => {
|
||||
renderQuery(<AlertsBellPopover summary={summary} onClose={vi.fn()} />)
|
||||
const user = userEvent.setup()
|
||||
const gear = await screen.findByRole('button', { name: 'Notification settings' })
|
||||
await user.click(gear)
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('[data-qqq-id="alerts-popover"]')).not.toBeVisible()
|
||||
)
|
||||
const drawer = await screen.findByRole('dialog', { name: 'Notifications' }, { timeout: 5000 })
|
||||
await within(drawer).findByRole('checkbox', { name: 'Email alerts' })
|
||||
await user.click(within(drawer).getByRole('button', { name: 'Close settings' }))
|
||||
await waitFor(() => expect(gear).toHaveFocus())
|
||||
expect(document.querySelector('[data-qqq-id="alerts-popover"]')).toBeVisible()
|
||||
})
|
||||
|
||||
it('fetches preferences lazily when the bell popover opens', async () => {
|
||||
const request = vi.fn()
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
|
||||
http.get('/qqq/v1/preferences', () => {
|
||||
request()
|
||||
return HttpResponse.json({ sections: [] })
|
||||
})
|
||||
)
|
||||
renderQuery(
|
||||
<AlertsBell metaData={{ apps: { alerts: { name: 'alerts' } } } as unknown as QInstance} />
|
||||
)
|
||||
const bell = await screen.findByRole('button', { name: 'Notifications, none unread' })
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
await userEvent.setup().click(bell)
|
||||
await waitFor(() => expect(request).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 Section-gated notification gear; its drawer and form load only when requested. */
|
||||
'use client'
|
||||
|
||||
import { lazy, Suspense, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { getPreferences } from '@/lib/api/preferences'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
|
||||
const PreferenceSectionDrawer = lazy(
|
||||
() => import('@/components/preferences/PreferenceSectionDrawer')
|
||||
)
|
||||
|
||||
/** Shows the shortcut only for a section visible to the current user.
|
||||
* @param props - Summary section name and optional popover visibility callback.
|
||||
* @returns Gear and lazily loaded drawer, or nothing.
|
||||
*/
|
||||
export function NotificationSettingsButton({
|
||||
sectionName,
|
||||
onOpenChange,
|
||||
}: {
|
||||
sectionName?: string | null
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.preferencesIndex(),
|
||||
queryFn: getPreferences,
|
||||
enabled: Boolean(sectionName),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
const section = query.data?.sections.find((section) => section.name === sectionName)
|
||||
const [open, setOpen] = useState(false)
|
||||
const gear = useRef<HTMLButtonElement>(null)
|
||||
const changeOpen = (next: boolean) => {
|
||||
onOpenChange?.(next)
|
||||
setOpen(next)
|
||||
}
|
||||
if (!section) return null
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={gear}
|
||||
type="button"
|
||||
aria-label="Notification settings"
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => changeOpen(true)}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="notifications-settings-gear"
|
||||
>
|
||||
<Settings className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<span role="status" className="sr-only">
|
||||
Loading settings…
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<PreferenceSectionDrawer
|
||||
section={section}
|
||||
open={open}
|
||||
onOpenChange={changeOpen}
|
||||
returnFocusRef={gear}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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 notifications page: metadata gating, filters and paging in the URL, the inbox
|
||||
// query, safe markdown bodies, actions, and empty and error states.
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
|
||||
const nav = vi.hoisted(() => ({ params: new URLSearchParams(), push: vi.fn(), replace: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: nav.push, replace: nav.replace, back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => '/app/notifications',
|
||||
useSearchParams: () => nav.params,
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
|
||||
const meta = vi.hoisted(() => ({ apps: { alerts: { name: 'alerts', label: 'Alerts' } } as Record<string, unknown> }))
|
||||
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn(async () => ({ apps: meta.apps, tables: {}, processes: {} })) }))
|
||||
|
||||
import { NotificationsPage, filterFromParams } from './NotificationsPage'
|
||||
|
||||
/** One inbox item. */
|
||||
function item(id: number, overrides: Record<string, unknown> = {}) {
|
||||
return { id, notificationId: id, title: `Notice ${id}`, severity: 'INFO', category: 'SYSTEM', source: 'QQQ', age: '1 h', unread: false, ...overrides }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the page with a fresh query client.
|
||||
*
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NotificationsPage />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the inbox, recording each request's query.
|
||||
*
|
||||
* @param page - The response.
|
||||
* @returns The recorded queries.
|
||||
*/
|
||||
function serveInbox(page: Record<string, unknown>): URLSearchParams[] {
|
||||
const queries: URLSearchParams[] = []
|
||||
server.use(http.get('/qqq/v1/alerts/inbox', ({ request }) => {
|
||||
queries.push(new URL(request.url).searchParams)
|
||||
return HttpResponse.json(page)
|
||||
}))
|
||||
return queries
|
||||
}
|
||||
|
||||
describe('filterFromParams', () => {
|
||||
it('reads status, category, severity and offset, with safe defaults', () => {
|
||||
expect(filterFromParams(new URLSearchParams('status=unread&category=JOB&severity=ERROR&offset=25'))).toEqual({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: 25, limit: 25 })
|
||||
expect(filterFromParams(new URLSearchParams('status=bogus&offset=-4'))).toEqual({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationsPage', () => {
|
||||
beforeEach(() => {
|
||||
nav.params = new URLSearchParams()
|
||||
nav.push.mockReset()
|
||||
nav.replace.mockReset()
|
||||
meta.apps = { alerts: { name: 'alerts', label: 'Alerts' } }
|
||||
})
|
||||
|
||||
it('sends no inbox request without the Alerts app', async () => {
|
||||
meta.apps = {}
|
||||
const queries = serveInbox({ items: [], total: 0 })
|
||||
renderPage()
|
||||
expect(await screen.findByText('Notifications are not available.')).toBeInTheDocument()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(queries).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lists notifications with markdown bodies rendered without HTML', async () => {
|
||||
const queries = serveInbox({
|
||||
items: [
|
||||
item(5, { title: 'Export finished', severity: 'SUCCESS', category: 'JOB', body: 'Your **export** is ready. <img src=x onerror=alert(1)>', href: '/app/exports/5', linkLabel: 'Download', unread: true }),
|
||||
item(4, { title: 'Key rotated', severity: 'CRITICAL', category: 'SECURITY', body: '[docs](https://qrun.io/docs) and [bad](javascript:alert(1))' }),
|
||||
],
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
})
|
||||
const reads: unknown[] = []
|
||||
server.use(http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
|
||||
reads.push(await request.json())
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderPage()
|
||||
const list = await screen.findByRole('list', { name: 'Notifications' })
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
expect(queries[0].get('status')).toBe('all')
|
||||
expect(queries[0].get('offset')).toBe('0')
|
||||
expect(queries[0].get('limit')).toBe('25')
|
||||
expect(queries[0].has('category')).toBe(false)
|
||||
|
||||
const items = within(list).getAllByRole('listitem')
|
||||
expect(items[0]).toHaveTextContent('Job · QQQ · 1 h ago')
|
||||
expect(within(items[0]).getByText('export').tagName).toBe('STRONG')
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(within(items[0]).getByText('(unread)', { exact: false })).toBeInTheDocument()
|
||||
expect(within(items[1]).getByText('Critical')).toBeInTheDocument()
|
||||
expect(within(items[1]).getByRole('link', { name: 'docs' })).toHaveAttribute('href', 'https://qrun.io/docs')
|
||||
expect(within(items[1]).queryByRole('link', { name: 'bad' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1–2 of 2')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(items[0]).getByRole('link', { name: 'Download' }))
|
||||
expect(nav.push).toHaveBeenCalledWith('/app/exports/5')
|
||||
await waitFor(() => expect(reads).toEqual([{ id: 5 }]))
|
||||
})
|
||||
|
||||
it('puts filters in the URL and sends them to the inbox', async () => {
|
||||
nav.params = new URLSearchParams('status=unread&category=SECURITY&severity=ERROR&offset=25')
|
||||
const queries = serveInbox({ items: [], total: 0, offset: 25, limit: 25 })
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
expect(await screen.findByText('No notifications match these filters.')).toBeInTheDocument()
|
||||
expect(queries[0].get('status')).toBe('unread')
|
||||
expect(queries[0].get('category')).toBe('SECURITY')
|
||||
expect(queries[0].get('severity')).toBe('ERROR')
|
||||
expect(queries[0].get('offset')).toBe('25')
|
||||
expect(screen.getByRole('button', { name: 'Unread' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'All' }))
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?category=SECURITY&severity=ERROR', { scroll: false })
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Category' }), 'RELEASE')
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?status=unread&category=RELEASE&severity=ERROR', { scroll: false })
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), '')
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?status=unread&category=SECURITY', { scroll: false })
|
||||
})
|
||||
|
||||
it('pages through the inbox', async () => {
|
||||
nav.params = new URLSearchParams('offset=25')
|
||||
serveInbox({ items: Array.from({ length: 25 }, (_, index) => item(100 - index)), total: 60, offset: 25, limit: 25 })
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
expect(await screen.findByText('26–50 of 60')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?offset=50', { scroll: false })
|
||||
await user.click(screen.getByRole('button', { name: 'Previous' }))
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications', { scroll: false })
|
||||
})
|
||||
|
||||
it('marks read, unread and all read, and dismisses at once', async () => {
|
||||
serveInbox({ items: [item(1, { unread: true }), item(2)], total: 2, offset: 0, limit: 25 })
|
||||
const bodies: Array<[string, unknown]> = []
|
||||
server.use(http.post('/qqq/v1/alerts/inbox/:action', async ({ request, params }) => {
|
||||
bodies.push([String(params.action), await request.json()])
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
await screen.findByRole('list', { name: 'Notifications' })
|
||||
await user.click(screen.getByRole('button', { name: 'Mark read: Notice 1' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mark unread: Notice 2' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mark all read' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Dismiss: Notice 2' }))
|
||||
await waitFor(() => expect(bodies).toEqual([['read', { id: 1 }], ['unread', { id: 2 }], ['read', { all: true }], ['dismiss', { id: 2 }]]))
|
||||
})
|
||||
|
||||
it('shows older items without actions, and the empty and error states', async () => {
|
||||
serveInbox({ items: [{ alertId: 3, title: 'Legacy alert', severity: 'ERROR', group: 'CDN', age: '2 h', unread: true }], total: 1 })
|
||||
const first = renderPage()
|
||||
const list = await screen.findByRole('list', { name: 'Notifications' })
|
||||
expect(list).toHaveTextContent('CDN · 2 h ago')
|
||||
expect(within(list).queryByRole('button')).not.toBeInTheDocument()
|
||||
first.unmount()
|
||||
|
||||
serveInbox({ items: [], total: 0 })
|
||||
const empty = renderPage()
|
||||
expect(await screen.findByText('You have no notifications.')).toBeInTheDocument()
|
||||
empty.unmount()
|
||||
|
||||
server.use(http.get('/qqq/v1/alerts/inbox', () => new HttpResponse(null, { status: 500 })))
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load notifications.')
|
||||
serveInbox({ items: [item(9)], total: 1 })
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Notice 9')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens notification preferences from the page header and restores focus to its gear', async () => {
|
||||
serveInbox({ items: [], total: 0 })
|
||||
renderPage()
|
||||
const user = userEvent.setup()
|
||||
const gear = await screen.findByRole('button', { name: 'Notification settings' })
|
||||
await user.click(gear)
|
||||
const drawer = await screen.findByRole('dialog', { name: 'Notifications' }, { timeout: 5000 })
|
||||
await within(drawer).findByRole('checkbox', { name: 'Email alerts' })
|
||||
expect(within(drawer).getByRole('link', { name: 'Manage all preferences' })).toHaveAttribute('href', '/app/preferences?section=notifications')
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(gear).toHaveFocus())
|
||||
})
|
||||
|
||||
it('hides the header gear when its summary section is not installed', async () => {
|
||||
serveInbox({ items: [], total: 0 })
|
||||
server.use(http.get('/qqq/v1/preferences', () => HttpResponse.json({ sections: [] })))
|
||||
renderPage()
|
||||
await screen.findByText('You have no notifications.')
|
||||
expect(screen.queryByRole('button', { name: 'Notification settings' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* 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 NotificationsPage — the notification center's full list at `/app/notifications`
|
||||
* (loaded on demand): All/Unread, category and severity filters, paging, markdown bodies
|
||||
* (rendered without HTML), and the bell's actions. Filters and the offset live in the URL.
|
||||
* Like the bell, it sends no request unless the instance metadata announces Alerts.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { getAlertsInbox } from '@/lib/api/alerts'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import type { InboxFilter, InboxItem } from '@/types/alerts'
|
||||
import { NOTIFICATION_CATEGORIES, NOTIFICATION_SEVERITIES } from '@/types/alerts'
|
||||
import { BUTTON_CLASS } from '@/components/ui/controls'
|
||||
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
|
||||
import { NotificationSettingsButton } from './NotificationSettingsButton'
|
||||
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
|
||||
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest, severityLabel } from './NotificationParts'
|
||||
|
||||
/** Items per page. */
|
||||
export const PAGE_SIZE = 25
|
||||
|
||||
const SELECT_CLASS = 'rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
|
||||
/**
|
||||
* "SECURITY" → "Security".
|
||||
*
|
||||
* @param value - An upper-case name.
|
||||
* @returns The label.
|
||||
*/
|
||||
const titleCase = (value: string) => value.charAt(0) + value.slice(1).toLowerCase()
|
||||
|
||||
/**
|
||||
* Reads the filter from the URL.
|
||||
*
|
||||
* @param params - The search params.
|
||||
* @returns The filter.
|
||||
*/
|
||||
export function filterFromParams(params: URLSearchParams): InboxFilter {
|
||||
const offset = Number.parseInt(params.get('offset') ?? '0', 10)
|
||||
return {
|
||||
status: params.get('status') === 'unread' ? 'unread' : 'all',
|
||||
category: params.get('category') ?? '',
|
||||
severity: params.get('severity') ?? '',
|
||||
offset: Number.isFinite(offset) && offset > 0 ? offset : 0,
|
||||
limit: PAGE_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's URL for a filter (defaults left out).
|
||||
*
|
||||
* @param filter - The filter.
|
||||
* @returns The path with its query.
|
||||
*/
|
||||
function pagePath(filter: InboxFilter): string {
|
||||
const params = new URLSearchParams()
|
||||
if (filter.status === 'unread') params.set('status', 'unread')
|
||||
if (filter.category) params.set('category', filter.category)
|
||||
if (filter.severity) params.set('severity', filter.severity)
|
||||
if (filter.offset > 0) params.set('offset', String(filter.offset))
|
||||
const query = params.toString()
|
||||
return `/app/notifications${query ? `?${query}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The loading indicator.
|
||||
*
|
||||
* @returns The indicator.
|
||||
*/
|
||||
function Loading() {
|
||||
return (
|
||||
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading notifications…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The notifications page.
|
||||
*
|
||||
* @returns The page.
|
||||
*/
|
||||
export function NotificationsPage() {
|
||||
const { setPageHeader } = useQContext()
|
||||
useEffect(() => setPageHeader('Notifications'), [setPageHeader])
|
||||
const { data: metaData, isLoading } = useMetaData()
|
||||
if (isLoading) return <Loading />
|
||||
if (!isAlertsInstalled(metaData)) {
|
||||
return (
|
||||
<p className="p-6 text-sm text-muted-foreground" data-qqq-id="notifications-unavailable">
|
||||
Notifications are not available.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return <NotificationsList />
|
||||
}
|
||||
|
||||
/**
|
||||
* The filters, list and paging.
|
||||
*
|
||||
* @returns The list.
|
||||
*/
|
||||
function NotificationsList() {
|
||||
const { data: summary } = useAlertsInbox(true)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const filter = filterFromParams(new URLSearchParams(searchParams?.toString() ?? ''))
|
||||
const navigate = (next: Partial<InboxFilter>) => router.replace(pagePath({ ...filter, offset: 0, ...next }), { scroll: false })
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.alertsInboxPage(filter),
|
||||
queryFn: () => getAlertsInbox(filter),
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
})
|
||||
const actions = useInboxActions()
|
||||
|
||||
const first = data && data.total > 0 ? data.offset + 1 : 0
|
||||
const last = data ? Math.min(data.offset + data.items.length, data.total) : 0
|
||||
const filtered = filter.status === 'unread' || filter.category !== '' || filter.severity !== ''
|
||||
|
||||
/**
|
||||
* Follows an item's link, marking it read.
|
||||
*
|
||||
* @param item - The item.
|
||||
* @param event - The click.
|
||||
*/
|
||||
const follow = (item: InboxItem, event: React.MouseEvent) => {
|
||||
const read = readRequest(item)
|
||||
if (read) actions.mutate(read)
|
||||
if (linkKind(item.href) === 'app' && item.href) {
|
||||
event.preventDefault()
|
||||
router.push(item.href)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4" data-qqq-id="notifications-page">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Notifications</h1>
|
||||
<NotificationSettingsButton sectionName={summary?.preferencesSection} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div role="group" aria-label="Show" className="inline-flex rounded-md border border-input bg-card p-0.5">
|
||||
{(['all', 'unread'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
aria-pressed={filter.status === status}
|
||||
onClick={() => navigate({ status })}
|
||||
className={`rounded px-3 py-1 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${filter.status === status ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent'}`}
|
||||
data-qqq-id={`notifications-status-${status}`}
|
||||
>
|
||||
{status === 'all' ? 'All' : 'Unread'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Category
|
||||
<select value={filter.category} onChange={(event) => navigate({ category: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-category">
|
||||
<option value="">All</option>
|
||||
{NOTIFICATION_CATEGORIES.map((category) => (
|
||||
<option key={category} value={category}>{titleCase(category)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Severity
|
||||
<select value={filter.severity} onChange={(event) => navigate({ severity: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-severity">
|
||||
<option value="">All</option>
|
||||
{NOTIFICATION_SEVERITIES.map((severity) => (
|
||||
<option key={severity} value={severity}>{severityLabel(severity)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BUTTON_CLASS} sm:ml-auto`}
|
||||
disabled={!data?.items.some((item) => item.unread)}
|
||||
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
|
||||
data-qqq-id="notifications-mark-all-read"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : isError ? (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">
|
||||
Could not load notifications.
|
||||
<button type="button" className={BUTTON_CLASS} onClick={() => void refetch()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<p className="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground" data-qqq-id="notifications-empty">
|
||||
{filtered ? 'No notifications match these filters.' : 'You have no notifications.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" aria-label="Notifications">
|
||||
{data.items.map((item) => {
|
||||
const kind = linkKind(item.href)
|
||||
return (
|
||||
<li
|
||||
key={item.key}
|
||||
className={`flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-80'}`}
|
||||
data-qqq-id={`notifications-item-${item.key}`}
|
||||
>
|
||||
<SeverityIcon severity={item.severity} />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h2 className={`break-words text-sm text-foreground ${item.unread ? 'font-semibold' : 'font-medium'}`}>
|
||||
{item.unread && <span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-primary align-middle" aria-hidden="true" />}
|
||||
{item.title}
|
||||
{item.unread && <span className="sr-only"> (unread)</span>}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{itemMeta(item, true)}</p>
|
||||
{item.body && <MarkdownView source={item.body} />}
|
||||
{kind && (
|
||||
<a
|
||||
href={item.href ?? undefined}
|
||||
target={kind === 'external' ? '_blank' : undefined}
|
||||
rel={kind === 'external' ? 'noopener noreferrer' : undefined}
|
||||
onClick={(event) => follow(item, event)}
|
||||
className="inline-block font-medium text-primary underline underline-offset-2"
|
||||
>
|
||||
{item.linkLabel ?? 'Open'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data && data.total > 0 && (
|
||||
<nav aria-label="Pages" className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||
<span>{`${first}–${last} of ${data.total}`}</span>
|
||||
<span className="flex gap-2">
|
||||
<button type="button" className={BUTTON_CLASS} disabled={filter.offset === 0} onClick={() => navigate({ offset: Math.max(0, filter.offset - PAGE_SIZE) })} data-qqq-id="notifications-previous">
|
||||
Previous
|
||||
</button>
|
||||
<button type="button" className={BUTTON_CLASS} disabled={filter.offset + PAGE_SIZE >= data.total} onClick={() => navigate({ offset: filter.offset + PAGE_SIZE })} data-qqq-id="notifications-next">
|
||||
Next
|
||||
</button>
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 SeverityDot — a small colored dot for an alert's severity, with its name for screen
|
||||
* readers.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { AlertSeverity } from '@/types/alerts'
|
||||
|
||||
// mid-tone colors that read on light and dark cards; inline so the dot adds no CSS
|
||||
const COLORS: Record<AlertSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
ERROR: '#ef4444',
|
||||
WARNING: '#d97706',
|
||||
INFO: '#2563eb',
|
||||
}
|
||||
|
||||
const LABELS: Record<AlertSeverity, string> = {
|
||||
CRITICAL: 'Critical',
|
||||
ERROR: 'Error',
|
||||
WARNING: 'Warning',
|
||||
INFO: 'Info',
|
||||
}
|
||||
|
||||
/**
|
||||
* The dot.
|
||||
*
|
||||
* @param props - The severity.
|
||||
* @returns A dot with a screen-reader label.
|
||||
*/
|
||||
export function SeverityDot({ severity }: { severity: AlertSeverity }) {
|
||||
return (
|
||||
<span className="flex shrink-0 items-center" style={{ marginTop: '0.375rem' }} data-qqq-id={`alerts-severity-${severity}`}>
|
||||
<span className="rounded-full" style={{ width: '0.625rem', height: '0.625rem', backgroundColor: COLORS[severity] ?? COLORS.INFO }} aria-hidden="true" />
|
||||
<span className="sr-only">{LABELS[severity] ?? severity}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import { AssistantComposer } from './AssistantComposer'
|
||||
|
||||
/** Composer props with no page context. */
|
||||
const BASE = {
|
||||
pageInfo: null,
|
||||
contextDismissed: false,
|
||||
onDismissContext: () => {},
|
||||
onRefreshContext: () => {},
|
||||
streaming: false,
|
||||
stopping: false,
|
||||
onSend: () => {},
|
||||
onStop: () => {},
|
||||
}
|
||||
|
||||
describe('AssistantComposer attachments', () => {
|
||||
it('hides the attach button unless attaching is offered', () => {
|
||||
render(<AssistantComposer {...BASE} />)
|
||||
expect(screen.queryByRole('button', { name: 'Attach a file' })).toBeNull()
|
||||
})
|
||||
|
||||
it('attaches a picked file, lists attachments and removes one', async () => {
|
||||
const onAttach = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AssistantComposer {...BASE} onAttach={onAttach} onRemoveAttachment={onRemove}
|
||||
attachments={[{ uploadRef: 'up_1', fileName: 'posts.csv' }]} />
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Attach a file' })).toBeEnabled()
|
||||
expect(screen.getByRole('list', { name: 'Attached files' })).toHaveTextContent('posts.csv')
|
||||
|
||||
const input = container.querySelector('[data-qqq-id="assistant-attach-input"]') as HTMLInputElement
|
||||
const file = new File(['Title\nHello\n'], 'more.csv', { type: 'text/csv' })
|
||||
await userEvent.upload(input, file)
|
||||
expect(onAttach).toHaveBeenCalledWith(file)
|
||||
expect(input.accept).toContain('.csv')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Remove posts.csv' }))
|
||||
expect(onRemove).toHaveBeenCalledWith('up_1')
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('shows the upload in progress and disables attaching', () => {
|
||||
render(<AssistantComposer {...BASE} onAttach={() => {}} attaching />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Uploading')
|
||||
expect(screen.getByRole('button', { name: 'Attach a file' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* 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 AssistantComposer — the message box: the dismissible page-context chip, a growing
|
||||
* textarea (Enter sends, Shift+Enter adds a line), and Send or Stop.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { FileText, Loader2, MapPin, Paperclip, SendHorizontal, Square, X } from 'lucide-react'
|
||||
|
||||
import type { AssistantAttachment } from '@/types/assistant'
|
||||
import type { AssistantPageInfo } from '@/lib/utils/assistant-links'
|
||||
|
||||
/** Props of {@link AssistantComposer}. */
|
||||
interface AssistantComposerProps {
|
||||
/** The current page as context, or null. */
|
||||
pageInfo: AssistantPageInfo | null
|
||||
/** Whether the user removed the context for this page. */
|
||||
contextDismissed: boolean
|
||||
/** Removes the page context. */
|
||||
onDismissContext: () => void
|
||||
/** Re-reads the page context (the filter can change without a navigation). */
|
||||
onRefreshContext: () => void
|
||||
/** Whether a reply is streaming in this conversation. */
|
||||
streaming: boolean
|
||||
/** Whether a stop was requested. */
|
||||
stopping: boolean
|
||||
/**
|
||||
* Sends the text.
|
||||
*
|
||||
* @param text - The message.
|
||||
*/
|
||||
onSend: (text: string) => void
|
||||
/** Stops the reply. */
|
||||
onStop: () => void
|
||||
/** Text to put in the box (not sent); a new `id` replaces the current text. */
|
||||
prefill?: { id: number; text: string } | null
|
||||
/** Files attached to this conversation (sent with each message). */
|
||||
attachments?: AssistantAttachment[]
|
||||
/**
|
||||
* Uploads and attaches a file; when absent, the attach button is not shown.
|
||||
*
|
||||
* @param file - The picked file.
|
||||
*/
|
||||
onAttach?: (file: File) => void
|
||||
/**
|
||||
* Removes an attachment.
|
||||
*
|
||||
* @param uploadRef - Its upload ref.
|
||||
*/
|
||||
onRemoveAttachment?: (uploadRef: string) => void
|
||||
/** Whether an upload is in progress. */
|
||||
attaching?: boolean
|
||||
}
|
||||
|
||||
/** File types the attach button offers (bulk load files). */
|
||||
const ATTACH_ACCEPT = '.csv,.tsv,.txt,.xlsx'
|
||||
|
||||
/** Tallest the textarea grows before it scrolls, in pixels. */
|
||||
const MAX_TEXTAREA_HEIGHT = 160
|
||||
|
||||
/**
|
||||
* The composer.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param ref - The textarea, focused when the panel opens.
|
||||
* @returns The composer form.
|
||||
*/
|
||||
function AssistantComposerInner(
|
||||
{ pageInfo, contextDismissed, onDismissContext, onRefreshContext, streaming, stopping, onSend, onStop, prefill, attachments = [], onAttach, onRemoveAttachment, attaching = false }: AssistantComposerProps,
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||
) {
|
||||
const [text, setText] = useState('')
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const localRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// A deep link pre-fills the box; the user reviews and sends it
|
||||
useEffect(() => {
|
||||
if (!prefill) return
|
||||
setText(prefill.text)
|
||||
const element = localRef.current
|
||||
if (element) {
|
||||
element.focus()
|
||||
element.setSelectionRange(prefill.text.length, prefill.text.length)
|
||||
}
|
||||
}, [prefill])
|
||||
|
||||
// Grow with the text up to MAX_TEXTAREA_HEIGHT
|
||||
useLayoutEffect(() => {
|
||||
const element = localRef.current
|
||||
if (!element) return
|
||||
element.style.height = 'auto'
|
||||
element.style.height = `${Math.min(element.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`
|
||||
}, [text])
|
||||
|
||||
/** Sends the text unless empty or a reply is streaming. */
|
||||
const submit = () => {
|
||||
if (!text.trim() || streaming) return
|
||||
onSend(text)
|
||||
setText('')
|
||||
}
|
||||
|
||||
const showContext = pageInfo && !contextDismissed
|
||||
return (
|
||||
<form
|
||||
className="shrink-0 border-t border-border bg-card px-3 pb-3 pt-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
data-qqq-id="assistant-composer-form"
|
||||
>
|
||||
{showContext && (
|
||||
<div className="mb-2 flex min-w-0">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full border border-border bg-muted py-0.5 pl-2 pr-0.5 text-xs text-muted-foreground" data-qqq-id="assistant-context-chip">
|
||||
<MapPin className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">On: {pageInfo.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissContext}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-8 pointer-coarse:w-8"
|
||||
aria-label={`Don't send page context (${pageInfo.label})`}
|
||||
data-qqq-id="assistant-context-dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(attachments.length > 0 || attaching) && (
|
||||
<ul className="mb-2 flex min-w-0 flex-wrap gap-1.5" aria-label="Attached files" data-qqq-id="assistant-attachments">
|
||||
{attachments.map((attachment) => (
|
||||
<li key={attachment.uploadRef} className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full border border-border bg-muted py-0.5 pl-2 pr-0.5 text-xs text-muted-foreground" data-qqq-id="assistant-attachment-chip">
|
||||
<FileText className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{attachment.fileName ?? 'Attached file'}</span>
|
||||
{onRemoveAttachment && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveAttachment(attachment.uploadRef)}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-8 pointer-coarse:w-8"
|
||||
aria-label={`Remove ${attachment.fileName ?? 'the attached file'}`}
|
||||
data-qqq-id="assistant-attachment-remove"
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{attaching && (
|
||||
<li className="inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-2 pr-2 text-xs text-muted-foreground" role="status" data-qqq-id="assistant-attaching">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
Uploading...
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex items-end gap-2 rounded-lg border border-input bg-background px-2 py-1.5 focus-within:ring-2 focus-within:ring-ring">
|
||||
{onAttach && (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={ATTACH_ACCEPT}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (file) onAttach(file)
|
||||
}}
|
||||
data-qqq-id="assistant-attach-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={attaching}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 pointer-coarse:h-11 pointer-coarse:w-11"
|
||||
aria-label="Attach a file"
|
||||
title="Attach a file (CSV or Excel) for a bulk load"
|
||||
data-qqq-id="assistant-attach"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<label htmlFor={inputId} className="sr-only">Message the assistant</label>
|
||||
<textarea
|
||||
id={inputId}
|
||||
ref={(element) => {
|
||||
localRef.current = element
|
||||
if (typeof ref === 'function') ref(element)
|
||||
else if (ref) ref.current = element
|
||||
}}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onFocus={onRefreshContext}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
placeholder="Ask about your data..."
|
||||
aria-describedby={hintId}
|
||||
className="max-h-40 min-h-6 min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-1 text-base text-foreground placeholder:text-muted-foreground focus:outline-none md:text-sm"
|
||||
data-qqq-id="assistant-composer"
|
||||
/>
|
||||
{streaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
disabled={stopping}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 pointer-coarse:h-11 pointer-coarse:w-11"
|
||||
aria-label={stopping ? 'Stopping' : 'Stop the reply'}
|
||||
data-qqq-id="assistant-stop"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!text.trim()}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 pointer-coarse:h-11 pointer-coarse:w-11"
|
||||
aria-label="Send"
|
||||
data-qqq-id="assistant-send"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p id={hintId} className="mt-1 hidden text-[11px] text-muted-foreground pointer-fine:block">Enter to send, Shift+Enter for a new line</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** The assistant composer; its ref is the textarea. */
|
||||
export const AssistantComposer = forwardRef(AssistantComposerInner)
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 AssistantHeaderButton — the header's assistant toggle with its unread badge; renders
|
||||
* nothing when the assistant is unavailable.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
import { useAssistant } from './assistant-context'
|
||||
|
||||
/**
|
||||
* Toggles the assistant panel; the badge counts unread replies.
|
||||
*
|
||||
* @returns The button, or null when the assistant is unavailable.
|
||||
*/
|
||||
export function AssistantHeaderButton() {
|
||||
const assistant = useAssistant()
|
||||
if (!assistant?.enabled) return null
|
||||
const { open, unreadCount, toggle, triggerRef } = assistant
|
||||
const label = unreadCount > 0 ? `Assistant, ${unreadCount} unread (Ctrl+J or ⌘J)` : 'Assistant (Ctrl+J or ⌘J)'
|
||||
return (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
// WebKit can activate a button without focusing it; focus returns here on close
|
||||
event.currentTarget.focus({ preventScroll: true })
|
||||
toggle()
|
||||
}}
|
||||
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={label}
|
||||
aria-pressed={open}
|
||||
data-qqq-id="button-assistant"
|
||||
>
|
||||
<Sparkles className="h-5 w-5 text-foreground/60" aria-hidden="true" />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
data-qqq-id="assistant-unread-badge"
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Markdown and streaming regressions for the owner's media breakdown.
|
||||
import React from 'react'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AssistantMarkdown } from './AssistantMarkdown'
|
||||
import { messageText, optimisticTurnMessages, reduceStreamEvent } from './message-reducer'
|
||||
import { SseParser } from '@/lib/api/sse-parser'
|
||||
import { toAssistantEvent } from '@/lib/api/assistant'
|
||||
|
||||
const ANSWER = 'Here is the breakdown:\n\n| Type | Count |\n|---|---|\n| JPEG (image/jpeg) | 81 |\n| PNG (image/png) | 20 |\n| WebP, GIF, SVG | 0 |\n\nYou can open the lists filtered by JPEG or PNG.'
|
||||
|
||||
describe('AssistantMarkdown', () => {
|
||||
it.each([false, true])('renders the exact answer as a table (streamed: %s)', (streamed) => {
|
||||
let text = ANSWER
|
||||
if (streamed) {
|
||||
const ids = { userId: -1, assistantId: -2 }
|
||||
let messages = optimisticTurnMessages('Count media', ids, '')
|
||||
for (let offset = 0; offset < ANSWER.length; offset += 7) {
|
||||
messages = reduceStreamEvent(messages, { type: 'text.delta', seq: offset + 1, messageId: -2, text: ANSWER.slice(offset, offset + 7) }, ids)
|
||||
}
|
||||
text = messageText(messages[1])
|
||||
expect(text).toBe(ANSWER)
|
||||
}
|
||||
render(<AssistantMarkdown text={text} />)
|
||||
const table = screen.getByRole('table')
|
||||
const rows = within(table).getAllByRole('row')
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(within(rows[0]).getAllByRole('columnheader')).toHaveLength(2)
|
||||
rows.slice(1).forEach((row) => expect(within(row).getAllByRole('cell')).toHaveLength(2))
|
||||
expect(within(table).getAllByRole('cell').map((cell) => cell.textContent)).toEqual(['JPEG (image/jpeg)', '81', 'PNG (image/png)', '20', 'WebP, GIF, SVG', '0'])
|
||||
})
|
||||
|
||||
it('renders headings, lists, soft breaks, strikethrough and disabled task inputs', () => {
|
||||
const { container } = render(<AssistantMarkdown text={'# Heading\n\n- First\n- Second\n\nLine one\nLine two\n\n~~Removed~~\n\n- [x] Done'} />)
|
||||
expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('list')[0].tagName).toBe('UL')
|
||||
expect(container.querySelector('p br')).toBeInTheDocument()
|
||||
expect(container.querySelector('del')).toHaveTextContent('Removed')
|
||||
expect(screen.getByRole('checkbox')).toBeDisabled()
|
||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||
})
|
||||
|
||||
it('preserves GFM numeric alignment while still blocking HTML and image loads', () => {
|
||||
const { container } = render(<AssistantMarkdown text={'| Type | Count |\n|:---|---:|\n| JPEG | 81 |\n\n<script>alert(1)</script>\n\n'} />)
|
||||
expect(screen.getByRole('columnheader', { name: 'Count' })).toHaveStyle({ textAlign: 'right' })
|
||||
expect(screen.getByRole('cell', { name: '81' })).toHaveStyle({ textAlign: 'right' })
|
||||
expect(container.querySelector('script, img')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves multiline SSE data and escaped text newlines through API decoding', () => {
|
||||
const parser = new SseParser()
|
||||
const [frame] = parser.push('id: 1\nevent: text.delta\ndata: {"messageId":2,\ndata: "text":"first\\nsecond"}\n\n')
|
||||
expect(frame.data).toBe('{"messageId":2,\n"text":"first\\nsecond"}')
|
||||
expect(toAssistantEvent(frame).event).toEqual({ type: 'text.delta', seq: 1, messageId: 2, text: 'first\nsecond' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantMarkdown — renders model-written markdown safely: raw HTML is dropped, the
|
||||
* tree is sanitized, images never load, and only same-origin `/app` links are links (others
|
||||
* are plain text with a copy button), so model text cannot exfiltrate data or inject markup.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import ReactMarkdown, { defaultUrlTransform, type Components } from 'react-markdown'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
|
||||
/** Keep GitHub's table/align, del and disabled checkbox rules; strip model styles and ids. */
|
||||
const SCHEMA = {
|
||||
...defaultSchema,
|
||||
clobberPrefix: 'assistant-md-',
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
'*': (defaultSchema.attributes?.['*'] ?? []).filter((attribute) => !['className', 'id', 'style'].includes(String(attribute))),
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies text to the clipboard and confirms it.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.text - The text to copy.
|
||||
* @returns The button.
|
||||
*/
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(text).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}).catch(() => undefined)
|
||||
}}
|
||||
className="ml-1 inline-flex h-5 w-5 items-center justify-center rounded align-middle text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-8 pointer-coarse:w-8"
|
||||
aria-label={copied ? 'Copied' : `Copy link ${text}`}
|
||||
data-qqq-id="assistant-copy-link"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" aria-hidden="true" /> : <Copy className="h-3 w-3" aria-hidden="true" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Element overrides: safe links, blocked images, focusable scrolling code blocks. */
|
||||
const COMPONENTS: Components = {
|
||||
a: ({ href, children }) => {
|
||||
const internal = internalAppHref(href)
|
||||
if (internal) {
|
||||
return <Link href={internal} className="font-medium text-primary underline underline-offset-2 hover:text-primary/80" data-qqq-id="assistant-link">{children}</Link>
|
||||
}
|
||||
const text = typeof href === 'string' ? href : ''
|
||||
const childText = React.Children.toArray(children).join('')
|
||||
return (
|
||||
<span data-qqq-id="assistant-external-link">
|
||||
{children}
|
||||
{text && childText !== text && <span className="break-all text-muted-foreground"> ({text})</span>}
|
||||
{text && <CopyButton text={text} />}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
img: ({ alt }) => <span className="text-muted-foreground">[image{alt ? `: ${alt}` : ''} not shown]</span>,
|
||||
pre: ({ children }) => (
|
||||
<pre tabIndex={0} data-qqq-id="markdown-code" className="my-2 overflow-x-auto rounded-md bg-muted p-2 text-xs focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">{children}</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div tabIndex={0} data-qqq-id="markdown-table" role="region" aria-label="Table" className="my-2 overflow-x-auto focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<table className="w-full text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
// react-markdown converts sanitized GFM align attributes to textAlign before these run.
|
||||
th: ({ style, children }) => <th scope="col" style={{ textAlign: style?.textAlign ?? 'left' }}>{children}</th>,
|
||||
td: ({ style, children }) => <td style={{ textAlign: style?.textAlign ?? 'left' }}>{children}</td>,
|
||||
}
|
||||
|
||||
/** Comment-only links; arbitrary markdown never turns into executable links. */
|
||||
const COMMENT_COMPONENTS: Components = {
|
||||
...COMPONENTS,
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith('user:')) return <span className="rounded-full bg-primary/10 px-1.5 py-0.5 font-medium text-primary" data-qqq-id="mention-chip">{children}</span>
|
||||
const match = href?.match(/^record:([^/]+)\/(.+)$/)
|
||||
const internal = internalAppHref(match ? `/app/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}` : href)
|
||||
return internal ? <Link href={internal} className="text-primary underline" data-qqq-id="comment-record-link">{children}</Link> : <span>{children}</span>
|
||||
},
|
||||
}
|
||||
const COMMENT_SCHEMA = { ...SCHEMA, protocols: { ...SCHEMA.protocols, href: [...(SCHEMA.protocols?.href ?? []), 'user', 'record'] } }
|
||||
|
||||
/** Typography for the rendered markdown (the app has no typography plugin). */
|
||||
const PROSE = [
|
||||
'min-w-0 break-words text-sm leading-relaxed text-foreground',
|
||||
'[&_p]:my-2 first:[&_p]:mt-0 last:[&_p]:mb-0',
|
||||
'[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5',
|
||||
'[&_h1]:mt-3 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-3 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-2 [&_h3]:text-sm [&_h3]:font-semibold',
|
||||
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:font-mono [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_thead]:bg-muted [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_td]:tabular-nums',
|
||||
].join(' ')
|
||||
|
||||
/**
|
||||
* Renders assistant markdown.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.text - Markdown from the model or comment.
|
||||
* @param props.mentions - Enables non-navigating mention chips and validated record links for comments.
|
||||
* @returns The rendered markdown.
|
||||
*/
|
||||
export function AssistantMarkdown({ text, mentions = false }: { text: string; mentions?: boolean }) {
|
||||
return (
|
||||
<div className={PROSE} data-qqq-id="assistant-markdown">
|
||||
<ReactMarkdown skipHtml remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[[rehypeSanitize, mentions ? COMMENT_SCHEMA : SCHEMA]]} components={mentions ? COMMENT_COMPONENTS : COMPONENTS} urlTransform={(url) => mentions && /^(user|record):/.test(url) ? url : defaultUrlTransform(url)}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* AssistantMessageView stories — the assistant panel's message rendering: user text, a reply
|
||||
* with a tool step, records table, record chips and markdown, a streaming reply, and notices.
|
||||
*/
|
||||
import React from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import type { AssistantMessage } from '@/types/assistant'
|
||||
import { AssistantMessageView } from './AssistantMessageView'
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } })
|
||||
|
||||
const meta = {
|
||||
title: 'Assistant/AssistantMessageView',
|
||||
component: AssistantMessageView,
|
||||
parameters: { layout: 'padded' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="@container w-full max-w-[390px] space-y-4 bg-card p-3">
|
||||
<Story />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AssistantMessageView>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const BASE = { turnId: 9, createDate: '2026-10-12T00:00:00Z' }
|
||||
|
||||
/** The user's question. */
|
||||
export const UserMessage: Story = {
|
||||
args: { message: { ...BASE, id: 10, role: 'USER', status: 'COMPLETE', parts: [{ type: 'text', text: 'Who is active?' }] } as AssistantMessage },
|
||||
}
|
||||
|
||||
/** A finished reply with a query step, its records, chips, markdown and a navigation. */
|
||||
export const Reply: Story = {
|
||||
args: {
|
||||
message: {
|
||||
...BASE,
|
||||
id: 11,
|
||||
role: 'ASSISTANT',
|
||||
status: 'COMPLETE',
|
||||
parts: [
|
||||
{
|
||||
type: 'tool', toolCallId: 't1', name: 'query_records', summary: 'Queried People · 2 rows', outcome: 'OK',
|
||||
render: {
|
||||
type: 'records', tableName: 'person', tableLabel: 'People', truncated: false, totalCount: 2, openHref: '/app/person',
|
||||
columns: [{ name: 'firstName', label: 'First Name' }, { name: 'lastName', label: 'Last Name' }, { name: 'status', label: 'Status' }],
|
||||
rows: [
|
||||
{ id: 1, href: '/app/person/1', values: { firstName: 'Alice', lastName: 'Johnson', status: 'Active' } },
|
||||
{ id: 2, href: '/app/person/2', values: { firstName: 'Bob', lastName: 'Martinez', status: 'Active' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'record_refs', refs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }] },
|
||||
{ type: 'text', text: 'Two people are **active**. See [Alice](/app/person/1); [external](https://example.com) links stay plain text.' },
|
||||
{ type: 'navigate', href: '/app/person/1' },
|
||||
],
|
||||
} as AssistantMessage,
|
||||
},
|
||||
}
|
||||
|
||||
/** A reply still streaming, with a running tool step. */
|
||||
export const Streaming: Story = {
|
||||
args: {
|
||||
message: {
|
||||
...BASE,
|
||||
id: 12,
|
||||
role: 'ASSISTANT',
|
||||
status: 'STREAMING',
|
||||
parts: [{ type: 'tool', toolCallId: 't2', name: 'count_records', summary: 'Counting People' }],
|
||||
} as AssistantMessage,
|
||||
},
|
||||
}
|
||||
|
||||
/** A denied tool call and an error notice. */
|
||||
export const DeniedAndError: Story = {
|
||||
args: {
|
||||
message: {
|
||||
...BASE,
|
||||
id: 13,
|
||||
role: 'ASSISTANT',
|
||||
status: 'ERROR',
|
||||
parts: [
|
||||
{ type: 'tool', toolCallId: 't3', name: 'get_record_history', summary: 'Record history is not available to you', outcome: 'DENIED' },
|
||||
{ type: 'notice', level: 'error', text: 'The daily assistant budget was reached.' },
|
||||
],
|
||||
} as AssistantMessage,
|
||||
},
|
||||
}
|
||||
|
||||
const mediaHref = (type: string) => `/app/media?filter=${encodeURIComponent(JSON.stringify({ criteria: [{ fieldName: 'mimeType', operator: 'EQUALS', values: [type] }], booleanOperator: 'AND' }))}`
|
||||
const jpegHref = mediaHref('image/jpeg')
|
||||
const pngHref = mediaHref('image/png')
|
||||
|
||||
/** The owner's media answer, in a phone-width panel. Switch Storybook's theme to verify dark mode. */
|
||||
export const MediaPresentation: Story = {
|
||||
args: {
|
||||
onSend: (prompt) => window.alert(`Follow-up: ${prompt}`),
|
||||
message: {
|
||||
...BASE, id: 14, role: 'ASSISTANT', status: 'COMPLETE',
|
||||
parts: [{
|
||||
type: 'presentation',
|
||||
summary: '101 media files are images: 81 JPEG and 20 PNG.',
|
||||
markdown: '101 media files are images: 81 JPEG and 20 PNG.',
|
||||
blocks: [
|
||||
{ kind: 'table', title: 'Media by type',
|
||||
columns: [{ key: 'type', label: 'Type' }, { key: 'count', label: 'Count', align: 'right' }, { key: 'share', label: 'Share', align: 'right' }],
|
||||
rows: [
|
||||
{ cells: { type: 'JPEG (image/jpeg)', count: 81, share: 0.802 }, display: { share: '80.2%' }, href: jpegHref },
|
||||
{ cells: { type: 'PNG (image/png)', count: 20, share: 0.198 }, display: { share: '19.8%' }, href: pngHref },
|
||||
],
|
||||
},
|
||||
{ kind: 'chart', chartType: 'pie', title: 'Share by type', points: [{ label: 'JPEG', value: 81, href: jpegHref }, { label: 'PNG', value: 20, href: pngHref }] },
|
||||
{ kind: 'filterLinks', items: [{ label: 'Open JPEG media (81)', tableName: 'media', href: jpegHref }, { label: 'Open PNG media (20)', tableName: 'media', href: pngHref }] },
|
||||
{ kind: 'actions', items: [{ label: 'Show the PNG files', prompt: 'Show me the PNG media files' }] },
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* 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 AssistantMessageView — one message of a conversation. Assistant messages render
|
||||
* their parts through {@link PART_RENDERERS}, so later part types (approval cards) slot in
|
||||
* by adding an entry; unknown part types are skipped.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, ChevronRight, CircleX, Info, Loader2, Navigation, ThumbsDown, ThumbsUp } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantNavigatePart,
|
||||
AssistantNoticePart,
|
||||
AssistantPart,
|
||||
AssistantRecordRefsPart,
|
||||
AssistantTextPart,
|
||||
AssistantToolOutcome,
|
||||
AssistantToolPart,
|
||||
} from '@/types/assistant'
|
||||
import { sendAssistantFeedback } from '@/lib/api/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown'
|
||||
import { AssistantPresentation, type AssistantPresentationActions } from './AssistantPresentation'
|
||||
import { AssistantRenderView, RecordChips } from './AssistantRenders'
|
||||
import Link from 'next/link'
|
||||
|
||||
/** Plain-language outcome of a tool call. */
|
||||
const OUTCOME_LABEL: Record<AssistantToolOutcome, string> = {
|
||||
OK: 'Done',
|
||||
DENIED: 'Not permitted',
|
||||
NOT_FOUND: 'Not found',
|
||||
INVALID_INPUT: 'Invalid request',
|
||||
TRUNCATED: 'Partial result',
|
||||
ERROR: 'Failed',
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool call as a compact, expandable step, followed by its structured result.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.part - The tool part.
|
||||
* @returns The step.
|
||||
*/
|
||||
function ToolStep({ part }: { part: AssistantToolPart }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const detailsId = useId()
|
||||
const running = !part.outcome
|
||||
const ok = part.outcome === 'OK' || part.outcome === 'TRUNCATED'
|
||||
return (
|
||||
<div className="space-y-1.5" data-qqq-id="assistant-tool-step" data-outcome={part.outcome ?? 'RUNNING'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
className="flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 motion-safe:animate-spin" aria-hidden="true" />
|
||||
) : ok ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-primary" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{part.summary || part.name}</span>
|
||||
<span className="sr-only">{running ? ', running' : `, ${OUTCOME_LABEL[part.outcome as AssistantToolOutcome] ?? part.outcome}`}</span>
|
||||
<ChevronRight className={cn('h-3 w-3 shrink-0 motion-safe:transition-transform', expanded && 'rotate-90')} aria-hidden="true" />
|
||||
</button>
|
||||
<dl id={detailsId} hidden={!expanded} className="ml-5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 text-xs text-muted-foreground">
|
||||
<dt>Tool</dt>
|
||||
<dd className="break-all font-mono">{part.name}</dd>
|
||||
<dt>Result</dt>
|
||||
<dd>{running ? 'Running' : OUTCOME_LABEL[part.outcome as AssistantToolOutcome] ?? part.outcome}</dd>
|
||||
</dl>
|
||||
{part.render && <AssistantRenderView render={part.render} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A notice inside a reply.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.part - The notice part.
|
||||
* @returns The notice.
|
||||
*/
|
||||
function NoticeView({ part }: { part: AssistantNoticePart }) {
|
||||
const Icon = part.level === 'error' ? CircleX : part.level === 'warning' ? AlertTriangle : Info
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-start gap-1.5 rounded-md border px-2 py-1.5 text-xs',
|
||||
part.level === 'error' ? 'border-destructive/40 text-destructive' : 'border-border text-muted-foreground'
|
||||
)}
|
||||
role={part.level === 'error' ? 'alert' : undefined}
|
||||
data-qqq-id="assistant-notice"
|
||||
data-level={part.level}
|
||||
>
|
||||
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 break-words">{part.text}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the assistant took the user.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.part - The navigate part.
|
||||
* @returns A link back to that page.
|
||||
*/
|
||||
function NavigateView({ part }: { part: AssistantNavigatePart }) {
|
||||
const href = internalAppHref(part.href)
|
||||
if (!href) return null
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" data-qqq-id="assistant-navigate">
|
||||
<Navigation className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>Opened</span>
|
||||
<Link href={href} className="min-w-0 truncate font-medium text-primary hover:underline">{href}</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders one part type. */
|
||||
type PartRenderer<T extends AssistantPart> = (props: { part: T } & AssistantPresentationActions) => React.ReactNode
|
||||
|
||||
/**
|
||||
* Renderers by part type. Phase 2 adds `approval` here; anything without a renderer is skipped.
|
||||
*/
|
||||
export const PART_RENDERERS: { [K in AssistantPart['type']]: PartRenderer<Extract<AssistantPart, { type: K }>> } = {
|
||||
presentation: ({ part, onSend, sending }) => <AssistantPresentation part={part} onSend={onSend} sending={sending} />,
|
||||
text: ({ part }: { part: AssistantTextPart }) => (part.text ? <AssistantMarkdown text={part.text} /> : null),
|
||||
tool: ({ part }: { part: AssistantToolPart }) => <ToolStep part={part} />,
|
||||
record_refs: ({ part }: { part: AssistantRecordRefsPart }) => <RecordChips refs={part.refs} />,
|
||||
navigate: ({ part }: { part: AssistantNavigatePart }) => <NavigateView part={part} />,
|
||||
notice: ({ part }: { part: AssistantNoticePart }) => <NoticeView part={part} />,
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a part through {@link PART_RENDERERS}.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.part - Any part, including types this version does not know.
|
||||
* @returns The rendered part, or null.
|
||||
*/
|
||||
function PartView({ part, onSend, sending }: { part: AssistantPart } & AssistantPresentationActions) {
|
||||
const render = (PART_RENDERERS as Record<string, PartRenderer<AssistantPart> | undefined>)[part.type]
|
||||
return render ? <>{render({ part, onSend, sending })}</> : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbs up or down on a finished reply; thumbs down asks for an optional comment.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.messageId - The assistant message id.
|
||||
* @returns The feedback controls.
|
||||
*/
|
||||
function FeedbackButtons({ messageId }: { messageId: number }) {
|
||||
const [rating, setRating] = useState<1 | -1 | null>(null)
|
||||
const [asking, setAsking] = useState(false)
|
||||
const [comment, setComment] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
const commentId = useId()
|
||||
|
||||
/**
|
||||
* Sends the rating.
|
||||
*
|
||||
* @param value - Up or down.
|
||||
* @param text - Optional comment.
|
||||
*/
|
||||
const send = (value: 1 | -1, text?: string) => {
|
||||
setRating(value)
|
||||
setSent(true)
|
||||
sendAssistantFeedback(messageId, text ? { rating: value, comment: text } : { rating: value }).catch(() => setSent(false))
|
||||
}
|
||||
|
||||
const buttonClass = 'inline-flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11 pointer-coarse:w-11 aria-pressed:text-primary'
|
||||
return (
|
||||
<div className="space-y-1" data-qqq-id="assistant-feedback">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button type="button" className={buttonClass} aria-label="Good response" aria-pressed={rating === 1} disabled={sent && rating === 1} onClick={() => { setAsking(false); send(1) }} data-qqq-id="assistant-feedback-up">
|
||||
<ThumbsUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className={buttonClass} aria-label="Bad response" aria-pressed={rating === -1} disabled={sent && rating === -1} onClick={() => { setRating(-1); setAsking(true) }} data-qqq-id="assistant-feedback-down">
|
||||
<ThumbsDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
{sent && <span className="text-xs text-muted-foreground" role="status">Thanks for the feedback.</span>}
|
||||
</div>
|
||||
{asking && !sent && (
|
||||
<form
|
||||
className="flex items-center gap-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setAsking(false)
|
||||
send(-1, comment.trim() || undefined)
|
||||
}}
|
||||
>
|
||||
<label htmlFor={commentId} className="sr-only">What went wrong? (optional)</label>
|
||||
<input
|
||||
id={commentId}
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="What went wrong? (optional)"
|
||||
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="h-8 rounded-md border border-border px-2 text-xs font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">Send</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One message.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.message - The message.
|
||||
* @returns The message article.
|
||||
*/
|
||||
export function AssistantMessageView({ message, onSend, sending }: { message: AssistantMessage } & AssistantPresentationActions) {
|
||||
if (message.role === 'USER') {
|
||||
const text = message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
|
||||
return (
|
||||
<article className="flex justify-end" aria-label="You" data-qqq-id="assistant-message" data-role="user">
|
||||
<p className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm bg-muted px-3 py-2 text-sm text-foreground">{text}</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
if (message.role === 'SYSTEM_NOTICE') {
|
||||
return (
|
||||
<article className="space-y-1" aria-label="Notice" data-qqq-id="assistant-message" data-role="notice">
|
||||
{message.parts.map((part, index) => <PartView key={index} part={part} />)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
const streaming = message.status === 'STREAMING'
|
||||
return (
|
||||
<article className="min-w-0 space-y-2" aria-label="Assistant" aria-busy={streaming} data-qqq-id="assistant-message" data-role="assistant" data-status={message.status}>
|
||||
{message.parts.map((part, index) => <PartView key={part.type === 'tool' ? part.toolCallId : index} part={part} onSend={onSend} sending={sending || streaming} />)}
|
||||
{streaming && message.parts.length === 0 && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" data-qqq-id="assistant-thinking">
|
||||
<Loader2 className="h-3.5 w-3.5 motion-safe:animate-spin" aria-hidden="true" />
|
||||
Working on it...
|
||||
</p>
|
||||
)}
|
||||
{message.status === 'CANCELLED' && <p className="text-xs text-muted-foreground">Stopped.</p>}
|
||||
{message.status === 'ERROR' && !message.parts.some((part) => part.type === 'notice' && part.level === 'error') && (
|
||||
<p className="text-xs text-destructive" role="alert">The reply did not finish.</p>
|
||||
)}
|
||||
{message.status === 'COMPLETE' && message.id > 0 && <FeedbackButtons messageId={message.id} />}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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 AssistantMount — the panel's place in the dashboard shell. The panel code (markdown,
|
||||
* streaming, record renders) loads on first open, so the shell's first load stays small; once
|
||||
* loaded it stays mounted, so a running reply keeps streaming while the panel is closed.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react'
|
||||
|
||||
import { useAssistant } from './assistant-context'
|
||||
|
||||
const AssistantPanel = lazy(() => import('./AssistantPanel'))
|
||||
|
||||
/**
|
||||
* Mounts the lazily loaded assistant panel once it has been opened.
|
||||
*
|
||||
* @returns The panel, or null until the assistant is available and first opened.
|
||||
*/
|
||||
export function AssistantMount() {
|
||||
const assistant = useAssistant()
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const open = Boolean(assistant?.open)
|
||||
useEffect(() => {
|
||||
if (open) setLoaded(true)
|
||||
}, [open])
|
||||
if (!assistant?.enabled || (!loaded && !open)) return null
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AssistantPanel />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* 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 assistant panel: probe gating, streaming, cancel, markdown safety, deep link
|
||||
|
||||
import React from 'react'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import { server } from '@/mocks/node'
|
||||
import { AssistantProvider, useAssistantController } from './AssistantProvider'
|
||||
import { AssistantHeaderButton } from './AssistantHeaderButton'
|
||||
import { AssistantMount } from './AssistantMount'
|
||||
|
||||
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),
|
||||
}))
|
||||
|
||||
const BASE = '/qqq/v1/assistant'
|
||||
const META = { tables: { person: { name: 'person', label: 'People' } }, supplementalInstanceMetaData: { assistant: {} } } as unknown as QInstance
|
||||
const CONVERSATION = { id: 4, title: null, status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z' }
|
||||
|
||||
/**
|
||||
* Formats stream events as SSE frames.
|
||||
*
|
||||
* @param events - `[type, data]` pairs.
|
||||
* @returns Event-stream text.
|
||||
*/
|
||||
function sse(...events: Array<[string, Record<string, unknown>]>): string {
|
||||
return events.map(([type, data]) => `id: ${data.seq}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* A stream the test writes to and closes.
|
||||
*
|
||||
* @returns The response factory, a writer and a closer.
|
||||
*/
|
||||
function controllableStream() {
|
||||
const encoder = new TextEncoder()
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
||||
const body = new ReadableStream<Uint8Array>({ start(c) { controller = c } })
|
||||
return {
|
||||
response: () => new HttpResponse(body, { headers: { 'Content-Type': 'text/event-stream' } }),
|
||||
write: (text: string) => controller?.enqueue(encoder.encode(text)),
|
||||
close: () => controller?.close(),
|
||||
}
|
||||
}
|
||||
|
||||
/** What the server has stored for conversation 4 (served after a turn ends). */
|
||||
const persisted: { messages: unknown[] } = { messages: [] }
|
||||
|
||||
/**
|
||||
* The stored messages of a finished turn.
|
||||
*
|
||||
* @param text - The user's text.
|
||||
* @param parts - The reply's parts.
|
||||
* @param status - The reply's status.
|
||||
* @returns The messages.
|
||||
*/
|
||||
function storedTurn(text: string, parts: unknown[], status = 'COMPLETE'): unknown[] {
|
||||
return [
|
||||
{ id: 10, role: 'USER', status: 'COMPLETE', turnId: 9, createDate: '2026-10-12T00:00:00Z', parts: [{ type: 'text', text }] },
|
||||
{ id: 11, role: 'ASSISTANT', status, turnId: 9, createDate: '2026-10-12T00:00:00Z', parts },
|
||||
]
|
||||
}
|
||||
|
||||
const START = ['message.start', { seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }] as [string, Record<string, unknown>]
|
||||
|
||||
/**
|
||||
* Harness: the dashboard's assistant wiring.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.metaData - Instance metadata (defaults to one announcing the qbit).
|
||||
* @returns The shell.
|
||||
*/
|
||||
function Shell({ metaData = META }: { metaData?: QInstance }) {
|
||||
const assistant = useAssistantController(metaData, true)
|
||||
return (
|
||||
<AssistantProvider value={assistant}>
|
||||
<header><AssistantHeaderButton /></header>
|
||||
<main>page</main>
|
||||
<AssistantMount />
|
||||
</AssistantProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the harness.
|
||||
*
|
||||
* @param metaData - Instance metadata (defaults to one announcing the qbit).
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderShell(metaData?: QInstance) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><Shell metaData={metaData} /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the probe and conversation routes.
|
||||
*
|
||||
* @param config - The probe response, or a status code.
|
||||
*/
|
||||
function mockAssistant(config: Record<string, unknown> | number = { enabled: true }) {
|
||||
persisted.messages = []
|
||||
server.use(
|
||||
http.get(`${BASE}/config`, () => (typeof config === 'number' ? HttpResponse.json({ error: 'x' }, { status: config }) : HttpResponse.json(config))),
|
||||
http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [] })),
|
||||
http.post(`${BASE}/conversations`, () => HttpResponse.json(CONVERSATION, { status: 201 })),
|
||||
http.get(`${BASE}/conversations/4`, () => HttpResponse.json({ conversation: CONVERSATION, messages: persisted.messages })),
|
||||
)
|
||||
}
|
||||
|
||||
// The panel is lazily loaded; load its module once so the tests do not race the import
|
||||
beforeAll(async () => {
|
||||
await import('./AssistantPanel')
|
||||
}, 30_000)
|
||||
|
||||
beforeEach(() => {
|
||||
router.push.mockReset()
|
||||
router.replace.mockReset()
|
||||
window.history.replaceState(null, '', '/app/person')
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/')
|
||||
})
|
||||
|
||||
describe('assistant gating', () => {
|
||||
it('does not probe an instance whose metadata does not announce the qbit', async () => {
|
||||
const probe = vi.fn()
|
||||
mockAssistant()
|
||||
server.use(http.get(`${BASE}/config`, () => {
|
||||
probe()
|
||||
return HttpResponse.json({ enabled: true })
|
||||
}))
|
||||
renderShell({ tables: META.tables } as unknown as QInstance)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
|
||||
})
|
||||
|
||||
it.each([404, 403])('renders nothing when the probe answers %i', async (status) => {
|
||||
mockAssistant(status)
|
||||
renderShell()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when the assistant is disabled for the user', async () => {
|
||||
mockAssistant({ enabled: false, reason: 'NO_PERMISSION' })
|
||||
renderShell()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the unread count on the header button', async () => {
|
||||
mockAssistant()
|
||||
server.use(http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [{ ...CONVERSATION, unreadCount: 2 }, { ...CONVERSATION, id: 5, unreadCount: 1 }] })))
|
||||
renderShell()
|
||||
expect(await screen.findByRole('button', { name: 'Assistant, 3 unread (Ctrl+J or ⌘J)' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('asking a question', () => {
|
||||
it('sends a presentation prompt through the same engine and context as the composer', async () => {
|
||||
mockAssistant()
|
||||
const reply = { type: 'presentation', summary: 'Images found.', markdown: 'Images found.', blocks: [{ kind: 'actions', items: [{ label: 'Show PNG', prompt: 'Show me PNG files' }] }] }
|
||||
const requests: unknown[] = []
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
|
||||
const body = await request.json() as { text: string }
|
||||
requests.push(body)
|
||||
persisted.messages = storedTurn(body.text, [reply])
|
||||
return new HttpResponse(sse(START, ['presentation', { seq: 2, messageId: 11, part: reply }], ['message.end', { seq: 3, messageId: 11, status: 'COMPLETE' }]), { headers: { 'Content-Type': 'text/event-stream' } })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const composer = await screen.findByRole('textbox', { name: 'Message the assistant' })
|
||||
await user.type(composer, 'Count images{Enter}')
|
||||
const action = await screen.findByRole('button', { name: 'Show PNG' })
|
||||
await waitFor(() => expect(action).toBeEnabled())
|
||||
await user.click(action)
|
||||
await waitFor(() => expect(requests[1]).toEqual({ text: 'Show me PNG files', pageContext: { route: '/app/person', tableName: 'person' } }))
|
||||
})
|
||||
|
||||
it('streams the reply with a tool step and record chip, follows navigation, and passes axe', async () => {
|
||||
mockAssistant()
|
||||
const stream = controllableStream()
|
||||
let turnBody: unknown
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
|
||||
turnBody = await request.json()
|
||||
return stream.response()
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
const composer = within(panel).getByRole('textbox', { name: 'Message the assistant' })
|
||||
expect(within(panel).getByText('On: People')).toBeInTheDocument()
|
||||
await user.type(composer, 'Who is active?{Enter}')
|
||||
|
||||
await waitFor(() => expect(turnBody).toEqual({ text: 'Who is active?', pageContext: { route: '/app/person', tableName: 'person' } }))
|
||||
expect(within(panel).getByText('Who is active?')).toBeInTheDocument()
|
||||
expect(within(panel).getByRole('button', { name: 'Stop the reply' })).toBeInTheDocument()
|
||||
|
||||
act(() => stream.write(sse(
|
||||
START,
|
||||
['tool.start', { seq: 2, messageId: 11, toolCallId: 't1', name: 'query_records', summary: 'Querying People' }],
|
||||
['tool.result', {
|
||||
seq: 3, messageId: 11, toolCallId: 't1', name: 'query_records', outcome: 'OK', summary: 'Queried People · 1 row',
|
||||
recordRefs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }],
|
||||
}],
|
||||
['text.delta', { seq: 4, messageId: 11, text: 'One person is **active**. ' }],
|
||||
['ui.navigate', { seq: 5, messageId: 11, href: '/app/person/1' }],
|
||||
['text.delta', { seq: 6, messageId: 11, text: 'Opened it.' }],
|
||||
)))
|
||||
|
||||
expect(await within(panel).findByText('active')).toBeInTheDocument()
|
||||
expect(within(panel).getByText('Queried People · 1 row')).toBeInTheDocument()
|
||||
const chip = within(panel).getByRole('link', { name: /Alice Johnson/ })
|
||||
expect(chip).toHaveAttribute('href', '/app/person/1')
|
||||
expect(router.push).toHaveBeenCalledWith('/app/person/1')
|
||||
|
||||
persisted.messages = storedTurn('Who is active?', [
|
||||
{ type: 'tool', toolCallId: 't1', name: 'query_records', summary: 'Queried People · 1 row', outcome: 'OK' },
|
||||
{ type: 'record_refs', refs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }] },
|
||||
{ type: 'text', text: 'One person is **active**. Opened it.' },
|
||||
])
|
||||
act(() => stream.write(sse(['message.end', { seq: 7, messageId: 11, status: 'COMPLETE' }])))
|
||||
act(() => stream.close())
|
||||
await waitFor(() => expect(within(panel).getByRole('button', { name: 'Send' })).toBeInTheDocument())
|
||||
expect(await within(panel).findByRole('button', { name: 'Good response' })).toBeInTheDocument()
|
||||
expect(within(panel).getByText('active')).toBeInTheDocument()
|
||||
expect(await axe(panel)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('shows the configured persona: name, greeting and starter prompts that send', async () => {
|
||||
mockAssistant({ enabled: true, profile: { displayName: 'Ada', icon: 'school', greeting: 'Hi, I am Ada.', starterPrompts: ['How many posts?'] } })
|
||||
const stream = controllableStream()
|
||||
let turnBody: unknown
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
|
||||
turnBody = await request.json()
|
||||
return stream.response()
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
expect(within(panel).getAllByText('Ada').length).toBeGreaterThan(0)
|
||||
expect(within(panel).getByText('Hi, I am Ada.')).toBeInTheDocument()
|
||||
await user.click(within(panel).getByRole('button', { name: 'How many posts?' }))
|
||||
await waitFor(() => expect(turnBody).toMatchObject({ text: 'How many posts?' }))
|
||||
act(() => stream.close())
|
||||
})
|
||||
|
||||
it('switches a conversation between Read-only and Write', async () => {
|
||||
mockAssistant({ enabled: true, features: { writeMode: true } })
|
||||
const stream = controllableStream()
|
||||
let patched: unknown
|
||||
server.use(
|
||||
http.post(`${BASE}/conversations`, () => HttpResponse.json({ ...CONVERSATION, writeMode: false }, { status: 201 })),
|
||||
http.get(`${BASE}/conversations/4`, () => HttpResponse.json({ conversation: { ...CONVERSATION, writeMode: false }, messages: [] })),
|
||||
http.post(`${BASE}/conversations/4/turns`, () => stream.response()),
|
||||
http.patch(`${BASE}/conversations/4`, async ({ request }) => {
|
||||
patched = await request.json()
|
||||
return HttpResponse.json({ ...CONVERSATION, writeMode: true })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'hi{Enter}')
|
||||
act(() => stream.close())
|
||||
const toggle = await within(panel).findByRole('switch', { name: /read-only/i })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
await user.click(toggle)
|
||||
await waitFor(() => expect(patched).toEqual({ writeMode: true }))
|
||||
expect(await within(panel).findByRole('switch', { name: /write/i })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('stops a reply through the cancel endpoint', async () => {
|
||||
mockAssistant()
|
||||
const stream = controllableStream()
|
||||
let cancelled = false
|
||||
server.use(
|
||||
http.post(`${BASE}/conversations/4/turns`, () => stream.response()),
|
||||
http.post(`${BASE}/turns/9/cancel`, () => {
|
||||
cancelled = true
|
||||
persisted.messages = storedTurn('Count people', [{ type: 'text', text: 'Counting' }], 'CANCELLED')
|
||||
stream.write(sse(['message.end', { seq: 3, messageId: 11, status: 'CANCELLED' }]))
|
||||
stream.close()
|
||||
return HttpResponse.json({ status: 'CANCELLING' }, { status: 202 })
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Count people{Enter}')
|
||||
act(() => stream.write(sse(START, ['text.delta', { seq: 2, messageId: 11, text: 'Counting' }])))
|
||||
await within(panel).findByText('Counting')
|
||||
await user.click(within(panel).getByRole('button', { name: 'Stop the reply' }))
|
||||
await waitFor(() => expect(cancelled).toBe(true))
|
||||
expect(await within(panel).findByText('Stopped.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a refused turn as an error in the reply', async () => {
|
||||
mockAssistant()
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => HttpResponse.json({ error: 'Daily budget reached' }, { status: 429 })))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Hi{Enter}')
|
||||
expect(await within(panel).findByText('Daily budget reached')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the page context once the chip is dismissed', async () => {
|
||||
mockAssistant()
|
||||
let turnBody: unknown
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
|
||||
turnBody = await request.json()
|
||||
const stream = controllableStream()
|
||||
stream.write(sse(START, ['message.end', { seq: 2, messageId: 11, status: 'COMPLETE' }]))
|
||||
stream.close()
|
||||
return stream.response()
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.click(within(panel).getByRole('button', { name: "Don't send page context (People)" }))
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Hi{Enter}')
|
||||
await waitFor(() => expect(turnBody).toEqual({ text: 'Hi' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdown safety', () => {
|
||||
it('renders only same-origin /app links, never raw HTML or images', async () => {
|
||||
mockAssistant()
|
||||
const text = [
|
||||
'See [the record](/app/person/1) or [elsewhere](https://evil.example.com/x?d=secret).',
|
||||
'<img src="https://evil.example.com/p.png"><b>bold html</b>',
|
||||
' [js](javascript:alert(1))',
|
||||
].join('\n\n')
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => {
|
||||
const stream = controllableStream()
|
||||
persisted.messages = storedTurn('Links?', [{ type: 'text', text }])
|
||||
stream.write(sse(START, ['text.delta', { seq: 2, messageId: 11, text }], ['message.end', { seq: 3, messageId: 11, status: 'COMPLETE' }]))
|
||||
stream.close()
|
||||
return stream.response()
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderShell()
|
||||
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Links?{Enter}')
|
||||
const internal = await within(panel).findByRole('link', { name: 'the record' })
|
||||
expect(internal).toHaveAttribute('href', '/app/person/1')
|
||||
expect(within(panel).queryByRole('link', { name: 'elsewhere' })).toBeNull()
|
||||
expect(within(panel).getByText('(https://evil.example.com/x?d=secret)')).toBeInTheDocument()
|
||||
expect(within(panel).getByRole('button', { name: 'Copy link https://evil.example.com/x?d=secret' })).toBeInTheDocument()
|
||||
expect(within(panel).queryByRole('link', { name: 'js' })).toBeNull()
|
||||
expect(panel.querySelector('img')).toBeNull()
|
||||
expect(panel.querySelector('b')).toBeNull()
|
||||
expect(within(panel).getByText('[image: tracker not shown]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deep link', () => {
|
||||
it('opens a new conversation with the prompt pre-filled, not sent, and strips the parameters', async () => {
|
||||
mockAssistant()
|
||||
const turns = vi.fn()
|
||||
server.use(http.post(`${BASE}/conversations/4/turns`, () => {
|
||||
turns()
|
||||
return HttpResponse.json({ error: 'no' }, { status: 500 })
|
||||
}))
|
||||
window.history.replaceState(null, '', '/app/person/1?assistant=open&assistantPrompt=Tell%20me%20about%20this%20record.&tab=2')
|
||||
renderShell()
|
||||
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
|
||||
const composer = within(panel).getByRole('textbox', { name: 'Message the assistant' })
|
||||
await waitFor(() => expect(composer).toHaveValue('Tell me about this record.'))
|
||||
expect(router.replace).toHaveBeenCalledWith('/app/person/1?tab=2', { scroll: false })
|
||||
expect(within(panel).getByText('On: People #1')).toBeInTheDocument()
|
||||
expect(turns).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is inert when the assistant is unavailable', async () => {
|
||||
mockAssistant(404)
|
||||
window.history.replaceState(null, '', '/app/person/1?assistant=open&assistantPrompt=Hi')
|
||||
renderShell()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(router.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,632 @@
|
||||
/*
|
||||
* 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 AssistantPanel — the assistant chat panel (loaded lazily by AssistantMount). From
|
||||
* 1024 px it docks beside the page as a resizable column (360-640 px) with its own scrolling
|
||||
* message list; below that it is a full-height dialog sheet that follows the on-screen keyboard.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Archive, ArchiveRestore, Eye, MoreHorizontal, PanelLeft, Pencil, PencilLine, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { AssistantAttachment, AssistantConversation, AssistantConversationDetail, AssistantConversationStatus, AssistantConversationUpdate } from '@/types/assistant'
|
||||
import { uploadFile } from '@/lib/api/uploads'
|
||||
import {
|
||||
deleteAssistantConversation,
|
||||
getAssistantConversation,
|
||||
listAssistantConversations,
|
||||
updateAssistantConversation,
|
||||
} from '@/lib/api/assistant'
|
||||
import { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { assistantPageInfo, type AssistantPageInfo } from '@/lib/utils/assistant-links'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { ASSISTANT_DOCKED_MEDIA_QUERY, assistantKeys, useAssistant, type AssistantController } from './assistant-context'
|
||||
import { AssistantComposer } from './AssistantComposer'
|
||||
import { AssistantMessageView } from './AssistantMessageView'
|
||||
import { ConversationRail, conversationTitle } from './ConversationRail'
|
||||
import { useAssistantEngine, type AssistantEngine } from './use-assistant-engine'
|
||||
import { useStreamAnnouncer } from './use-stream-announcer'
|
||||
|
||||
/** Docked width limits, in pixels. */
|
||||
export const PANEL_MIN_WIDTH = 360
|
||||
export const PANEL_MAX_WIDTH = 640
|
||||
const PANEL_DEFAULT_WIDTH = 400
|
||||
/** Below this panel width the conversation list replaces the messages instead of sitting beside them. */
|
||||
const SIDE_BY_SIDE_MIN_WIDTH = 560
|
||||
/** Room kept for the sidebar (16rem) and a readable page (25rem) beside the docked panel. */
|
||||
const PAGE_RESERVE = '41rem'
|
||||
|
||||
/**
|
||||
* Clamps a docked width.
|
||||
*
|
||||
* @param width - Requested width.
|
||||
* @returns The width within 360-640 px.
|
||||
*/
|
||||
export function clampPanelWidth(width: number): number {
|
||||
if (!Number.isFinite(width)) return PANEL_DEFAULT_WIDTH
|
||||
return Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, Math.round(width)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual viewport's height and top offset, so the sheet stays above the on-screen keyboard.
|
||||
*
|
||||
* @param active - Whether to track it.
|
||||
* @returns The height and offset, or null without `visualViewport`.
|
||||
*/
|
||||
function useVisualViewport(active: boolean): { height: number; top: number } | null {
|
||||
const [box, setBox] = useState<{ height: number; top: number } | null>(null)
|
||||
useEffect(() => {
|
||||
const viewport = typeof window === 'undefined' ? undefined : window.visualViewport
|
||||
if (!active || !viewport) return
|
||||
/**
|
||||
* Copies the viewport's box.
|
||||
*
|
||||
* @returns Nothing.
|
||||
*/
|
||||
const update = () => setBox({ height: viewport.height, top: viewport.offsetTop })
|
||||
update()
|
||||
viewport.addEventListener('resize', update)
|
||||
viewport.addEventListener('scroll', update)
|
||||
return () => {
|
||||
viewport.removeEventListener('resize', update)
|
||||
viewport.removeEventListener('scroll', update)
|
||||
}
|
||||
}, [active])
|
||||
return active ? box : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The current page as assistant context, read from the location (the query filter can change
|
||||
* without a navigation, so callers re-read it before sending).
|
||||
*
|
||||
* @param metaData - Instance metadata.
|
||||
* @returns The page info now.
|
||||
*/
|
||||
function readPageInfo(metaData: QInstance | undefined): AssistantPageInfo | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const segments = window.location.pathname.split('/').filter(Boolean)
|
||||
let recordLabel: string | undefined
|
||||
if (segments.length >= 3) {
|
||||
let recordId = segments[2]
|
||||
try {
|
||||
recordId = decodeURIComponent(recordId)
|
||||
} catch {
|
||||
// keep the raw segment
|
||||
}
|
||||
recordLabel = getRecentRecords().find((record) => record.tableName === segments[1] && record.recordId === recordId)?.recordLabel
|
||||
}
|
||||
return assistantPageInfo(window.location.pathname, window.location.search, metaData, recordLabel)
|
||||
}
|
||||
|
||||
/** Props of {@link PanelBody}. */
|
||||
interface PanelBodyProps {
|
||||
assistant: AssistantController
|
||||
engine: AssistantEngine
|
||||
/** Whether the list and the messages fit side by side. */
|
||||
wide: boolean
|
||||
/** The title element id (labels the panel). */
|
||||
titleId: string
|
||||
composerRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Header, conversation list, messages and composer, shared by the docked panel and the sheet.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The panel body.
|
||||
*/
|
||||
function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { activeConversationId, setActiveConversationId, pendingRequest, consumeRequest, metaData } = assistant
|
||||
const pathname = usePathname()
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
const [railStatus, setRailStatus] = useState<AssistantConversationStatus>('ACTIVE')
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [dismissedRoute, setDismissedRoute] = useState<string | null>(null)
|
||||
const [pageInfo, setPageInfo] = useState(() => readPageInfo(metaData))
|
||||
const [prefill, setPrefill] = useState<{ id: number; text: string } | null>(null)
|
||||
const [attachments, setAttachments] = useState<AssistantAttachment[]>([])
|
||||
const [attaching, setAttaching] = useState(false)
|
||||
// attaching is offered where saved bulk load profiles are (the bulk-load tools use the files)
|
||||
const canAttach = Boolean(metaData?.processes?.storeSavedBulkLoadProfile)
|
||||
|
||||
// attachments belong to one conversation
|
||||
useEffect(() => setAttachments([]), [activeConversationId])
|
||||
|
||||
/**
|
||||
* Uploads a picked file (POST /uploads, as the user) and attaches its ref.
|
||||
*
|
||||
* @param file - The file.
|
||||
*/
|
||||
const attach = async (file: File) => {
|
||||
setAttaching(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file)
|
||||
setAttachments((current) => [...current.filter((a) => a.uploadRef !== uploaded.ref), { uploadRef: uploaded.ref, fileName: uploaded.fileName }].slice(-5))
|
||||
} catch (error) {
|
||||
toast.error(`Could not attach ${file.name}: ${getErrorMessage(error)}`)
|
||||
} finally {
|
||||
setAttaching(false)
|
||||
}
|
||||
}
|
||||
const listId = useId()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const stickToBottom = useRef(true)
|
||||
|
||||
useEffect(() => setPageInfo(readPageInfo(metaData)), [pathname, metaData])
|
||||
|
||||
const conversationsQuery = useQuery({
|
||||
queryKey: assistantKeys.conversations(railStatus),
|
||||
queryFn: () => listAssistantConversations(railStatus),
|
||||
staleTime: 1000 * 60,
|
||||
})
|
||||
const run = activeConversationId !== null ? engine.runs[activeConversationId] : undefined
|
||||
const detailQuery = useQuery({
|
||||
queryKey: assistantKeys.conversation(activeConversationId ?? 0),
|
||||
queryFn: () => getAssistantConversation(activeConversationId as number),
|
||||
enabled: activeConversationId !== null,
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const detail = activeConversationId !== null ? detailQuery.data : undefined
|
||||
const messages = useMemo(() => detail?.messages ?? [], [detail])
|
||||
const conversation: AssistantConversation | undefined = detail?.conversation
|
||||
?? conversationsQuery.data?.find((item) => item.id === activeConversationId)
|
||||
const announcement = useStreamAnnouncer(messages)
|
||||
|
||||
// Opening a conversation reads it: the badge drops its unread replies
|
||||
useEffect(() => {
|
||||
if (activeConversationId === null || !assistant.open) return
|
||||
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) =>
|
||||
list?.map((item) => (item.id === activeConversationId && item.unreadCount ? { ...item, unreadCount: 0 } : item)))
|
||||
}, [activeConversationId, assistant.open, queryClient, messages.length])
|
||||
|
||||
// A reply still streaming on the server (another tab, a reload): follow it
|
||||
useEffect(() => {
|
||||
if (activeConversationId === null || run) return
|
||||
const streaming = messages.find((message) => message.role === 'ASSISTANT' && message.status === 'STREAMING' && message.id > 0 && message.turnId !== null)
|
||||
if (streaming) engine.attach(activeConversationId, streaming)
|
||||
}, [activeConversationId, messages, run, engine])
|
||||
|
||||
// Hand-overs: the command palette sends at once, the deep link only pre-fills
|
||||
useEffect(() => {
|
||||
if (!pendingRequest) return
|
||||
consumeRequest(pendingRequest.id)
|
||||
setActiveConversationId(null)
|
||||
setRailOpen(false)
|
||||
if (pendingRequest.mode === 'prefill') {
|
||||
setPrefill({ id: pendingRequest.id, text: pendingRequest.prompt })
|
||||
return
|
||||
}
|
||||
const context = pendingRequest.context === undefined ? readPageInfo(metaData)?.context ?? null : pendingRequest.context
|
||||
void engine.send(pendingRequest.prompt, null, context)
|
||||
}, [pendingRequest, consumeRequest, setActiveConversationId, metaData, engine])
|
||||
|
||||
// Keep the newest message in view while the user has not scrolled up
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRef.current
|
||||
if (element && stickToBottom.current) element.scrollTop = element.scrollHeight
|
||||
}, [messages])
|
||||
useEffect(() => {
|
||||
stickToBottom.current = true
|
||||
}, [activeConversationId])
|
||||
|
||||
const contextDismissed = pageInfo !== null && dismissedRoute === pageInfo.context.route
|
||||
|
||||
/**
|
||||
* Sends from the composer, with the page context unless dismissed.
|
||||
*
|
||||
* @param text - The message.
|
||||
*/
|
||||
const send = (text: string) => {
|
||||
const info = readPageInfo(metaData)
|
||||
const pageContext = info && dismissedRoute !== info.context.route ? info.context : null
|
||||
const context = attachments.length ? { ...(pageContext ?? {}), attachments } : pageContext
|
||||
stickToBottom.current = true
|
||||
void engine.send(text, activeConversationId, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a change to the active conversation.
|
||||
*
|
||||
* @param update - The change.
|
||||
*/
|
||||
const updateConversation = async (update: AssistantConversationUpdate) => {
|
||||
if (activeConversationId === null) return
|
||||
try {
|
||||
const updated = await updateAssistantConversation(activeConversationId, update)
|
||||
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(updated.id), (current) => (current ? { ...current, conversation: updated } : current))
|
||||
await queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
|
||||
if (update.status && update.status !== railStatus) setActiveConversationId(null)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Could not update the conversation.'))
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes the active conversation. */
|
||||
const deleteConversation = async () => {
|
||||
if (activeConversationId === null) return
|
||||
const id = activeConversationId
|
||||
try {
|
||||
await deleteAssistantConversation(id)
|
||||
setActiveConversationId(null)
|
||||
queryClient.removeQueries({ queryKey: assistantKeys.conversation(id) })
|
||||
await queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Could not delete the conversation.'))
|
||||
}
|
||||
}
|
||||
|
||||
const showMessages = wide || !railOpen
|
||||
const profile = assistant.config?.profile
|
||||
const assistantName = profile?.displayName?.trim() || 'Assistant'
|
||||
const title = activeConversationId === null ? assistantName : conversationTitle(conversation)
|
||||
const iconButton = 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11 pointer-coarse:w-11'
|
||||
const menuItem = 'flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm text-foreground outline-none data-[highlighted]:bg-accent pointer-coarse:min-h-11'
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2" style={{ minHeight: 'var(--qqq-header-height)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
onClick={() => setRailOpen((value) => !value)}
|
||||
aria-expanded={railOpen}
|
||||
aria-controls={listId}
|
||||
aria-label={railOpen ? 'Hide conversations' : 'Show conversations'}
|
||||
data-qqq-id="assistant-rail-toggle"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
{renaming && conversation ? (
|
||||
<form
|
||||
className="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const value = String(new FormData(event.currentTarget).get('title') ?? '').trim()
|
||||
setRenaming(false)
|
||||
if (value && value !== conversation.title) void updateConversation({ title: value })
|
||||
}}
|
||||
>
|
||||
<label className="sr-only" htmlFor={`${titleId}-rename`}>Conversation name</label>
|
||||
<input
|
||||
id={`${titleId}-rename`}
|
||||
name="title"
|
||||
defaultValue={conversation.title ?? ''}
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
onBlur={(event) => event.currentTarget.form?.requestSubmit()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
setRenaming(false)
|
||||
}
|
||||
}}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-base text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-sm"
|
||||
data-qqq-id="assistant-rename-input"
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<h2 id={titleId} className="min-w-0 flex-1 truncate px-1 text-sm font-semibold text-foreground" data-qqq-id="assistant-title">{title}</h2>
|
||||
)}
|
||||
{conversation && activeConversationId !== null && assistant.config?.features?.writeMode && (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={Boolean(conversation.writeMode)}
|
||||
onClick={() => void updateConversation({ writeMode: !conversation.writeMode })}
|
||||
title={conversation.writeMode
|
||||
? 'Write mode: the assistant may propose changes, which you confirm. It acts only with your own permissions.'
|
||||
: 'Read-only: the assistant cannot change anything. Switch to Write to let it propose changes you confirm.'}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center gap-1 rounded-full border px-2.5 text-xs font-medium pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
conversation.writeMode ? 'border-amber-500/60 bg-amber-500/10 text-amber-800 dark:text-amber-300' : 'border-border text-muted-foreground hover:bg-accent'
|
||||
)}
|
||||
data-qqq-id="assistant-write-mode"
|
||||
>
|
||||
{conversation.writeMode ? <PencilLine className="h-3.5 w-3.5" aria-hidden="true" /> : <Eye className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{conversation.writeMode ? 'Write' : 'Read-only'}
|
||||
</button>
|
||||
)}
|
||||
{conversation && activeConversationId !== null && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button type="button" className={iconButton} aria-label="Conversation actions" data-qqq-id="assistant-conversation-menu">
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content align="end" sideOffset={4} className="z-[2100] min-w-44 rounded-lg border border-border bg-popover p-1 shadow-lg" data-qqq-id="assistant-conversation-menu-content">
|
||||
<DropdownMenu.Item className={menuItem} onSelect={() => setRenaming(true)} data-qqq-id="assistant-rename">
|
||||
<Pencil className="h-4 w-4 text-muted-foreground" aria-hidden="true" />Rename
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={menuItem} onSelect={() => void updateConversation({ pinned: !conversation.pinned })} data-qqq-id="assistant-pin">
|
||||
{conversation.pinned ? <PinOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> : <Pin className="h-4 w-4 text-muted-foreground" aria-hidden="true" />}
|
||||
{conversation.pinned ? 'Unpin' : 'Pin'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className={menuItem}
|
||||
onSelect={() => void updateConversation({ status: conversation.status === 'ARCHIVED' ? 'ACTIVE' : 'ARCHIVED' })}
|
||||
data-qqq-id="assistant-archive"
|
||||
>
|
||||
{conversation.status === 'ARCHIVED' ? <ArchiveRestore className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> : <Archive className="h-4 w-4 text-muted-foreground" aria-hidden="true" />}
|
||||
{conversation.status === 'ARCHIVED' ? 'Unarchive' : 'Archive'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item className={cn(menuItem, 'text-destructive')} onSelect={() => setConfirmDelete(true)} data-qqq-id="assistant-delete">
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
onClick={() => {
|
||||
setActiveConversationId(null)
|
||||
setRailOpen(false)
|
||||
requestAnimationFrame(() => composerRef.current?.focus())
|
||||
}}
|
||||
aria-label="New conversation"
|
||||
data-qqq-id="assistant-new"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className={iconButton} onClick={() => assistant.setOpen(false)} aria-label="Close assistant" data-qqq-id="assistant-close">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{railOpen && (
|
||||
<div id={listId} className={cn('flex min-h-0', wide ? 'w-52 shrink-0 border-r border-border' : 'min-w-0 flex-1')}>
|
||||
<ConversationRail
|
||||
className="min-w-0 flex-1"
|
||||
conversations={conversationsQuery.data}
|
||||
loading={conversationsQuery.isLoading}
|
||||
activeId={activeConversationId}
|
||||
status={railStatus}
|
||||
onStatusChange={setRailStatus}
|
||||
onSelect={(id) => {
|
||||
setActiveConversationId(id)
|
||||
if (!engine.runs[id]) void queryClient.invalidateQueries({ queryKey: assistantKeys.conversation(id) })
|
||||
if (!wide) setRailOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showMessages && (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(event) => {
|
||||
const element = event.currentTarget
|
||||
stickToBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48
|
||||
}}
|
||||
className="@container min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-3 py-3"
|
||||
tabIndex={0}
|
||||
role="log"
|
||||
aria-live="off"
|
||||
aria-label="Messages"
|
||||
data-qqq-id="assistant-messages"
|
||||
>
|
||||
{activeConversationId === null && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground" data-qqq-id="assistant-empty">
|
||||
<p className="font-medium text-foreground">{profile?.greeting?.trim() || 'Ask about your data'}</p>
|
||||
<p className="mt-1">Find records, count and explain them, or ask to be taken to a screen. {assistantName} sees and does only what you can, through the same API as these screens.</p>
|
||||
{(profile?.starterPrompts?.length ?? 0) > 0 && (
|
||||
<ul className="mt-4 flex flex-wrap justify-center gap-2" aria-label="Suggested questions" data-qqq-id="assistant-starters">
|
||||
{profile?.starterPrompts?.map((prompt) => (
|
||||
<li key={prompt} className="max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => send(prompt)}
|
||||
disabled={Boolean(run)}
|
||||
className="max-w-full rounded-full border border-border bg-background px-3 py-1.5 text-left text-xs text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeConversationId !== null && detailQuery.isLoading && <p className="text-xs text-muted-foreground" role="status">Loading conversation...</p>}
|
||||
{activeConversationId !== null && detailQuery.isError && (
|
||||
<p className="text-xs text-destructive" role="alert">{getErrorMessage(detailQuery.error, 'Could not load the conversation.')}</p>
|
||||
)}
|
||||
{messages.map((message) => <AssistantMessageView key={message.id} message={message} onSend={send} sending={Boolean(run)} />)}
|
||||
</div>
|
||||
<AssistantComposer
|
||||
ref={composerRef}
|
||||
pageInfo={pageInfo}
|
||||
contextDismissed={contextDismissed}
|
||||
onDismissContext={() => setDismissedRoute(pageInfo?.context.route ?? null)}
|
||||
onRefreshContext={() => setPageInfo(readPageInfo(metaData))}
|
||||
streaming={Boolean(run)}
|
||||
stopping={run?.status === 'cancelling'}
|
||||
onSend={send}
|
||||
onStop={() => activeConversationId !== null && engine.cancel(activeConversationId)}
|
||||
prefill={prefill}
|
||||
attachments={attachments}
|
||||
onAttach={canAttach ? (file) => void attach(file) : undefined}
|
||||
onRemoveAttachment={(uploadRef) => setAttachments((current) => current.filter((a) => a.uploadRef !== uploadRef))}
|
||||
attaching={attaching}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" data-qqq-id="assistant-announcer">{announcement}</div>
|
||||
|
||||
<AlertDialog.Root open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-[2200] bg-black/40" />
|
||||
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-[2201] w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-5 shadow-lg focus:outline-none" data-qqq-id="assistant-delete-dialog">
|
||||
<AlertDialog.Title className="text-base font-semibold text-foreground">Delete this conversation?</AlertDialog.Title>
|
||||
<AlertDialog.Description className="mt-1 text-sm text-muted-foreground">{conversationTitle(conversation)} and its messages are deleted. This cannot be undone.</AlertDialog.Description>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<AlertDialog.Cancel className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11">Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
|
||||
onClick={() => void deleteConversation()}
|
||||
data-qqq-id="assistant-delete-confirm"
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The docked panel: a resizable complementary column beside the page.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.assistant - The controller.
|
||||
* @param props.engine - The turn runner.
|
||||
* @returns The panel.
|
||||
*/
|
||||
function DockedPanel({ assistant, engine }: { assistant: AssistantController; engine: AssistantEngine }) {
|
||||
const [storedWidth, setStoredWidth] = useLocalStorage<number>('qqq-assistant-width', PANEL_DEFAULT_WIDTH)
|
||||
const width = clampPanelWidth(storedWidth)
|
||||
const titleId = useId()
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const drag = useRef<{ x: number; width: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
composerRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const onPointerMove = useCallback((event: React.PointerEvent) => {
|
||||
if (!drag.current) return
|
||||
setStoredWidth(clampPanelWidth(drag.current.width + drag.current.x - event.clientX))
|
||||
}, [setStoredWidth])
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="relative flex h-full min-h-0 shrink-0 flex-col overflow-hidden border-l border-border bg-card"
|
||||
style={{ width, maxWidth: `max(${PANEL_MIN_WIDTH}px, calc(100vw - ${PAGE_RESERVE}))` }}
|
||||
aria-labelledby={titleId}
|
||||
data-qqq-id="assistant-panel"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
if ((event.target as HTMLElement).closest('[role="menu"]')) return
|
||||
event.preventDefault()
|
||||
assistant.setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize assistant panel"
|
||||
aria-valuemin={PANEL_MIN_WIDTH}
|
||||
aria-valuemax={PANEL_MAX_WIDTH}
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
onPointerDown={(event) => {
|
||||
drag.current = { x: event.clientX, width }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={() => { drag.current = null }}
|
||||
onPointerCancel={() => { drag.current = null }}
|
||||
onKeyDown={(event) => {
|
||||
const step = event.shiftKey ? 64 : 16
|
||||
if (event.key === 'ArrowLeft') setStoredWidth(clampPanelWidth(width + step))
|
||||
else if (event.key === 'ArrowRight') setStoredWidth(clampPanelWidth(width - step))
|
||||
else if (event.key === 'Home') setStoredWidth(PANEL_MIN_WIDTH)
|
||||
else if (event.key === 'End') setStoredWidth(PANEL_MAX_WIDTH)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}}
|
||||
className="absolute inset-y-0 left-0 z-10 w-1.5 -translate-x-1/2 cursor-col-resize touch-none hover:bg-primary/30 focus:outline-none focus-visible:bg-primary/40"
|
||||
data-qqq-id="assistant-resize"
|
||||
/>
|
||||
<PanelBody assistant={assistant} engine={engine} wide={width >= SIDE_BY_SIDE_MIN_WIDTH} titleId={titleId} composerRef={composerRef} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet below 1024 px: a modal dialog, full screen on phones and a side sheet on tablets.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.assistant - The controller.
|
||||
* @param props.engine - The turn runner.
|
||||
* @returns The dialog.
|
||||
*/
|
||||
function SheetPanel({ assistant, engine }: { assistant: AssistantController; engine: AssistantEngine }) {
|
||||
const titleId = useId()
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const viewport = useVisualViewport(assistant.open)
|
||||
return (
|
||||
<Dialog.Root open={assistant.open} onOpenChange={(next) => { if (!next) assistant.setOpen(false) }}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-[var(--qqq-z-modal,1000)] bg-black/40 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=open]:fade-in-0" />
|
||||
<Dialog.Content
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
composerRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
// focus returns to the opener through the controller
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="fixed inset-x-0 top-0 z-[var(--qqq-z-modal,1000)] flex h-dvh flex-col overflow-hidden bg-card shadow-lg focus:outline-none md:left-auto md:w-[min(30rem,100vw)] md:border-l md:border-border"
|
||||
style={viewport ? { top: viewport.top, height: viewport.height } : undefined}
|
||||
data-qqq-id="assistant-panel"
|
||||
data-variant="sheet"
|
||||
>
|
||||
<Dialog.Title className="sr-only">Assistant</Dialog.Title>
|
||||
<PanelBody assistant={assistant} engine={engine} wide={false} titleId={titleId} composerRef={composerRef} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The assistant panel: docked on wide screens, a sheet below 1024 px. The turn runner lives
|
||||
* here, above both, so a reply keeps streaming while the panel is closed.
|
||||
*
|
||||
* @returns The panel, or null when closed on a wide screen.
|
||||
*/
|
||||
export default function AssistantPanel() {
|
||||
const assistant = useAssistant()
|
||||
const docked = useMediaQuery(ASSISTANT_DOCKED_MEDIA_QUERY)
|
||||
const engine = useAssistantEngine()
|
||||
if (!assistant) return null
|
||||
if (docked) return assistant.open ? <DockedPanel assistant={assistant} engine={engine} /> : null
|
||||
return <SheetPanel assistant={assistant} engine={engine} />
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import type { AssistantPendingActionRender } from '@/types/assistant'
|
||||
import { server } from '@/mocks/node'
|
||||
import AssistantPendingActionCard from './AssistantPendingActionCard'
|
||||
|
||||
const RENDER: AssistantPendingActionRender = {
|
||||
type: 'pending_action',
|
||||
actionId: 7,
|
||||
toolName: 'create_record',
|
||||
tableName: 'blog_post',
|
||||
tableLabel: 'Blog Post',
|
||||
summary: 'Create a Blog Post: Title = Hello',
|
||||
changes: [{ field: 'title', label: 'Title', value: 'Hello' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the action endpoints with the given state transitions.
|
||||
*
|
||||
* @param confirmStatus - The status the confirm endpoint answers.
|
||||
* @returns The calls made, by kind.
|
||||
*/
|
||||
function serve(confirmStatus: 'EXECUTED' | 'FAILED' = 'EXECUTED') {
|
||||
const calls: string[] = []
|
||||
server.use(
|
||||
http.get('*/assistant/actions/7', () => {
|
||||
calls.push('get')
|
||||
return HttpResponse.json({ id: 7, status: 'PENDING', toolName: 'create_record', tableName: 'blog_post', summary: RENDER.summary })
|
||||
}),
|
||||
http.post('*/assistant/actions/7/confirm', () => {
|
||||
calls.push('confirm')
|
||||
return HttpResponse.json({ id: 7, status: confirmStatus, toolName: 'create_record', tableName: 'blog_post', summary: RENDER.summary, resultSummary: 'Created Blog Post 9', outcome: 'OK', href: '/app/blog_post/9' })
|
||||
}),
|
||||
http.post('*/assistant/actions/7/reject', () => {
|
||||
calls.push('reject')
|
||||
return HttpResponse.json({ id: 7, status: 'REJECTED', toolName: 'create_record', tableName: 'blog_post', summary: RENDER.summary, resultSummary: 'Cancelled by the user; nothing changed.' })
|
||||
})
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('AssistantPendingActionCard', () => {
|
||||
it('shows the proposed change and runs it only when confirmed', async () => {
|
||||
const calls = serve()
|
||||
const { container } = render(<QueryClientProvider client={new QueryClient()}><AssistantPendingActionCard render={RENDER} /></QueryClientProvider>)
|
||||
expect(screen.getByText('Create a Blog Post: Title = Hello')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
await waitFor(() => expect(calls).toContain('get'))
|
||||
expect(calls).not.toContain('confirm')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
await waitFor(() => expect(screen.getByText(/Done · Created Blog Post 9/)).toBeInTheDocument())
|
||||
expect(calls.filter((c) => c === 'confirm')).toHaveLength(1)
|
||||
expect(screen.queryByRole('button', { name: /create/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /open blog post/i })).toHaveAttribute('href', '/app/blog_post/9')
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('cancels without running anything', async () => {
|
||||
const calls = serve()
|
||||
render(<QueryClientProvider client={new QueryClient()}><AssistantPendingActionCard render={RENDER} /></QueryClientProvider>)
|
||||
await waitFor(() => expect(calls).toContain('get'))
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||
await waitFor(() => expect(screen.getByText(/Cancelled, nothing changed/)).toBeInTheDocument())
|
||||
expect(calls).not.toContain('confirm')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AssistantPendingActionCard — a write the assistant proposed (create, update or delete a
|
||||
* record). Nothing changes until the user confirms; the server then runs the write through the
|
||||
* UI API with the user's own session, so the same permissions, record locks and validation apply.
|
||||
* The card loads the action's current state, so a reloaded conversation shows the outcome.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, PencilLine, Plus, Trash2, X } from 'lucide-react'
|
||||
|
||||
import type { AssistantPendingAction, AssistantPendingActionRender } from '@/types/assistant'
|
||||
import { confirmAssistantAction, getAssistantAction, rejectAssistantAction } from '@/lib/api/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
|
||||
/** What a finished action says, by status. */
|
||||
const STATUS_TEXT: Record<AssistantPendingAction['status'], string> = {
|
||||
PENDING: 'Waiting for your confirmation',
|
||||
RUNNING: 'Saving…',
|
||||
EXECUTED: 'Done',
|
||||
FAILED: 'Not saved',
|
||||
REJECTED: 'Cancelled, nothing changed',
|
||||
EXPIRED: 'Expired, nothing changed',
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation card of one proposed write.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The `pending_action` payload of the tool result.
|
||||
* @returns The card.
|
||||
*/
|
||||
export default function AssistantPendingActionCard({ render }: { render: AssistantPendingActionRender }) {
|
||||
const [action, setAction] = useState<AssistantPendingAction | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
getAssistantAction(render.actionId)
|
||||
.then((loaded) => {
|
||||
if (live) setAction(loaded)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (live) setError(getErrorMessage(e, 'Could not load this action.'))
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [render.actionId])
|
||||
|
||||
const status = action?.status ?? 'PENDING'
|
||||
const pending = status === 'PENDING'
|
||||
const Icon = render.toolName === 'delete_record' ? Trash2 : render.toolName === 'create_record' ? Plus : PencilLine
|
||||
const verb = render.toolName === 'delete_record' ? 'Delete' : render.toolName === 'create_record' ? 'Create' : 'Save'
|
||||
const resultHref = internalAppHref(action?.href ?? render.href)
|
||||
const tone = status === 'EXECUTED' ? 'text-emerald-700 dark:text-emerald-400' : status === 'FAILED' ? 'text-destructive' : 'text-muted-foreground'
|
||||
|
||||
/**
|
||||
* Confirms or cancels.
|
||||
*
|
||||
* @param confirm - True to run the write.
|
||||
*/
|
||||
async function decide(confirm: boolean) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const decided = confirm ? await confirmAssistantAction(render.actionId) : await rejectAssistantAction(render.actionId)
|
||||
setAction(decided)
|
||||
// the record screens on the page show the saved values without a reload
|
||||
if (decided.status === 'EXECUTED') await queryClient.invalidateQueries()
|
||||
} catch (e) {
|
||||
setError(getErrorMessage(e, confirm ? 'The change could not be saved.' : 'The action could not be cancelled.'))
|
||||
getAssistantAction(render.actionId).then(setAction).catch(() => undefined)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card" data-qqq-id="assistant-pending-action" role="group" aria-label={`Proposed change: ${render.summary}`}>
|
||||
<div className="flex items-start gap-2 border-b border-border px-3 py-2">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground break-words">{render.summary}</p>
|
||||
<p className={`text-xs ${tone}`} aria-live="polite" data-qqq-id="assistant-pending-action-status">
|
||||
{STATUS_TEXT[status]}
|
||||
{action?.resultSummary && status !== 'PENDING' ? ` · ${action.resultSummary}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{render.changes.length > 0 && (
|
||||
<dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-1 px-3 py-2 text-xs">
|
||||
{render.changes.map((change) => (
|
||||
<React.Fragment key={change.field}>
|
||||
<dt className="truncate text-muted-foreground">{change.label}</dt>
|
||||
<dd className="break-words text-foreground">{change.value ?? <span className="text-muted-foreground">(empty)</span>}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{(error || action?.message) && status !== 'EXECUTED' && (
|
||||
<p className="px-3 pb-2 text-xs text-destructive" role="alert">{error ?? action?.message}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2">
|
||||
{pending ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => decide(true)}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-xs font-medium pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60 ${render.toolName === 'delete_record' ? 'bg-destructive text-white hover:bg-destructive/90' : 'bg-primary text-primary-foreground hover:bg-primary/90'}`}
|
||||
data-qqq-id="assistant-pending-action-confirm"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{busy ? 'Working…' : verb}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => decide(false)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
||||
data-qqq-id="assistant-pending-action-cancel"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{resultHref && status !== 'REJECTED' && status !== 'EXPIRED' && (
|
||||
<Link href={resultHref} className="ml-auto inline-flex items-center gap-1 rounded text-xs font-medium text-primary hover:underline pointer-coarse:min-h-11 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
Open {render.tableLabel}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Presentation contract, navigation safety and action behavior.
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import type { AssistantPresentationPart } from '@/types/assistant'
|
||||
import { AssistantPresentation } from './AssistantPresentation'
|
||||
import { AssistantMessageView } from './AssistantMessageView'
|
||||
import { optimisticTurnMessages, reduceStreamEvent } from './message-reducer'
|
||||
import { toAssistantEvent } from '@/lib/api/assistant'
|
||||
|
||||
const router = vi.hoisted(() => ({ push: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => router }))
|
||||
// Keep real Recharts, supplying the size that jsdom cannot measure.
|
||||
vi.mock('recharts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('recharts')>()
|
||||
return { ...actual, ResponsiveContainer: ({ children }: { children: React.ReactElement }) => React.cloneElement(children as React.ReactElement<{ width: number; height: number }>, { width: 340, height: 200 }) }
|
||||
})
|
||||
|
||||
const table = {
|
||||
kind: 'table' as const, title: 'Media by type',
|
||||
columns: [{ key: 'type', label: 'Type' }, { key: 'count', label: 'Count' }, { key: 'share', label: 'Share', align: 'right' as const }],
|
||||
rows: [{ cells: { type: 'JPEG', count: 81, share: 0.802 }, display: { share: '80.2%' }, href: '/app/media?filter=jpeg' }],
|
||||
}
|
||||
const part: AssistantPresentationPart = {
|
||||
type: 'presentation', summary: '101 images.', markdown: '**Fallback answer**',
|
||||
blocks: [
|
||||
{ kind: 'metrics', items: [{ label: 'Images', value: 101, display: '101 files', href: '/app/media' }] },
|
||||
table,
|
||||
{ kind: 'filterLinks', items: [{ label: 'Open JPEG', tableName: 'media', href: '/app/media?filter=jpeg' }] },
|
||||
{ kind: 'recordLinks', items: [{ label: 'Sunset.jpg', tableName: 'media', recordId: '12', href: '/app/media/12' }] },
|
||||
{ kind: 'actions', items: [{ label: 'Show PNG', prompt: 'Show me PNG files' }, { label: 'Open media', href: '/app/media' }] },
|
||||
{ kind: 'future', payload: 'Ignore this' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('AssistantPresentation', () => {
|
||||
it('renders summary, metrics, table display values, links and prompt actions accessibly', async () => {
|
||||
const send = vi.fn()
|
||||
const { container } = render(<AssistantPresentation part={part} onSend={send} />)
|
||||
expect(screen.getByText('101 images.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: '101 files' })).toHaveAttribute('href', '/app/media')
|
||||
expect(screen.getByRole('table', { name: 'Media by type' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('cell', { name: '81' })).toHaveStyle({ textAlign: 'right' })
|
||||
expect(screen.getByRole('cell', { name: '80.2%' })).toHaveStyle({ textAlign: 'right' })
|
||||
expect(screen.getByRole('link', { name: 'JPEG' })).toHaveAttribute('href', '/app/media?filter=jpeg')
|
||||
expect(screen.getByRole('link', { name: 'Open JPEG' })).toHaveAttribute('href', '/app/media?filter=jpeg')
|
||||
expect(screen.getByRole('link', { name: 'Sunset.jpg' })).toHaveAttribute('href', '/app/media/12')
|
||||
expect(screen.getByRole('link', { name: 'Open media' })).toHaveAttribute('href', '/app/media')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show PNG' }))
|
||||
expect(send).toHaveBeenCalledWith('Show me PNG files')
|
||||
expect(screen.queryByText('Ignore this')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Fallback answer')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('region')).not.toBeInTheDocument()
|
||||
expect((await axe(container)).violations).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['https://evil.test/app/media', 'javascript:alert(1)', '//evil.test/app/media'])('rejects unsafe hrefs everywhere: %s', async (href) => {
|
||||
const unsafe: AssistantPresentationPart = { ...part, blocks: [
|
||||
{ kind: 'metrics', items: [{ label: 'Total', value: 1, href }] },
|
||||
{ ...table, rows: [{ ...table.rows[0], href }] },
|
||||
{ kind: 'filterLinks', items: [{ label: 'Filtered', tableName: 'media', href }] },
|
||||
{ kind: 'recordLinks', items: [{ label: 'Record', tableName: 'media', recordId: 1, href }] },
|
||||
{ kind: 'actions', items: [{ label: 'Action', href }] },
|
||||
{ kind: 'chart', chartType: 'pie', points: [{ label: 'Point', value: 1, href }] },
|
||||
] }
|
||||
render(<AssistantPresentation part={unsafe} />)
|
||||
await screen.findByRole('img', { name: /Point: 1/ }, { timeout: 10_000 })
|
||||
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
||||
expect(screen.getByText('Action')).toBeInTheDocument()
|
||||
router.push.mockClear()
|
||||
fireEvent.click(document.querySelector('.recharts-pie-sector')!)
|
||||
expect(router.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['bar', 'pie', 'line'] as const)('renders a real %s chart with accessible data and clickable points', async (chartType) => {
|
||||
const { container } = render(<AssistantPresentation part={{ ...part, blocks: [{ kind: 'chart', chartType, title: 'Media share', points: [{ label: 'JPEG', value: 81, display: '81 files', href: '/app/media' }, { label: 'PNG', value: 20 }] }] }} />)
|
||||
const chart = await screen.findByRole('img', { name: 'Media share: JPEG: 81 files; PNG: 20' }, { timeout: 10_000 })
|
||||
expect(chart.querySelector('svg')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('list', { name: 'Media share data' })).getAllByRole('listitem')).toHaveLength(2)
|
||||
expect(screen.getByRole('link', { name: 'JPEG: 81 files' })).toHaveAttribute('href', '/app/media')
|
||||
const selector = chartType === 'bar' ? '.recharts-bar-rectangle' : chartType === 'pie' ? '.recharts-pie-sector' : '.recharts-line-dots circle'
|
||||
router.push.mockClear()
|
||||
fireEvent.click(container.querySelector(selector)!)
|
||||
expect(router.push).toHaveBeenCalledWith('/app/media')
|
||||
})
|
||||
|
||||
it('falls back to markdown when every block is unknown or empty', () => {
|
||||
render(<AssistantPresentation part={{ ...part, blocks: [{ kind: 'future' }, { kind: 'metrics', items: [] }] }} />)
|
||||
expect(screen.getByText('Fallback answer').tagName).toBe('STRONG')
|
||||
})
|
||||
|
||||
it('gives wide tables an accessible scroll region and disables busy actions', () => {
|
||||
render(<AssistantPresentation part={{ ...part, blocks: [{ ...table, columns: [...table.columns, { key: 'extra', label: 'Extra' }] }, part.blocks[4]] }} onSend={vi.fn()} sending />)
|
||||
expect(screen.getByRole('region', { name: 'Media by type' })).toHaveAttribute('tabindex', '0')
|
||||
expect(screen.getByRole('button', { name: 'Show PNG' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('decodes and appends streamed presentations between text parts, preserving reload order', () => {
|
||||
const ids = { userId: -1, assistantId: -2 }
|
||||
let messages = optimisticTurnMessages('Media', ids, '')
|
||||
messages = reduceStreamEvent(messages, { type: 'text.delta', seq: 1, messageId: -2, text: 'Before' }, ids)
|
||||
const event = toAssistantEvent({ event: 'presentation', id: '2', data: JSON.stringify({ messageId: -2, part }) }).event!
|
||||
expect(event.type).toBe('presentation')
|
||||
messages = reduceStreamEvent(messages, event, ids)
|
||||
messages = reduceStreamEvent(messages, { type: 'text.delta', seq: 3, messageId: -2, text: 'After' }, ids)
|
||||
expect(messages[1].parts).toEqual([{ type: 'text', text: 'Before' }, part, { type: 'text', text: 'After' }])
|
||||
const send = vi.fn()
|
||||
const { container } = render(<AssistantMessageView message={{ ...messages[1], status: 'COMPLETE' }} onSend={send} />)
|
||||
expect(container.textContent).toMatch(/Before.*101 images\..*After/)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show PNG' }))
|
||||
expect(send).toHaveBeenCalledWith('Show me PNG files')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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 Structured assistant answers, with safe navigation and markdown fallback. */
|
||||
'use client'
|
||||
|
||||
import React, { lazy, Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import type { AssistantPresentationBlock, AssistantPresentationPart, AssistantPresentationTable } from '@/types/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown'
|
||||
|
||||
const Chart = lazy(() => import('./AssistantPresentationChart').then((module) => ({ default: module.AssistantPresentationChart })))
|
||||
const LINK = 'rounded text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
const CHIP = 'inline-flex max-w-full items-center rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50'
|
||||
|
||||
/** Send behavior shared with the composer; unavailable or busy actions are disabled. */
|
||||
export interface AssistantPresentationActions {
|
||||
onSend?: (prompt: string) => void
|
||||
sending?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks known kinds before narrowing the forward-compatible wire union.
|
||||
* @param block - A server block.
|
||||
* @returns Whether this version can render nonempty content.
|
||||
*/
|
||||
function isRenderable(block: AssistantPresentationPart['blocks'][number]): block is AssistantPresentationBlock {
|
||||
switch (block.kind) {
|
||||
case 'metrics': case 'filterLinks': case 'recordLinks': case 'actions':
|
||||
return Array.isArray(block.items) && block.items.length > 0
|
||||
case 'table':
|
||||
return Array.isArray(block.columns) && block.columns.length > 0 && Array.isArray(block.rows)
|
||||
case 'chart':
|
||||
return ['bar', 'pie', 'line'].includes(String(block.chartType)) && Array.isArray(block.points) && block.points.length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the same href policy as assistant markdown to every presentation link.
|
||||
* @param props - Destination, content and styling.
|
||||
* @returns An internal Next link or plain text.
|
||||
*/
|
||||
export function PresentationLink({ href, children, className = LINK }: { href?: string; children: React.ReactNode; className?: string }) {
|
||||
const safe = internalAppHref(href)
|
||||
return safe
|
||||
? <Link href={safe} className={className} data-qqq-id="assistant-presentation-link">{children}</Link>
|
||||
: <span>{children}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps up to three columns within the panel, wrapping even unbroken cell values.
|
||||
* @param props - Table payload.
|
||||
* @returns A semantic table; only wider tables get a scroll region.
|
||||
*/
|
||||
function PresentationTable({ block }: { block: AssistantPresentationTable }) {
|
||||
const wide = block.columns.length > 3
|
||||
const alignments = block.columns.map((column) => column.align ?? (
|
||||
block.rows.some((row) => typeof row.cells[column.key] === 'number') ? 'right' : 'left'
|
||||
))
|
||||
return (
|
||||
<div className={wide ? 'overflow-x-auto focus-visible:ring-2 focus-visible:ring-ring' : 'min-w-0'} role={wide ? 'region' : undefined} tabIndex={wide ? 0 : undefined} aria-label={wide ? block.title ?? 'Results table' : undefined}>
|
||||
<table className={`w-full border-collapse text-sm ${wide ? '' : 'table-fixed'}`}>
|
||||
<caption className="mb-2 text-left font-medium">{block.title ?? 'Results'}</caption>
|
||||
<thead className="bg-muted">
|
||||
<tr>{block.columns.map((column, index) => <th key={column.key} scope="col" style={{ textAlign: alignments[index] }} className="border border-border px-2 py-1.5 [overflow-wrap:anywhere]">{column.label}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>{block.rows.map((row, index) => (
|
||||
<tr key={index}>{block.columns.map((column, cellIndex) => {
|
||||
const value = row.display?.[column.key] ?? String(row.cells[column.key] ?? '—')
|
||||
return <td key={column.key} style={{ textAlign: alignments[cellIndex] }} className="border border-border px-2 py-1.5 align-top tabular-nums [overflow-wrap:anywhere]">{cellIndex === 0 ? <PresentationLink href={row.href}>{value}</PresentationLink> : value}</td>
|
||||
})}</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one known block, using the existing panel send path for follow-up prompts.
|
||||
* @param props - Block and send behavior.
|
||||
* @returns Block content.
|
||||
*/
|
||||
function BlockView({ block, onSend, sending }: { block: AssistantPresentationBlock } & AssistantPresentationActions) {
|
||||
switch (block.kind) {
|
||||
case 'metrics':
|
||||
return <dl className="flex flex-wrap gap-2">{block.items.map((item, index) => (
|
||||
<div key={index} className="min-w-0 max-w-full flex-auto rounded-lg border border-border bg-muted/40 p-3 [overflow-wrap:anywhere]">
|
||||
<dt className="text-xs text-muted-foreground">{item.label}</dt>
|
||||
<dd className="text-2xl font-semibold tabular-nums"><PresentationLink href={item.href}>{item.display ?? item.value}</PresentationLink></dd>
|
||||
</div>
|
||||
))}</dl>
|
||||
case 'table':
|
||||
return <PresentationTable block={block} />
|
||||
case 'chart':
|
||||
return <Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading chart…</p>}><Chart block={block} /></Suspense>
|
||||
case 'filterLinks': case 'recordLinks':
|
||||
return <ul aria-label={block.kind === 'filterLinks' ? 'Filtered lists' : 'Records'} className="flex flex-wrap gap-2">{block.items.map((item, index) => <li key={index} className="min-w-0 max-w-full [overflow-wrap:anywhere]"><PresentationLink href={item.href} className={CHIP}>{item.label}</PresentationLink></li>)}</ul>
|
||||
case 'actions':
|
||||
return <div className="flex flex-wrap gap-2">{block.items.map((item, index) => item.prompt !== undefined ? (
|
||||
<button key={index} type="button" disabled={!onSend || sending} onClick={() => onSend?.(item.prompt)} className={`${CHIP} [overflow-wrap:anywhere]`} data-qqq-id="assistant-presentation-action">{item.label}</button>
|
||||
) : <PresentationLink key={index} href={item.href} className={CHIP}>{item.label}</PresentationLink>)}</div>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the direct answer first and preserves server block order.
|
||||
* @param props - Presentation and composer callback.
|
||||
* @returns The answer or its whole-part markdown fallback.
|
||||
*/
|
||||
export function AssistantPresentation({ part, onSend, sending }: { part: AssistantPresentationPart } & AssistantPresentationActions) {
|
||||
const blocks = part.blocks.filter(isRenderable)
|
||||
if (!blocks.length) return <AssistantMarkdown text={part.markdown || part.summary} />
|
||||
return (
|
||||
<div className="min-w-0 space-y-3 text-foreground [overflow-wrap:anywhere]" data-qqq-id="assistant-presentation">
|
||||
<p className="text-base font-semibold leading-snug" data-qqq-id="assistant-presentation-summary">{part.summary}</p>
|
||||
{blocks.map((block, index) => <div key={index} className="min-w-0" data-qqq-id={`assistant-presentation-${block.kind}`}><BlockView block={block} onSend={onSend} sending={sending} /></div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* 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 Lazy Recharts rendering for small assistant presentation charts. */
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Bar, BarChart, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import type { AssistantPresentationChart as ChartBlock } from '@/types/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { PresentationLink } from './AssistantPresentation'
|
||||
|
||||
/**
|
||||
* The dashboard chart widgets' palette (PieChartWidget): concrete colors, because Tailwind only
|
||||
* emits the `--color-chart-*` theme variables that some class uses, and an unemitted one renders black.
|
||||
*/
|
||||
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#f97316']
|
||||
const TICK = { fontSize: 11, fill: 'var(--color-muted-foreground)' }
|
||||
|
||||
/**
|
||||
* Plots values and provides equivalent keyboard-accessible links and data for every point.
|
||||
* @param props - The chart payload.
|
||||
* @returns A themed chart no taller than 200px, with a screen-reader data list.
|
||||
*/
|
||||
export function AssistantPresentationChart({ block }: { block: ChartBlock }) {
|
||||
const router = useRouter()
|
||||
const label = `${block.title ?? `${block.chartType} chart`}: ${block.points.map((point) => `${point.label}: ${point.display ?? point.value}`).join('; ')}`
|
||||
const navigate = (index: number) => {
|
||||
const href = internalAppHref(block.points[index]?.href)
|
||||
if (href) router.push(href)
|
||||
}
|
||||
const tooltip = <Tooltip contentStyle={{ background: 'var(--color-popover)', color: 'var(--color-popover-foreground)', border: '1px solid var(--color-border)' }} itemStyle={{ color: 'var(--color-popover-foreground)' }} formatter={(value: number, _name, item) => [item.payload.display ?? value, item.payload.label]} />
|
||||
const axes = <><XAxis dataKey="label" tick={TICK} tickLine={false} /><YAxis width={40} tick={TICK} tickLine={false} /></>
|
||||
return (
|
||||
<figure className="min-w-0">
|
||||
{block.title && <figcaption className="mb-1 text-sm font-medium">{block.title}</figcaption>}
|
||||
<div role="img" aria-label={label} className="h-[200px] min-w-0">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
{block.chartType === 'pie' ? (
|
||||
<PieChart>{tooltip}<Pie data={block.points} dataKey="value" nameKey="label" outerRadius="80%" isAnimationActive={false} onClick={(_point, index) => navigate(index)}>
|
||||
{block.points.map((point, index) => <Cell key={index} fill={COLORS[index % COLORS.length]} cursor={internalAppHref(point.href) ? 'pointer' : undefined} />)}
|
||||
</Pie></PieChart>
|
||||
) : block.chartType === 'bar' ? (
|
||||
<BarChart data={block.points}>{axes}{tooltip}<Bar dataKey="value" isAnimationActive={false} onClick={(_point, index) => navigate(index)}>
|
||||
{block.points.map((point, index) => <Cell key={index} fill={COLORS[index % COLORS.length]} cursor={internalAppHref(point.href) ? 'pointer' : undefined} />)}
|
||||
</Bar></BarChart>
|
||||
) : (
|
||||
<LineChart data={block.points}>{axes}{tooltip}<Line dataKey="value" stroke={COLORS[0]} isAnimationActive={false} activeDot={false} dot={(props) => (
|
||||
<circle key={props.index} cx={props.cx} cy={props.cy} r={5} fill={COLORS[0]} onClick={() => navigate(props.index ?? -1)} style={{ cursor: internalAppHref(props.payload?.href) ? 'pointer' : undefined }} />
|
||||
)} /></LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ul aria-label={`${block.title ?? 'Chart'} data`} className="sr-only focus-within:not-sr-only focus-within:mt-2 focus-within:space-y-1">
|
||||
{block.points.map((point, index) => <li key={index}><PresentationLink href={point.href}>{point.label}: {point.display ?? point.value}</PresentationLink></li>)}
|
||||
</ul>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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 AssistantProvider — creates the shell-level assistant controller (probe, open state,
|
||||
* unread count, hand-over) for the dashboard layout. The panel itself loads lazily.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { AssistantPageContext } from '@/types/assistant'
|
||||
import { getAssistantConfig, listAssistantConversations } from '@/lib/api/assistant-probe'
|
||||
import { readAssistantDeepLink } from '@/lib/utils/assistant-deep-link'
|
||||
import { ASSISTANT_DOCKED_MEDIA_QUERY, AssistantContext, assistantKeys, type AssistantController, type AssistantRequest } from './assistant-context'
|
||||
|
||||
/** localStorage key remembering that the docked panel was open. */
|
||||
const OPEN_STORAGE_KEY = 'qqq-assistant-open'
|
||||
|
||||
/**
|
||||
* Reads a localStorage flag; storage can be unavailable (private mode, blocked site data).
|
||||
*
|
||||
* @param key - The key.
|
||||
* @returns Whether the stored value is `true`.
|
||||
*/
|
||||
function readFlag(key: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(key) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a localStorage flag, ignoring unavailable storage.
|
||||
*
|
||||
* @param key - The key.
|
||||
* @param value - The value.
|
||||
*/
|
||||
function writeFlag(key: string, value: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value))
|
||||
} catch {
|
||||
// storage unavailable: the panel just does not reopen on the next visit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the instance metadata announces the assistant qbit (`supplementalInstanceMetaData.assistant`,
|
||||
* published by qbit-assistant), the precondition for probing `/assistant/config`.
|
||||
*
|
||||
* @param metaData - Instance metadata.
|
||||
* @returns True when the entry is present.
|
||||
*/
|
||||
export function isAssistantInstalled(metaData: QInstance | undefined): boolean {
|
||||
const entry = metaData?.supplementalInstanceMetaData?.assistant
|
||||
return Boolean(entry) && typeof entry === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the assistant controller. Call it once in the dashboard layout (which also needs it
|
||||
* for the Ctrl/⌘+J shortcut) and pass the result to {@link AssistantProvider}.
|
||||
*
|
||||
* @param metaData - Instance metadata (tables, for the panel's page context).
|
||||
* @param isAuthenticated - The probe runs only for a signed-in user.
|
||||
* @returns The controller.
|
||||
*/
|
||||
export function useAssistantController(metaData: QInstance | undefined, isAuthenticated: boolean): AssistantController {
|
||||
const pathname = usePathname() ?? ''
|
||||
const router = useRouter()
|
||||
// Only an instance that announces the qbit is probed: elsewhere /assistant/config would answer
|
||||
// 404 on every page load (a console error in every browser)
|
||||
const installed = isAssistantInstalled(metaData)
|
||||
const { data: config = null } = useQuery({
|
||||
queryKey: assistantKeys.config(),
|
||||
queryFn: getAssistantConfig,
|
||||
enabled: isAuthenticated && installed,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
retry: false,
|
||||
})
|
||||
const enabled = installed && Boolean(config?.enabled)
|
||||
|
||||
const { data: conversations } = useQuery({
|
||||
queryKey: assistantKeys.conversations(),
|
||||
queryFn: () => listAssistantConversations(),
|
||||
enabled,
|
||||
staleTime: 1000 * 60,
|
||||
})
|
||||
const unreadCount = useMemo(() => (conversations ?? []).reduce((total, conversation) => total + (conversation.unreadCount || 0), 0), [conversations])
|
||||
|
||||
const [open, setOpenState] = useState(false)
|
||||
const [activeConversationId, setActiveConversationId] = useState<number | null>(null)
|
||||
const [pendingRequest, setPendingRequest] = useState<AssistantRequest | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const originRef = useRef<HTMLElement | null>(null)
|
||||
const wasOpen = useRef(false)
|
||||
|
||||
const setOpen = useCallback((next: boolean) => {
|
||||
if (next && typeof document !== 'undefined') {
|
||||
const active = document.activeElement
|
||||
originRef.current = active instanceof HTMLElement && active !== document.body && !active.closest('[data-qqq-id="assistant-panel"]') ? active : null
|
||||
}
|
||||
setOpenState(next)
|
||||
if (typeof window !== 'undefined' && window.matchMedia?.(ASSISTANT_DOCKED_MEDIA_QUERY)?.matches) writeFlag(OPEN_STORAGE_KEY, next)
|
||||
}, [])
|
||||
|
||||
// Closing returns focus to what had it before opening, else to the header button
|
||||
useEffect(() => {
|
||||
if (open || !wasOpen.current) {
|
||||
wasOpen.current = open
|
||||
return
|
||||
}
|
||||
wasOpen.current = false
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const target = originRef.current?.isConnected ? originRef.current : triggerRef.current
|
||||
originRef.current = null
|
||||
const active = document.activeElement
|
||||
if (target && (!active || active === document.body || !active.isConnected)) target.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [open])
|
||||
|
||||
// The docked panel reopens on the next visit when it was left open
|
||||
useEffect(() => {
|
||||
if (enabled && readFlag(OPEN_STORAGE_KEY) && window.matchMedia?.(ASSISTANT_DOCKED_MEDIA_QUERY)?.matches) setOpenState(true)
|
||||
}, [enabled])
|
||||
|
||||
// Disabled (kill switch, permission removed): close and hide everything
|
||||
useEffect(() => {
|
||||
if (!enabled) setOpenState(false)
|
||||
}, [enabled])
|
||||
|
||||
const toggle = useCallback(() => setOpen(!open), [open, setOpen])
|
||||
const openWith = useCallback((prompt: string, options?: { context?: AssistantPageContext | null; mode?: 'send' | 'prefill' }) => {
|
||||
const mode = options?.mode ?? 'send'
|
||||
if (mode === 'send' && !prompt.trim()) return
|
||||
setPendingRequest({ id: Date.now(), prompt: prompt.trim(), context: options?.context, mode })
|
||||
setOpen(true)
|
||||
}, [setOpen])
|
||||
|
||||
// Deep link `?assistant=open&assistantPrompt=...` (the record Developer screen's "Ask the
|
||||
// assistant about this record"): a new conversation on this page with the prompt pre-filled,
|
||||
// not sent; both parameters are then removed from the URL. Inert while the assistant is unavailable.
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') return
|
||||
const deepLink = readAssistantDeepLink(window.location.search)
|
||||
if (!deepLink) return
|
||||
openWith(deepLink.prompt, { mode: 'prefill' })
|
||||
router.replace(`${window.location.pathname}${deepLink.remainingSearch}${window.location.hash}`, { scroll: false })
|
||||
}, [enabled, pathname, openWith, router])
|
||||
const consumeRequest = useCallback((id: number) => {
|
||||
setPendingRequest((current) => (current?.id === id ? null : current))
|
||||
}, [])
|
||||
|
||||
return useMemo<AssistantController>(() => ({
|
||||
config,
|
||||
enabled,
|
||||
open: enabled && open,
|
||||
setOpen,
|
||||
toggle,
|
||||
activeConversationId,
|
||||
setActiveConversationId,
|
||||
unreadCount: enabled ? unreadCount : 0,
|
||||
openWith,
|
||||
pendingRequest,
|
||||
consumeRequest,
|
||||
triggerRef,
|
||||
metaData,
|
||||
}), [config, enabled, open, setOpen, toggle, activeConversationId, unreadCount, openWith, pendingRequest, consumeRequest, metaData])
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the assistant controller to the dashboard shell.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.value - The controller from {@link useAssistantController}.
|
||||
* @param props.children - The shell.
|
||||
* @returns The provider.
|
||||
*/
|
||||
export function AssistantProvider({ value, children }: { value: AssistantController; children: React.ReactNode }) {
|
||||
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* 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 AssistantRenders — structured tool-result renders (records table, record, count,
|
||||
* history, widget) and record chips. Everything comes from server payloads, never from
|
||||
* model-written HTML; links pass the same-origin `/app` check.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowRight, Table2 } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
AssistantCountRender,
|
||||
AssistantHistoryRender,
|
||||
AssistantRecordRef,
|
||||
AssistantRecordRender,
|
||||
AssistantRecordsRender,
|
||||
AssistantRender,
|
||||
AssistantWidgetRender,
|
||||
} from '@/types/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
import { RecordHoverCard } from '@/components/records/RecordHoverCard'
|
||||
|
||||
const PendingActionCard = lazy(() => import('./AssistantPendingActionCard'))
|
||||
const WidgetRenderer = lazy(() => import('@/components/widgets/WidgetRenderer').then((m) => ({ default: m.WidgetRenderer })))
|
||||
|
||||
/** Widget types the panel renders inline (charts and statistics); others link to their screen. */
|
||||
const INLINE_WIDGET_TYPES = new Set([
|
||||
'barChart', 'horizontalBarChart', 'stackedBarChart', 'lineChart', 'smallLineChart', 'pieChart', 'chart', 'statistics', 'multiStatistics',
|
||||
])
|
||||
|
||||
/** Classes of a link styled as a small action. */
|
||||
const ACTION_LINK = 'inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded pointer-coarse:min-h-11'
|
||||
|
||||
/**
|
||||
* A same-origin link, or plain text when the href fails the check.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.href - The server-built href.
|
||||
* @param props.children - Link content.
|
||||
* @param props.className - Classes.
|
||||
* @param props.qqqId - The `data-qqq-id`.
|
||||
* @returns The link or a span.
|
||||
*/
|
||||
function SafeLink({ href, children, className, qqqId }: { href: string | undefined; children: React.ReactNode; className?: string; qqqId?: string }) {
|
||||
const internal = internalAppHref(href)
|
||||
if (!internal) return <span className={className} data-qqq-id={qqqId}>{children}</span>
|
||||
return <Link href={internal} className={className} data-qqq-id={qqqId}>{children}</Link>
|
||||
}
|
||||
|
||||
/**
|
||||
* Record references as chips that open the record; hovering previews it.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.refs - The references.
|
||||
* @returns The chip list.
|
||||
*/
|
||||
export function RecordChips({ refs }: { refs: AssistantRecordRef[] }) {
|
||||
if (!refs.length) return null
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-1.5" aria-label="Records" data-qqq-id="assistant-record-refs">
|
||||
{refs.map((ref) => {
|
||||
const href = internalAppHref(ref.href)
|
||||
const chip = (
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 truncate">
|
||||
<Table2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="truncate">{ref.label || `${ref.tableLabel} #${ref.id}`}</span>
|
||||
<span className="sr-only">, {ref.tableLabel}</span>
|
||||
</span>
|
||||
)
|
||||
const chipClass = 'inline-flex max-w-full items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-foreground pointer-coarse:min-h-11'
|
||||
return (
|
||||
<li key={`${ref.tableName}:${ref.id}`} className="max-w-full">
|
||||
{href ? (
|
||||
<RecordHoverCard tableName={ref.tableName} primaryKey={ref.id} tableMetaData={{ label: ref.tableLabel }}>
|
||||
<Link href={href} className={`${chipClass} hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring`} data-qqq-id="assistant-record-chip">
|
||||
{chip}
|
||||
</Link>
|
||||
</RecordHoverCard>
|
||||
) : (
|
||||
<span className={chipClass} data-qqq-id="assistant-record-chip">{chip}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query results: a table where the panel is wide enough, cards where it is narrow.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The records payload.
|
||||
* @returns The records view.
|
||||
*/
|
||||
function RecordsView({ render }: { render: AssistantRecordsRender }) {
|
||||
const shown = render.rows.length
|
||||
const total = render.totalCount ?? shown
|
||||
const caption = `${render.tableLabel}: ${shown === total ? `${total} ${total === 1 ? 'row' : 'rows'}` : `${shown} of ${total} rows`}`
|
||||
const [firstColumn, ...otherColumns] = render.columns
|
||||
return (
|
||||
<div className="@container rounded-lg border border-border bg-card" data-qqq-id="assistant-records">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<span className="truncate text-xs font-medium text-foreground">{caption}</span>
|
||||
<SafeLink href={render.openHref} className={ACTION_LINK} qqqId="assistant-open-query">
|
||||
Open in Query screen<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</SafeLink>
|
||||
</div>
|
||||
{/* wide: a table (scrolls sideways inside its own box when it has many columns) */}
|
||||
<div className="hidden overflow-x-auto @lg:block" tabIndex={0} role="region" aria-label={caption}>
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground">
|
||||
{render.columns.map((column) => <th key={column.name} scope="col" className="whitespace-nowrap px-3 py-1.5 font-medium">{column.label}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{render.rows.map((row) => (
|
||||
<tr key={String(row.id)} className="border-b border-border last:border-0">
|
||||
{render.columns.map((column, index) => (
|
||||
<td key={column.name} className="max-w-[16rem] truncate px-3 py-1.5 text-foreground">
|
||||
{index === 0 ? (
|
||||
<SafeLink href={row.href} className="font-medium text-primary hover:underline">{row.values[column.name] ?? String(row.id)}</SafeLink>
|
||||
) : (row.values[column.name] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* narrow: one card per row */}
|
||||
<ul className="divide-y divide-border @lg:hidden">
|
||||
{render.rows.map((row) => (
|
||||
<li key={String(row.id)} className="px-3 py-2">
|
||||
{firstColumn && (
|
||||
<SafeLink href={row.href} className="block truncate text-sm font-medium text-primary hover:underline">
|
||||
{row.values[firstColumn.name] ?? String(row.id)}
|
||||
</SafeLink>
|
||||
)}
|
||||
<dl className="mt-1 grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
|
||||
{otherColumns.slice(0, 4).map((column) => (
|
||||
<React.Fragment key={column.name}>
|
||||
<dt className="truncate text-muted-foreground">{column.label}</dt>
|
||||
<dd className="truncate text-foreground">{row.values[column.name] ?? ''}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{render.truncated && <p className="border-t border-border px-3 py-1.5 text-xs text-muted-foreground">More rows match; open the query screen to see them all.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One record's fields.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The record payload.
|
||||
* @returns The record card.
|
||||
*/
|
||||
function RecordView({ render }: { render: AssistantRecordRender }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-record">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{render.tableLabel} #{render.id}</span>
|
||||
<SafeLink href={render.href} className={ACTION_LINK}>Open<ArrowRight className="h-3 w-3" aria-hidden="true" /></SafeLink>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
|
||||
{render.fields.map((field) => (
|
||||
<React.Fragment key={field.name}>
|
||||
<dt className="truncate text-muted-foreground">{field.label}</dt>
|
||||
<dd className="break-words text-foreground">{field.value ?? '—'}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A record count.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The count payload.
|
||||
* @returns The count card.
|
||||
*/
|
||||
function CountView({ render }: { render: AssistantCountRender }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-count">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xl font-semibold tabular-nums text-foreground">{render.count.toLocaleString()}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{render.tableLabel}</p>
|
||||
</div>
|
||||
<SafeLink href={render.openHref} className={ACTION_LINK} qqqId="assistant-open-query">
|
||||
Open in Query screen<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</SafeLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A record's change history.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The history payload.
|
||||
* @returns The history list.
|
||||
*/
|
||||
function HistoryView({ render }: { render: AssistantHistoryRender }) {
|
||||
if (!render.entries.length) return <p className="text-xs text-muted-foreground">No changes recorded.</p>
|
||||
return (
|
||||
<ol className="space-y-2 rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-history">
|
||||
{render.entries.map((entry, index) => (
|
||||
<li key={index} className="text-xs">
|
||||
<p className="text-muted-foreground">{formatTimestamp(entry.timestamp)} · {entry.user}</p>
|
||||
<p className="text-foreground">{entry.message}</p>
|
||||
{entry.details.length > 0 && (
|
||||
<ul className="mt-0.5 space-y-0.5 pl-3">
|
||||
{entry.details.map((detail, detailIndex) => (
|
||||
<li key={detailIndex} className="break-words text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{detail.field}</span>: {detail.oldValue ?? '—'} {'→'} {detail.newValue ?? '—'}{detail.truncated ? ' …' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an ISO timestamp for display, keeping unparseable values as they are.
|
||||
*
|
||||
* @param value - The timestamp.
|
||||
* @returns The formatted date and time.
|
||||
*/
|
||||
function formatTimestamp(value: string): string {
|
||||
try {
|
||||
return formatDateTime(value) || value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget payload rendered by the dashboard's own chart and statistics widgets.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The widget payload.
|
||||
* @returns The widget, or its label when the type is not rendered inline.
|
||||
*/
|
||||
function WidgetView({ render }: { render: AssistantWidgetRender }) {
|
||||
if (!INLINE_WIDGET_TYPES.has(render.widgetType)) {
|
||||
return <p className="text-xs text-muted-foreground">{render.label}: this widget type is shown on its dashboard only.</p>
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg border border-border bg-card p-2" data-qqq-id="assistant-widget">
|
||||
<p className="mb-1 truncate text-xs font-medium text-foreground">{render.label}</p>
|
||||
<Suspense fallback={<div className="h-40 rounded bg-muted motion-safe:animate-pulse" />}>
|
||||
<WidgetRenderer widgetMetaData={{ name: render.widgetName, label: render.label, type: render.widgetType, hasPermission: true }} data={render.data} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a tool result payload.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.render - The payload.
|
||||
* @returns The matching view, or null for an unknown type.
|
||||
*/
|
||||
export function AssistantRenderView({ render }: { render: AssistantRender }) {
|
||||
switch (render.type) {
|
||||
case 'records':
|
||||
return <RecordsView render={render} />
|
||||
case 'record':
|
||||
return <RecordView render={render} />
|
||||
case 'count':
|
||||
return <CountView render={render} />
|
||||
case 'history':
|
||||
return <HistoryView render={render} />
|
||||
case 'widget':
|
||||
return <WidgetView render={render} />
|
||||
case 'pending_action':
|
||||
return (
|
||||
<Suspense fallback={<div className="h-24 rounded-lg bg-muted motion-safe:animate-pulse" />}>
|
||||
<PendingActionCard render={render} />
|
||||
</Suspense>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file ConversationRail — the panel's conversation list (pinned, recent, archived) as a
|
||||
* listbox: arrow keys move, Enter or Space opens, unread conversations show a dot.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useId, useRef } from 'react'
|
||||
import { Pin } from 'lucide-react'
|
||||
|
||||
import type { AssistantConversation, AssistantConversationStatus } from '@/types/assistant'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/** Props of {@link ConversationRail}. */
|
||||
interface ConversationRailProps {
|
||||
conversations: AssistantConversation[] | undefined
|
||||
loading: boolean
|
||||
activeId: number | null
|
||||
/** Which list is shown. */
|
||||
status: AssistantConversationStatus
|
||||
/**
|
||||
* Switches between active and archived conversations.
|
||||
*
|
||||
* @param status - The list to show.
|
||||
*/
|
||||
onStatusChange: (status: AssistantConversationStatus) => void
|
||||
/**
|
||||
* Opens a conversation.
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
*/
|
||||
onSelect: (id: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversation's display title.
|
||||
*
|
||||
* @param conversation - The conversation.
|
||||
* @returns Its title, or a placeholder for an untitled one.
|
||||
*/
|
||||
export function conversationTitle(conversation: AssistantConversation | undefined | null): string {
|
||||
return conversation?.title?.trim() || 'New conversation'
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation list.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The rail.
|
||||
*/
|
||||
export function ConversationRail({ conversations, loading, activeId, status, onStatusChange, onSelect, className }: ConversationRailProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const labelId = useId()
|
||||
const list = conversations ?? []
|
||||
const pinned = list.filter((conversation) => conversation.pinned)
|
||||
const recent = list.filter((conversation) => !conversation.pinned)
|
||||
const ordered = [...pinned, ...recent]
|
||||
const focusIndex = Math.max(0, ordered.findIndex((conversation) => conversation.id === activeId))
|
||||
|
||||
/**
|
||||
* Arrow-key navigation between options.
|
||||
*
|
||||
* @param event - The key event.
|
||||
*/
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const options = Array.from(listRef.current?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])
|
||||
const current = options.indexOf(document.activeElement as HTMLElement)
|
||||
let next = -1
|
||||
if (event.key === 'ArrowDown') next = Math.min(options.length - 1, current + 1)
|
||||
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1)
|
||||
else if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = options.length - 1
|
||||
else if ((event.key === 'Enter' || event.key === ' ') && current >= 0) {
|
||||
event.preventDefault()
|
||||
onSelect(Number(options[current].dataset.conversationId))
|
||||
return
|
||||
}
|
||||
if (next >= 0 && options[next]) {
|
||||
event.preventDefault()
|
||||
options.forEach((option, index) => option.setAttribute('tabindex', index === next ? '0' : '-1'))
|
||||
options[next].focus()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One option.
|
||||
*
|
||||
* @param conversation - The conversation.
|
||||
* @param index - Its position in keyboard order.
|
||||
* @returns The option.
|
||||
*/
|
||||
const option = (conversation: AssistantConversation, index: number) => {
|
||||
const selected = conversation.id === activeId
|
||||
const unread = conversation.unreadCount > 0 && !selected
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={index === focusIndex ? 0 : -1}
|
||||
data-conversation-id={conversation.id}
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11',
|
||||
selected ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-qqq-id="assistant-conversation"
|
||||
>
|
||||
{conversation.pinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
<span className={cn('min-w-0 flex-1 truncate', unread && 'font-semibold')}>{conversationTitle(conversation)}</span>
|
||||
{unread && (
|
||||
<>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" aria-hidden="true" data-qqq-id="assistant-conversation-unread" />
|
||||
<span className="sr-only">, unread</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={cn('flex min-h-0 flex-col bg-sidebar', className)} aria-label="Conversations" data-qqq-id="assistant-rail">
|
||||
<div className="flex shrink-0 gap-1 border-b border-border p-1.5" role="group" aria-label="Show">
|
||||
{(['ACTIVE', 'ARCHIVED'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={status === value}
|
||||
onClick={() => onStatusChange(value)}
|
||||
className="flex-1 rounded px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-background aria-pressed:text-foreground pointer-coarse:min-h-11"
|
||||
>
|
||||
{value === 'ACTIVE' ? 'Recent' : 'Archived'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span id={labelId} className="sr-only">{status === 'ACTIVE' ? 'Conversations' : 'Archived conversations'}</span>
|
||||
<div ref={listRef} role="listbox" aria-labelledby={labelId} onKeyDown={onKeyDown} className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-1.5" data-qqq-id="assistant-conversation-list">
|
||||
{loading && <p className="px-2 py-1 text-xs text-muted-foreground" role="status">Loading...</p>}
|
||||
{!loading && ordered.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{status === 'ACTIVE' ? 'No conversations yet.' : 'No archived conversations.'}</p>
|
||||
)}
|
||||
{pinned.length > 0 && (
|
||||
<div role="group" aria-label="Pinned">
|
||||
<p className="px-2 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground" aria-hidden="true">Pinned</p>
|
||||
{pinned.map((conversation, index) => option(conversation, index))}
|
||||
</div>
|
||||
)}
|
||||
{recent.length > 0 && (
|
||||
<div role="group" aria-label={status === 'ACTIVE' ? 'Recent' : 'Archived'}>
|
||||
{pinned.length > 0 && <p className="px-2 pb-0.5 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground" aria-hidden="true">Recent</p>}
|
||||
{recent.map((conversation, index) => option(conversation, pinned.length + index))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Assistant context — the shell-level state of the assistant panel (feature probe,
|
||||
* open state, active conversation, unread count, hand-over requests), shared by the header
|
||||
* button, the command palette, the keyboard shortcut and the lazily loaded panel.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, type RefObject } from 'react'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { AssistantConfig, AssistantConversationStatus, AssistantPageContext } from '@/types/assistant'
|
||||
|
||||
/** The panel docks beside the page at this width and up; below it opens as a sheet. */
|
||||
export const ASSISTANT_DOCKED_MEDIA_QUERY = '(min-width: 1024px)'
|
||||
|
||||
/** TanStack Query keys of the assistant. */
|
||||
export const assistantKeys = {
|
||||
/**
|
||||
* Root of every assistant query.
|
||||
*
|
||||
* @returns The key.
|
||||
*/
|
||||
all: () => ['qqq', 'assistant'] as const,
|
||||
/**
|
||||
* The feature probe.
|
||||
*
|
||||
* @returns The key.
|
||||
*/
|
||||
config: () => [...assistantKeys.all(), 'config'] as const,
|
||||
/**
|
||||
* A conversation list.
|
||||
*
|
||||
* @param status - Active or archived conversations.
|
||||
* @returns The key.
|
||||
*/
|
||||
conversations: (status: AssistantConversationStatus = 'ACTIVE') => [...assistantKeys.all(), 'conversations', status] as const,
|
||||
/**
|
||||
* One conversation and its messages.
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
* @returns The key.
|
||||
*/
|
||||
conversation: (id: number) => [...assistantKeys.all(), 'conversation', id] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt handed to the panel in a new conversation: sent at once (command palette "Ask
|
||||
* Assistant") or only pre-filled in the composer (the `?assistant=open` deep link).
|
||||
*/
|
||||
export interface AssistantRequest {
|
||||
/** Distinguishes requests with the same text. */
|
||||
id: number
|
||||
prompt: string
|
||||
/** `send` asks right away; `prefill` puts the prompt in the composer for the user to send. */
|
||||
mode: 'send' | 'prefill'
|
||||
/** Page context for the request; `undefined` uses the current page. */
|
||||
context?: AssistantPageContext | null
|
||||
}
|
||||
|
||||
/** The shell-level assistant state and actions. */
|
||||
export interface AssistantController {
|
||||
/** The probe result; null while loading or when the qbit is absent. */
|
||||
config: AssistantConfig | null
|
||||
/** Whether the assistant is available: everything is hidden and inert otherwise. */
|
||||
enabled: boolean
|
||||
open: boolean
|
||||
/**
|
||||
* Opens or closes the panel; closing returns focus to where it was.
|
||||
*
|
||||
* @param open - The new state.
|
||||
*/
|
||||
setOpen: (open: boolean) => void
|
||||
/** Opens a closed panel, closes an open one. */
|
||||
toggle: () => void
|
||||
activeConversationId: number | null
|
||||
/**
|
||||
* Shows a conversation (null: a new, empty one).
|
||||
*
|
||||
* @param id - The conversation id.
|
||||
*/
|
||||
setActiveConversationId: (id: number | null) => void
|
||||
/** Unread replies across conversations, for the header badge. */
|
||||
unreadCount: number
|
||||
/**
|
||||
* Opens the panel on a new conversation and asks `prompt` (or only pre-fills it).
|
||||
*
|
||||
* @param prompt - The question.
|
||||
* @param options - The page context to send (defaults to the current page) and whether to send.
|
||||
* @param options.context - The context, or null for none.
|
||||
* @param options.mode - `send` (default) or `prefill`.
|
||||
*/
|
||||
openWith: (prompt: string, options?: { context?: AssistantPageContext | null; mode?: 'send' | 'prefill' }) => void
|
||||
/** A hand-over the panel has not processed yet. */
|
||||
pendingRequest: AssistantRequest | null
|
||||
/**
|
||||
* Marks a hand-over as processed.
|
||||
*
|
||||
* @param id - The request id.
|
||||
*/
|
||||
consumeRequest: (id: number) => void
|
||||
/** The header button, where focus returns when nothing else had it. */
|
||||
triggerRef: RefObject<HTMLButtonElement | null>
|
||||
/** Instance metadata, for record previews. */
|
||||
metaData?: QInstance
|
||||
}
|
||||
|
||||
/** Context carrying the {@link AssistantController}; null outside the dashboard shell. */
|
||||
export const AssistantContext = createContext<AssistantController | null>(null)
|
||||
|
||||
/**
|
||||
* Reads the assistant controller.
|
||||
*
|
||||
* @returns The controller, or null outside the dashboard shell.
|
||||
*/
|
||||
export function useAssistant(): AssistantController | null {
|
||||
return useContext(AssistantContext)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 applying stream events to messages, and the announcer's sentence detection
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssistantMessage, AssistantStreamEvent } from '@/types/assistant'
|
||||
import { endReply, messageText, optimisticTurnMessages, reduceStreamEvent, type TurnMessageIds } from './message-reducer'
|
||||
import { completeSentenceLength } from './use-stream-announcer'
|
||||
|
||||
const REF = { tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice', href: '/app/person/1' }
|
||||
|
||||
/**
|
||||
* Applies events to a fresh optimistic turn.
|
||||
*
|
||||
* @param events - Stream events.
|
||||
* @returns The messages and ids.
|
||||
*/
|
||||
function apply(...events: AssistantStreamEvent[]): { messages: AssistantMessage[]; ids: TurnMessageIds } {
|
||||
const ids: TurnMessageIds = { userId: -1, assistantId: -2 }
|
||||
let messages = optimisticTurnMessages('Hi', { userId: -1, assistantId: -2 }, '2026-10-12T00:00:00Z')
|
||||
for (const event of events) messages = reduceStreamEvent(messages, event, ids)
|
||||
return { messages, ids }
|
||||
}
|
||||
|
||||
const START: AssistantStreamEvent = { type: 'message.start', seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }
|
||||
|
||||
describe('reduceStreamEvent', () => {
|
||||
it('appends a presentation to its message and makes its summary available to announcements', () => {
|
||||
const part = { type: 'presentation' as const, summary: '101 images.', markdown: '101 images.', blocks: [] }
|
||||
const { messages } = apply(START, { type: 'presentation', seq: 2, messageId: 11, part })
|
||||
expect(messages[1].parts).toEqual([part])
|
||||
expect(messageText(messages[1])).toBe('\n101 images.\n')
|
||||
})
|
||||
|
||||
it('swaps temporary ids for the server ids on message.start', () => {
|
||||
const { messages, ids } = apply(START)
|
||||
expect(messages.map((message) => [message.id, message.turnId])).toEqual([[10, 9], [11, 9]])
|
||||
expect(ids).toEqual({ userId: 10, assistantId: 11 })
|
||||
})
|
||||
|
||||
it('appends text deltas to the last text part and starts a new one after other parts', () => {
|
||||
const { messages } = apply(
|
||||
START,
|
||||
{ type: 'text.delta', seq: 2, messageId: 11, text: 'Hel' },
|
||||
{ type: 'text.delta', seq: 3, messageId: 11, text: 'lo' },
|
||||
{ type: 'tool.start', seq: 4, messageId: 11, toolCallId: 't', name: 'count_records', summary: 'Counting' },
|
||||
{ type: 'text.delta', seq: 5, messageId: 11, text: 'Done' },
|
||||
)
|
||||
expect(messages[1].parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
|
||||
expect(messageText(messages[1])).toBe('HelloDone')
|
||||
})
|
||||
|
||||
it('completes tool parts, adds record refs after them without duplicates, and ignores repeated starts', () => {
|
||||
const result: AssistantStreamEvent = { type: 'tool.result', seq: 3, messageId: 11, toolCallId: 't', name: 'get_record', outcome: 'OK', summary: 'Got Alice', recordRefs: [REF] }
|
||||
const { messages } = apply(
|
||||
START,
|
||||
{ type: 'tool.start', seq: 2, messageId: 11, toolCallId: 't', name: 'get_record', summary: 'Getting' },
|
||||
{ type: 'tool.start', seq: 2, messageId: 11, toolCallId: 't', name: 'get_record', summary: 'Getting' },
|
||||
result,
|
||||
{ ...result, seq: 4, recordRefs: [REF, { ...REF, id: 2, label: 'Bob' }] },
|
||||
{ type: 'tool.result', seq: 5, messageId: 11, toolCallId: 'u', name: 'count_records', outcome: 'DENIED', summary: 'Not allowed', render: { type: 'count', tableName: 'x', tableLabel: 'X', count: 1, openHref: '/app/x' } },
|
||||
)
|
||||
expect(messages[1].parts).toEqual([
|
||||
{ type: 'tool', toolCallId: 't', name: 'get_record', summary: 'Got Alice', outcome: 'OK' },
|
||||
{ type: 'record_refs', refs: [REF, { ...REF, id: 2, label: 'Bob' }] },
|
||||
{ type: 'tool', toolCallId: 'u', name: 'count_records', summary: 'Not allowed', outcome: 'DENIED', render: { type: 'count', tableName: 'x', tableLabel: 'X', count: 1, openHref: '/app/x' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('adds navigate, notice and error parts and applies the end status', () => {
|
||||
const { messages } = apply(
|
||||
START,
|
||||
{ type: 'ui.navigate', seq: 2, messageId: 11, href: '/app/person' },
|
||||
{ type: 'notice', seq: 3, messageId: 11, level: 'warning', text: 'Truncated' },
|
||||
{ type: 'error', seq: 4, code: 'BUDGET', message: 'Budget reached' },
|
||||
{ type: 'usage', seq: 5, turnId: 9, inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costMicros: 1 },
|
||||
{ type: 'message.end', seq: 6, messageId: 11, status: 'ERROR' },
|
||||
)
|
||||
expect(messages[1].parts).toEqual([
|
||||
{ type: 'navigate', href: '/app/person' },
|
||||
{ type: 'notice', level: 'warning', text: 'Truncated' },
|
||||
{ type: 'notice', level: 'error', text: 'Budget reached' },
|
||||
])
|
||||
expect(messages[1].status).toBe('ERROR')
|
||||
})
|
||||
|
||||
it('leaves messages unchanged for an unknown message id', () => {
|
||||
const { messages } = apply(START)
|
||||
expect(reduceStreamEvent(messages, { type: 'text.delta', seq: 2, messageId: 99, text: 'x' }, { userId: 10, assistantId: 99 })).toBe(messages)
|
||||
})
|
||||
|
||||
it('keeps the user message when attaching to a reply (no user id)', () => {
|
||||
const messages = optimisticTurnMessages('Hi', { userId: 10, assistantId: 11 }, '2026-10-12T00:00:00Z')
|
||||
const ids: TurnMessageIds = { userId: null, assistantId: 11 }
|
||||
expect(reduceStreamEvent(messages, START, ids).map((message) => message.id)).toEqual([10, 11])
|
||||
})
|
||||
})
|
||||
|
||||
describe('endReply', () => {
|
||||
it('ends a streaming reply with a notice, and keeps a finished status', () => {
|
||||
const { messages } = apply(START)
|
||||
const ended = endReply(messages, 11, 'ERROR', 'Lost')
|
||||
expect(ended[1]).toMatchObject({ status: 'ERROR', parts: [{ type: 'notice', level: 'error', text: 'Lost' }] })
|
||||
expect(endReply(ended, 11, 'CANCELLED')[1].status).toBe('ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeSentenceLength', () => {
|
||||
it('finds the end of the last complete sentence', () => {
|
||||
expect(completeSentenceLength('One. Two! Three')).toBe(9)
|
||||
expect(completeSentenceLength('Line one\nmore')).toBe(9)
|
||||
expect(completeSentenceLength('Version 1.5 is')).toBe(0)
|
||||
expect(completeSentenceLength('Done.')).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 message-reducer — applies turn stream events to a conversation's messages (pure, so
|
||||
* the live reply can be kept in the TanStack Query cache and survive the panel closing).
|
||||
*/
|
||||
|
||||
import type { AssistantMessage, AssistantPart, AssistantRecordRef, AssistantStreamEvent } from '@/types/assistant'
|
||||
|
||||
/** The messages a running turn writes: the user's (until the server id is known) and the reply. */
|
||||
export interface TurnMessageIds {
|
||||
/** The user message id (a temporary negative id until `message.start`), or null when attaching. */
|
||||
userId: number | null
|
||||
/** The assistant message id (temporary until `message.start`). */
|
||||
assistantId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the optimistic messages of a new turn.
|
||||
*
|
||||
* @param text - The user's text.
|
||||
* @param ids - Temporary ids.
|
||||
* @param now - Creation time (ISO).
|
||||
* @returns The user message and the empty streaming reply.
|
||||
*/
|
||||
export function optimisticTurnMessages(text: string, ids: { userId: number; assistantId: number }, now: string): AssistantMessage[] {
|
||||
return [
|
||||
{ id: ids.userId, role: 'USER', status: 'COMPLETE', turnId: null, createDate: now, parts: [{ type: 'text', text }] },
|
||||
{ id: ids.assistantId, role: 'ASSISTANT', status: 'STREAMING', turnId: null, createDate: now, parts: [] },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one stream event to the messages. `ids` is updated in place on `message.start`.
|
||||
*
|
||||
* @param messages - The conversation's messages.
|
||||
* @param event - The stream event.
|
||||
* @param ids - The ids of the running turn's messages.
|
||||
* @returns The new messages (the same array when nothing changed).
|
||||
*/
|
||||
export function reduceStreamEvent(messages: AssistantMessage[], event: AssistantStreamEvent, ids: TurnMessageIds): AssistantMessage[] {
|
||||
switch (event.type) {
|
||||
case 'message.start': {
|
||||
const userId = ids.userId
|
||||
const assistantId = ids.assistantId
|
||||
ids.assistantId = event.messageId
|
||||
if (userId !== null) ids.userId = event.userMessageId
|
||||
return messages.map((message) => {
|
||||
if (userId !== null && message.id === userId) return { ...message, id: event.userMessageId, turnId: event.turnId }
|
||||
if (message.id === assistantId) return { ...message, id: event.messageId, turnId: event.turnId }
|
||||
return message
|
||||
})
|
||||
}
|
||||
case 'text.delta':
|
||||
return updateMessage(messages, ids.assistantId, (message) => {
|
||||
const last = message.parts[message.parts.length - 1]
|
||||
if (last?.type === 'text') return { ...message, parts: [...message.parts.slice(0, -1), { type: 'text', text: last.text + event.text }] }
|
||||
return { ...message, parts: [...message.parts, { type: 'text', text: event.text }] }
|
||||
})
|
||||
case 'tool.start':
|
||||
return updateMessage(messages, ids.assistantId, (message) => {
|
||||
if (message.parts.some((part) => part.type === 'tool' && part.toolCallId === event.toolCallId)) return message
|
||||
return { ...message, parts: [...message.parts, { type: 'tool', toolCallId: event.toolCallId, name: event.name, summary: event.summary }] }
|
||||
})
|
||||
case 'tool.result':
|
||||
return updateMessage(messages, ids.assistantId, (message) => {
|
||||
const parts = [...message.parts]
|
||||
const tool: AssistantPart = { type: 'tool', toolCallId: event.toolCallId, name: event.name, summary: event.summary, outcome: event.outcome, ...(event.render ? { render: event.render } : {}) }
|
||||
let index = parts.findIndex((part) => part.type === 'tool' && part.toolCallId === event.toolCallId)
|
||||
if (index === -1) {
|
||||
parts.push(tool)
|
||||
index = parts.length - 1
|
||||
} else {
|
||||
parts[index] = tool
|
||||
}
|
||||
if (event.recordRefs?.length) {
|
||||
const next = parts[index + 1]
|
||||
if (next?.type === 'record_refs') parts[index + 1] = { type: 'record_refs', refs: mergeRefs(next.refs, event.recordRefs) }
|
||||
else parts.splice(index + 1, 0, { type: 'record_refs', refs: mergeRefs([], event.recordRefs) })
|
||||
}
|
||||
return { ...message, parts }
|
||||
})
|
||||
case 'presentation':
|
||||
return appendPart(messages, event.messageId, event.part)
|
||||
case 'ui.navigate':
|
||||
return appendPart(messages, ids.assistantId, { type: 'navigate', href: event.href })
|
||||
case 'notice':
|
||||
return appendPart(messages, ids.assistantId, { type: 'notice', level: event.level, text: event.text })
|
||||
case 'error':
|
||||
return appendPart(messages, ids.assistantId, { type: 'notice', level: 'error', text: event.message })
|
||||
case 'message.end':
|
||||
return updateMessage(messages, ids.assistantId, (message) => ({ ...message, status: event.status }))
|
||||
default:
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a reply locally (the stream failed or was aborted), with an optional error notice.
|
||||
*
|
||||
* @param messages - The conversation's messages.
|
||||
* @param assistantId - The reply.
|
||||
* @param status - Its final status.
|
||||
* @param notice - An error to show in the reply.
|
||||
* @returns The new messages.
|
||||
*/
|
||||
export function endReply(messages: AssistantMessage[], assistantId: number, status: AssistantMessage['status'], notice?: string): AssistantMessage[] {
|
||||
return updateMessage(messages, assistantId, (message) => ({
|
||||
...message,
|
||||
status: message.status === 'STREAMING' ? status : message.status,
|
||||
parts: notice ? [...message.parts, { type: 'notice', level: 'error', text: notice }] : message.parts,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Text and presentation summaries for the streaming screen-reader announcer.
|
||||
*
|
||||
* @param message - A message.
|
||||
* @returns The concatenated text.
|
||||
*/
|
||||
export function messageText(message: AssistantMessage): string {
|
||||
return message.parts.map((part) => part.type === 'text' ? part.text : part.type === 'presentation' ? `\n${part.summary}\n` : '').join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one message.
|
||||
*
|
||||
* @param messages - The messages.
|
||||
* @param id - The message id.
|
||||
* @param update - Produces the new message.
|
||||
* @returns The new messages, or the same array when the id is absent.
|
||||
*/
|
||||
function updateMessage(messages: AssistantMessage[], id: number, update: (message: AssistantMessage) => AssistantMessage): AssistantMessage[] {
|
||||
const index = messages.findIndex((message) => message.id === id)
|
||||
if (index === -1) return messages
|
||||
const next = [...messages]
|
||||
next[index] = update(messages[index])
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a part to a message.
|
||||
*
|
||||
* @param messages - The messages.
|
||||
* @param id - The message id.
|
||||
* @param part - The part.
|
||||
* @returns The new messages.
|
||||
*/
|
||||
function appendPart(messages: AssistantMessage[], id: number, part: AssistantPart): AssistantMessage[] {
|
||||
return updateMessage(messages, id, (message) => ({ ...message, parts: [...message.parts, part] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds record references, skipping ones already present.
|
||||
*
|
||||
* @param existing - References already shown.
|
||||
* @param added - New references.
|
||||
* @returns The merged list.
|
||||
*/
|
||||
function mergeRefs(existing: AssistantRecordRef[], added: AssistantRecordRef[]): AssistantRecordRef[] {
|
||||
const seen = new Set(existing.map((ref) => `${ref.tableName}:${ref.id}`))
|
||||
const merged = [...existing]
|
||||
for (const ref of added) {
|
||||
const key = `${ref.tableName}:${ref.id}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
merged.push(ref)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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 useAssistantEngine — runs assistant turns: creates the conversation when needed, adds
|
||||
* the optimistic messages, streams the reply into the TanStack Query cache (so it survives the
|
||||
* panel closing and route changes), follows `ui.navigate`, cancels, and resumes a reply that
|
||||
* was still streaming when the conversation was opened.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
AssistantConversation,
|
||||
AssistantConversationDetail,
|
||||
AssistantMessage,
|
||||
AssistantPageContext,
|
||||
AssistantStreamEvent,
|
||||
} from '@/types/assistant'
|
||||
import {
|
||||
AssistantHttpError,
|
||||
cancelAssistantTurn,
|
||||
createAssistantConversation,
|
||||
resumeAssistantTurn,
|
||||
streamAssistantTurn,
|
||||
type AssistantTurnResult,
|
||||
} from '@/lib/api/assistant'
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { assistantKeys, useAssistant } from './assistant-context'
|
||||
import { endReply, optimisticTurnMessages, reduceStreamEvent, type TurnMessageIds } from './message-reducer'
|
||||
|
||||
/** Give up waiting for the server to confirm a cancel after this long, and drop the stream. */
|
||||
const CANCEL_TIMEOUT_MS = 15_000
|
||||
|
||||
/** A turn streaming in one conversation. */
|
||||
export interface AssistantRun {
|
||||
turnId: number | null
|
||||
status: 'running' | 'cancelling'
|
||||
}
|
||||
|
||||
/** The turn runner used by the panel. */
|
||||
export interface AssistantEngine {
|
||||
/** Running turns by conversation id. */
|
||||
runs: Record<number, AssistantRun>
|
||||
/**
|
||||
* Sends the user's text, starting a conversation when `conversationId` is null.
|
||||
*
|
||||
* @param text - The user's message.
|
||||
* @param conversationId - The conversation, or null for a new one.
|
||||
* @param pageContext - Where the user is (omitted when dismissed).
|
||||
*/
|
||||
send: (text: string, conversationId: number | null, pageContext?: AssistantPageContext | null) => Promise<void>
|
||||
/**
|
||||
* Stops the running turn of a conversation.
|
||||
*
|
||||
* @param conversationId - The conversation.
|
||||
*/
|
||||
cancel: (conversationId: number) => void
|
||||
/**
|
||||
* Re-attaches to a reply that is still streaming on the server.
|
||||
*
|
||||
* @param conversationId - The conversation.
|
||||
* @param message - The streaming assistant message.
|
||||
*/
|
||||
attach: (conversationId: number, message: AssistantMessage) => void
|
||||
}
|
||||
|
||||
/** Temporary ids of optimistic messages, below any server id. */
|
||||
let nextTemporaryId = -1
|
||||
|
||||
/**
|
||||
* Creates the turn runner.
|
||||
*
|
||||
* @returns The engine.
|
||||
*/
|
||||
export function useAssistantEngine(): AssistantEngine {
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const assistant = useAssistant()
|
||||
const [runs, setRuns] = useState<Record<number, AssistantRun>>({})
|
||||
const controllers = useRef(new Map<number, AbortController>())
|
||||
const turnIds = useRef(new Map<number, number>())
|
||||
const assistantRef = useRef(assistant)
|
||||
assistantRef.current = assistant
|
||||
|
||||
// Abort every stream when the panel unmounts (sign-out); turns keep running on the server
|
||||
useEffect(() => {
|
||||
const active = controllers.current
|
||||
return () => active.forEach((controller) => controller.abort())
|
||||
}, [])
|
||||
|
||||
const setMessages = useCallback((conversationId: number, update: (messages: AssistantMessage[]) => AssistantMessage[]) => {
|
||||
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(conversationId), (detail) =>
|
||||
detail ? { ...detail, messages: update(detail.messages) } : detail)
|
||||
}, [queryClient])
|
||||
|
||||
const setRun = useCallback((conversationId: number, run: AssistantRun | null) => {
|
||||
setRuns((current) => {
|
||||
const next = { ...current }
|
||||
if (run) next[conversationId] = run
|
||||
else delete next[conversationId]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** Applies one event to the cache and follows navigation. */
|
||||
const handleEvent = useCallback((conversationId: number, event: AssistantStreamEvent, ids: TurnMessageIds) => {
|
||||
setMessages(conversationId, (messages) => reduceStreamEvent(messages, event, ids))
|
||||
if (event.type === 'message.start') {
|
||||
turnIds.current.set(conversationId, event.turnId)
|
||||
setRuns((current) => (current[conversationId] ? { ...current, [conversationId]: { ...current[conversationId], turnId: event.turnId } } : current))
|
||||
}
|
||||
if (event.type === 'ui.navigate') {
|
||||
const href = internalAppHref(event.href)
|
||||
if (href) router.push(href)
|
||||
}
|
||||
}, [router, setMessages])
|
||||
|
||||
/** Reconciles with the server and reports a finished reply the user is not looking at. */
|
||||
const finish = useCallback((conversationId: number, result: AssistantTurnResult | null) => {
|
||||
controllers.current.get(conversationId)?.abort()
|
||||
controllers.current.delete(conversationId)
|
||||
turnIds.current.delete(conversationId)
|
||||
setRun(conversationId, null)
|
||||
// a refused or lost turn keeps its local error (the server may have nothing to show for it)
|
||||
if (result) void queryClient.invalidateQueries({ queryKey: assistantKeys.conversation(conversationId) })
|
||||
void queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
|
||||
const current = assistantRef.current
|
||||
if (result?.status !== 'COMPLETE' || !current) return
|
||||
if (current.open && current.activeConversationId === conversationId) return
|
||||
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) =>
|
||||
list?.map((conversation) => (conversation.id === conversationId ? { ...conversation, unreadCount: conversation.unreadCount + 1 } : conversation)))
|
||||
toast('Assistant finished', {
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => {
|
||||
assistantRef.current?.setActiveConversationId(conversationId)
|
||||
assistantRef.current?.setOpen(true)
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [queryClient, setRun])
|
||||
|
||||
/** Streams a turn (new or resumed) and handles its end. */
|
||||
const run = useCallback(async (conversationId: number, ids: TurnMessageIds, start: (controller: AbortController) => Promise<AssistantTurnResult>) => {
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(conversationId, controller)
|
||||
let result: AssistantTurnResult | null = null
|
||||
try {
|
||||
result = await start(controller)
|
||||
if (result.status === 'ABORTED') setMessages(conversationId, (messages) => endReply(messages, ids.assistantId, 'CANCELLED'))
|
||||
} catch (error) {
|
||||
const message = error instanceof AssistantHttpError || error instanceof Error ? error.message : 'The assistant could not answer.'
|
||||
setMessages(conversationId, (messages) => endReply(messages, ids.assistantId, 'ERROR', message))
|
||||
} finally {
|
||||
finish(conversationId, result)
|
||||
}
|
||||
}, [finish, setMessages])
|
||||
|
||||
const send = useCallback(async (text: string, conversationId: number | null, pageContext?: AssistantPageContext | null) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
let id = conversationId
|
||||
if (id === null) {
|
||||
let conversation: AssistantConversation
|
||||
try {
|
||||
conversation = await createAssistantConversation(pageContext ? { context: pageContext } : {})
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Could not start a conversation.'))
|
||||
return
|
||||
}
|
||||
id = conversation.id
|
||||
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(id), { conversation, messages: [] })
|
||||
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) => [conversation, ...(list ?? []).filter((item) => item.id !== conversation.id)])
|
||||
assistantRef.current?.setActiveConversationId(id)
|
||||
}
|
||||
const conversation = id
|
||||
if (controllers.current.has(conversation)) return
|
||||
const ids: TurnMessageIds = { userId: nextTemporaryId--, assistantId: nextTemporaryId-- }
|
||||
const optimistic = optimisticTurnMessages(trimmed, { userId: ids.userId as number, assistantId: ids.assistantId }, new Date().toISOString())
|
||||
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(conversation), (detail) =>
|
||||
detail ? { ...detail, messages: [...detail.messages, ...optimistic] } : detail)
|
||||
setRun(conversation, { turnId: null, status: 'running' })
|
||||
await run(conversation, ids, (controller) => streamAssistantTurn(
|
||||
conversation,
|
||||
pageContext ? { text: trimmed, pageContext } : { text: trimmed },
|
||||
{ signal: controller.signal, onEvent: (event) => handleEvent(conversation, event, ids) }
|
||||
))
|
||||
}, [handleEvent, queryClient, run, setRun])
|
||||
|
||||
const attach = useCallback((conversationId: number, message: AssistantMessage) => {
|
||||
if (controllers.current.has(conversationId) || message.turnId === null) return
|
||||
const turnId = message.turnId
|
||||
const ids: TurnMessageIds = { userId: null, assistantId: message.id }
|
||||
// the replay rebuilds the reply from its first event
|
||||
setMessages(conversationId, (messages) => messages.map((item) => (item.id === message.id ? { ...item, parts: [] } : item)))
|
||||
turnIds.current.set(conversationId, turnId)
|
||||
setRun(conversationId, { turnId, status: 'running' })
|
||||
void run(conversationId, ids, (controller) => resumeAssistantTurn(turnId, 0, {
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => handleEvent(conversationId, event, ids),
|
||||
}))
|
||||
}, [handleEvent, run, setMessages, setRun])
|
||||
|
||||
const cancel = useCallback((conversationId: number) => {
|
||||
const controller = controllers.current.get(conversationId)
|
||||
if (!controller) return
|
||||
const turnId = turnIds.current.get(conversationId)
|
||||
setRuns((current) => (current[conversationId] ? { ...current, [conversationId]: { ...current[conversationId], status: 'cancelling' } } : current))
|
||||
if (turnId === undefined) {
|
||||
// the server has not started the turn yet: drop the request
|
||||
controller.abort()
|
||||
return
|
||||
}
|
||||
// the stream ends with a CANCELLED message.end; drop it if the server does not confirm in time
|
||||
const timer = setTimeout(() => controller.abort(), CANCEL_TIMEOUT_MS)
|
||||
controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
|
||||
cancelAssistantTurn(turnId).catch(() => controller.abort())
|
||||
}, [])
|
||||
|
||||
return { runs, send, cancel, attach }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file useStreamAnnouncer — screen reader announcements for a streaming reply, throttled to
|
||||
* whole sentences (at most one announcement per interval) and the rest on completion, instead
|
||||
* of every token.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { AssistantMessage } from '@/types/assistant'
|
||||
import { messageText } from './message-reducer'
|
||||
|
||||
/** Minimum time between two announcements while text streams. */
|
||||
export const ANNOUNCE_INTERVAL_MS = 1500
|
||||
|
||||
/**
|
||||
* The length of `text` up to and including its last sentence end (`.`, `!`, `?` or a line
|
||||
* break, followed by whitespace or the end).
|
||||
*
|
||||
* @param text - Text not yet announced.
|
||||
* @returns The length to announce, 0 when no sentence is complete.
|
||||
*/
|
||||
export function completeSentenceLength(text: string): number {
|
||||
let end = 0
|
||||
const pattern = /[.!?](?=\s|$)|\n/g
|
||||
for (let match = pattern.exec(text); match; match = pattern.exec(text)) end = match.index + 1
|
||||
return end
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the latest assistant reply and returns the text to put in a polite live region.
|
||||
*
|
||||
* @param messages - The visible conversation's messages.
|
||||
* @returns The current announcement.
|
||||
*/
|
||||
export function useStreamAnnouncer(messages: AssistantMessage[]): string {
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [tick, setTick] = useState(0)
|
||||
const state = useRef<{ id: number | null; spoken: number; at: number; streaming: boolean }>({ id: null, spoken: 0, at: 0, streaming: false })
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const reply = [...messages].reverse().find((message) => message.role === 'ASSISTANT')
|
||||
if (!reply) return
|
||||
const current = state.current
|
||||
const text = messageText(reply)
|
||||
if (current.id !== reply.id) {
|
||||
// a new reply (or the temporary id replaced by the server's): keep what was already spoken
|
||||
const continued = current.streaming && reply.status !== 'COMPLETE' && text.length >= current.spoken
|
||||
state.current = { id: reply.id, spoken: continued ? current.spoken : 0, at: continued ? current.at : 0, streaming: reply.status === 'STREAMING' }
|
||||
}
|
||||
const tracked = state.current
|
||||
if (reply.status === 'STREAMING') {
|
||||
tracked.streaming = true
|
||||
const pending = text.slice(tracked.spoken)
|
||||
const length = completeSentenceLength(pending)
|
||||
if (!length) return
|
||||
const wait = tracked.at + ANNOUNCE_INTERVAL_MS - Date.now()
|
||||
if (wait > 0) {
|
||||
if (!timer.current) timer.current = setTimeout(() => { timer.current = null; setTick((value) => value + 1) }, wait)
|
||||
return
|
||||
}
|
||||
tracked.spoken += length
|
||||
tracked.at = Date.now()
|
||||
setAnnouncement(pending.slice(0, length).trim())
|
||||
return
|
||||
}
|
||||
if (!tracked.streaming) return
|
||||
// finished: announce the rest, or how it ended
|
||||
tracked.streaming = false
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
const rest = text.slice(tracked.spoken).trim()
|
||||
tracked.spoken = text.length
|
||||
if (reply.status === 'CANCELLED') setAnnouncement(rest ? `${rest} Stopped.` : 'Stopped.')
|
||||
else if (reply.status === 'ERROR') setAnnouncement('The reply did not finish.')
|
||||
else setAnnouncement(rest || 'Reply complete.')
|
||||
}, [messages, tick])
|
||||
|
||||
return announcement
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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 AUTHENTIK_POLICY_EDITOR widget: matrix, select-all, search, unknowns, JSON output
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { axe } from 'jest-axe'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QWidgetMetaData } from '@/types'
|
||||
import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
|
||||
import { AuthentikPolicyEditorWidget, isPolicyPermission } from './AuthentikPolicyEditorWidget'
|
||||
import type { PolicyEditorPayload } from './AuthentikPolicyEditorWidget'
|
||||
|
||||
const widget = { name: 'authentikPolicyEditor', type: 'AUTHENTIK_POLICY_EDITOR', label: 'Policy' } as unknown as QWidgetMetaData
|
||||
|
||||
const payload = (overrides: Partial<PolicyEditorPayload> = {}): PolicyEditorPayload => ({
|
||||
spec: 'qqq-iam',
|
||||
version: '1.0.0',
|
||||
groupLabel: 'editors',
|
||||
catalog: [
|
||||
{
|
||||
app: 'content',
|
||||
label: 'Content',
|
||||
resources: [
|
||||
{ name: 'content', label: 'Content', kind: 'APP', actions: ['hasAccess'] },
|
||||
{ name: 'cms_page', label: 'Pages', kind: 'TABLE', actions: ['read', 'insert', 'edit', 'delete'] },
|
||||
{ name: 'publishPage', label: 'Publish Page (Pages action)', kind: 'PROCESS', actions: ['hasAccess'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
app: null,
|
||||
label: 'Other',
|
||||
resources: [{ name: 'audit_log', label: 'Audit Log', kind: 'TABLE', actions: ['read'], note: 'Read only.' }],
|
||||
},
|
||||
],
|
||||
document: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
/**
|
||||
* Render the editor and capture what it hands the step.
|
||||
* @param data - Payload.
|
||||
* @returns The callback mock.
|
||||
*/
|
||||
function setup(data: PolicyEditorPayload) {
|
||||
const onWidgetData = vi.fn()
|
||||
const view = render(<AuthentikPolicyEditorWidget widgetMetaData={widget} data={data as unknown as Record<string, unknown>} onWidgetData={onWidgetData} />)
|
||||
const last = () => String(onWidgetData.mock.calls.at(-1)?.[0]?.policyDocument ?? '')
|
||||
return { onWidgetData, last, ...view }
|
||||
}
|
||||
|
||||
describe('AuthentikPolicyEditorWidget', () => {
|
||||
it('starts from the existing document and hands the canonical JSON to the step', async () => {
|
||||
const { last } = setup(payload({ document: '{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Allow","Action":["*"],"Resource":["cms_page"]}]}' }))
|
||||
expect(screen.getByLabelText('Read: Pages')).toBeChecked()
|
||||
expect(screen.getByLabelText('Delete: Pages')).toBeChecked()
|
||||
expect(screen.getByLabelText('Access: Content')).not.toBeChecked()
|
||||
await waitFor(() => expect(last()).toContain('"Action":["read"],"Resource":["cms_page"]'))
|
||||
// the hasAccess grant on a table that does not offer it is kept, and listed as unknown
|
||||
expect(screen.getByText('cms_page.hasAccess')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles a cell, a row, a column and an app', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { last } = setup(payload())
|
||||
|
||||
await user.click(screen.getByLabelText('Access: Content'))
|
||||
await waitFor(() => expect(last()).toContain('"Action":["hasAccess"],"Resource":["content"]'))
|
||||
|
||||
await user.click(screen.getByLabelText('All actions: Pages'))
|
||||
for (const action of ['Read', 'Insert', 'Edit', 'Delete']) expect(screen.getByLabelText(`${action}: Pages`)).toBeChecked()
|
||||
|
||||
await user.click(screen.getByLabelText('Access for everything in Content'))
|
||||
expect(screen.getByLabelText('Access: Publish Page (Pages action)')).toBeChecked()
|
||||
|
||||
await user.click(screen.getByLabelText('Everything in Content'))
|
||||
expect(screen.getByLabelText('Access: Content')).not.toBeChecked()
|
||||
expect(screen.getByLabelText('Read: Pages')).not.toBeChecked()
|
||||
|
||||
await user.click(screen.getByLabelText('Everything in Content'))
|
||||
expect(screen.getByText('6 permissions selected')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Clear all' }))
|
||||
expect(screen.getByText('0 permissions selected')).toBeInTheDocument()
|
||||
await waitFor(() => expect(last()).toBe('{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[]}'))
|
||||
})
|
||||
|
||||
it('shows the mixed state on partial selections', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup(payload())
|
||||
await user.click(screen.getByLabelText('Read: Pages'))
|
||||
expect(screen.getByLabelText('All actions: Pages')).toHaveAttribute('aria-checked', 'mixed')
|
||||
expect(screen.getByLabelText('Everything in Content')).toHaveAttribute('aria-checked', 'mixed')
|
||||
})
|
||||
|
||||
it('filters by search and keeps hidden selections', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup(payload({ document: '{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Allow","Action":["read"],"Resource":["audit_log"]}]}' }))
|
||||
await user.type(screen.getByLabelText('Find a resource'), 'publish')
|
||||
expect(screen.getByLabelText('Access: Publish Page (Pages action)')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Read: Audit Log')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 permission selected')).toBeInTheDocument()
|
||||
|
||||
await user.clear(screen.getByLabelText('Find a resource'))
|
||||
await user.type(screen.getByLabelText('Find a resource'), 'zzz')
|
||||
expect(screen.getByText('No resource matches the search.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists unknown resources and removes them', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { last } = setup(payload({ document: '{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Allow","Action":["read"],"Resource":["gone_table","cms_page"]}]}' }))
|
||||
const unknown = screen.getByRole('region', { name: 'Not in this app (1)' })
|
||||
expect(within(unknown).getByText('gone_table.read')).toBeInTheDocument()
|
||||
await waitFor(() => expect(last()).toContain('"gone_table"'))
|
||||
await user.click(within(unknown).getByRole('button', { name: 'Remove gone_table.read' }))
|
||||
expect(screen.queryByRole('region', { name: /Not in this app/ })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(last()).not.toContain('gone_table'))
|
||||
})
|
||||
|
||||
it('reports an invalid stored document and a change made in Authentik', () => {
|
||||
setup(payload({ document: '{"PolicySpec":"other","PolicyVersion":"1.0.0"}', changedInAuthentik: true }))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('PolicySpec must be "qqq-iam"')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('changed in Authentik')
|
||||
expect(screen.getByText('0 permissions selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the JSON preview and the check', () => {
|
||||
setup(payload())
|
||||
expect(screen.getByLabelText('Policy document JSON').textContent).toContain('"PolicySpec": "qqq-iam"')
|
||||
expect(screen.getByText('0 statements; checked')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('writes to a custom output field', async () => {
|
||||
const onWidgetData = vi.fn()
|
||||
render(<AuthentikPolicyEditorWidget widgetMetaData={widget} data={payload({ outputFieldName: 'doc' }) as unknown as Record<string, unknown>} onWidgetData={onWidgetData} />)
|
||||
await waitFor(() => expect(onWidgetData).toHaveBeenCalledWith({ doc: expect.stringContaining('qqq-iam') }))
|
||||
})
|
||||
|
||||
it('reports a malformed payload', () => {
|
||||
render(<AuthentikPolicyEditorWidget widgetMetaData={widget} data={{ catalog: [] }} />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('not in the expected format')
|
||||
})
|
||||
|
||||
it('is rendered lazily by WidgetRenderer', async () => {
|
||||
render(<WidgetRenderer widgetMetaData={widget} data={payload() as unknown as Record<string, unknown>} />)
|
||||
expect(await screen.findByLabelText('Read: Pages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no accessibility violations', async () => {
|
||||
const { container } = setup(payload({ document: '{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Allow","Action":["read"],"Resource":["gone"]}]}' }))
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('knows a policy permission', () => {
|
||||
expect(isPolicyPermission('a.read')).toBe(true)
|
||||
expect(isPolicyPermission('a.b.read')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* 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 AuthentikPolicyEditorWidget — the `AUTHENTIK_POLICY_EDITOR` widget of qbit-authentik
|
||||
* (its DESIGN.md section 12.4): edits a group's policy document as a matrix of the app's
|
||||
* resources (apps, tables, processes, reports, widgets from metadata, grouped by app) by action,
|
||||
* with search, select-all per row, per column and per app, a list of resources the app does not
|
||||
* know, and a live read-only JSON preview. On a process step it hands the generated document to
|
||||
* the step as `{ [outputFieldName]: compactJson }`; the server validates it again on Review.
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { WidgetComponentProps } from '@/components/widgets/widget-types'
|
||||
import { asList, isPlainObject } from '@/components/widgets/widget-types'
|
||||
import { ALERT_CLASS, INPUT_CLASS, WARNING_CLASS, buttonClass } from '@/components/ui/controls'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import type { PolicyAction, PolicyFormat } from './policy-document'
|
||||
import { POLICY_ACTIONS, generatePolicy, parsePolicy, permissionName, roundTrips, splitPermission } from './policy-document'
|
||||
|
||||
/** One grantable resource in the catalog. */
|
||||
export interface PolicyCatalogResource {
|
||||
name: string
|
||||
label: string
|
||||
kind: 'APP' | 'TABLE' | 'PROCESS' | 'REPORT' | 'WIDGET'
|
||||
actions: PolicyAction[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** The resources of one app (`app` null: in no app). */
|
||||
export interface PolicyCatalogGroup {
|
||||
app: string | null
|
||||
label: string
|
||||
resources: PolicyCatalogResource[]
|
||||
}
|
||||
|
||||
/** The widget's payload from the qbit. */
|
||||
export interface PolicyEditorPayload {
|
||||
spec: string
|
||||
version: string
|
||||
/** The group the policy belongs to (display only). */
|
||||
groupLabel?: string
|
||||
catalog: PolicyCatalogGroup[]
|
||||
/** The group's current document (JSON text), or empty for none. */
|
||||
document?: string
|
||||
/** Why the current document in Authentik does not parse, if it does not. */
|
||||
documentProblem?: string
|
||||
/** The document in Authentik differs from the last save made in QQQ. */
|
||||
changedInAuthentik?: boolean
|
||||
/** Process value the generated document goes to (default `policyDocument`). */
|
||||
outputFieldName?: string
|
||||
}
|
||||
|
||||
/** Column labels of the actions. */
|
||||
const ACTION_LABELS: Record<PolicyAction, string> = { hasAccess: 'Access', read: 'Read', insert: 'Insert', edit: 'Edit', delete: 'Delete' }
|
||||
|
||||
/** Plain names of the resource kinds. */
|
||||
const KIND_LABELS: Record<PolicyCatalogResource['kind'], string> = { APP: 'App', TABLE: 'Table', PROCESS: 'Process', REPORT: 'Report', WIDGET: 'Widget' }
|
||||
|
||||
/**
|
||||
* A checkbox that can show the mixed state.
|
||||
* @param props - Checked state, mixed flag, label and change handler.
|
||||
* @returns The input.
|
||||
*/
|
||||
function TriStateCheckbox({ checked, mixed, label, onChange, disabled, qqqId }: { checked: boolean; mixed: boolean; label: string; onChange: (next: boolean) => void; disabled?: boolean; qqqId?: string }) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = mixed
|
||||
}, [mixed])
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 cursor-pointer accent-[var(--color-primary)] disabled:cursor-not-allowed"
|
||||
checked={checked}
|
||||
aria-checked={mixed ? 'mixed' : checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
data-qqq-id={qqqId}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a set of permissions within a selection.
|
||||
* @param permissions - The permissions.
|
||||
* @param selected - The selection.
|
||||
* @returns Whether all are selected, and whether only some are.
|
||||
*/
|
||||
function stateOf(permissions: string[], selected: Set<string>): { checked: boolean; mixed: boolean } {
|
||||
const count = permissions.filter((permission) => selected.has(permission)).length
|
||||
return { checked: permissions.length > 0 && count === permissions.length, mixed: count > 0 && count < permissions.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy editor.
|
||||
* @param props - Widget metadata, payload and the step callback.
|
||||
* @returns The editor, or a notice for a malformed payload.
|
||||
*/
|
||||
export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData }: WidgetComponentProps<Record<string, unknown>>) {
|
||||
const payload = data as unknown as PolicyEditorPayload
|
||||
const catalog = useMemo(() => (asList<PolicyCatalogGroup>(payload.catalog) ?? []).filter((group) => isPlainObject(group) && Array.isArray(group.resources)), [payload.catalog])
|
||||
const format: PolicyFormat = useMemo(() => ({ spec: String(payload.spec ?? ''), version: String(payload.version ?? '') }), [payload.spec, payload.version])
|
||||
const outputFieldName = typeof payload.outputFieldName === 'string' && payload.outputFieldName ? payload.outputFieldName : 'policyDocument'
|
||||
const searchId = useId()
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// what this app can grant: permission -> its resource and label //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
const known = useMemo(() => {
|
||||
const map = new Map<string, PolicyCatalogResource>()
|
||||
for (const group of catalog) {
|
||||
for (const resource of group.resources) {
|
||||
for (const action of resource.actions ?? []) map.set(permissionName(resource.name, action), resource)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [catalog])
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!payload.document || payload.document.trim() === '') return { permissions: new Set<string>(), problem: payload.documentProblem ?? null }
|
||||
const parsed = parsePolicy(payload.document, format)
|
||||
return parsed.ok ? { permissions: parsed.permissions, problem: payload.documentProblem ?? null } : { permissions: new Set<string>(), problem: payload.documentProblem ?? parsed.error }
|
||||
}, [format, payload.document, payload.documentProblem])
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set(initial.permissions))
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const generated = useMemo(() => generatePolicy(selected, format), [format, selected])
|
||||
const unknown = useMemo(() => [...selected].filter((permission) => !known.has(permission)).sort(), [known, selected])
|
||||
const valid = useMemo(() => roundTrips(selected, format), [format, selected])
|
||||
|
||||
useEffect(() => {
|
||||
onWidgetData?.({ [outputFieldName]: generated.compact })
|
||||
}, [generated.compact, onWidgetData, outputFieldName])
|
||||
|
||||
const term = search.trim().toLowerCase()
|
||||
const visibleGroups = useMemo(() => catalog
|
||||
.map((group) => ({
|
||||
...group,
|
||||
resources: term === '' || group.label.toLowerCase().includes(term)
|
||||
? group.resources
|
||||
: group.resources.filter((resource) => resource.label.toLowerCase().includes(term) || resource.name.toLowerCase().includes(term)),
|
||||
}))
|
||||
.filter((group) => group.resources.length > 0), [catalog, term])
|
||||
|
||||
const set = (permissions: string[], on: boolean) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current)
|
||||
permissions.forEach((permission) => (on ? next.add(permission) : next.delete(permission)))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const resourceCount = catalog.reduce((sum, group) => sum + group.resources.length, 0)
|
||||
if (!payload.spec || !payload.version) {
|
||||
return <p role="alert" className={ALERT_CLASS}>The policy editor data is not in the expected format (spec).</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container flex min-w-0 flex-col gap-4" data-qqq-id={`policy-editor-${widgetMetaData.name}`}>
|
||||
{payload.changedInAuthentik && (
|
||||
<p className={WARNING_CLASS} role="status" data-qqq-id="policy-editor-changed">
|
||||
This policy was changed in Authentik since it was last saved here. The editor shows what Authentik holds now.
|
||||
</p>
|
||||
)}
|
||||
{initial.problem && (
|
||||
<p className={ALERT_CLASS} role="alert" data-qqq-id="policy-editor-problem">
|
||||
{`The policy stored in Authentik is not valid, so it grants nothing: ${initial.problem}. Saving replaces it.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-end @xl:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label htmlFor={searchId} className="text-sm font-medium text-foreground">Find a resource</label>
|
||||
<input
|
||||
id={searchId}
|
||||
type="search"
|
||||
className={cn(INPUT_CLASS, '@xl:max-w-sm')}
|
||||
placeholder="App, table, process or report"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
data-qqq-id="policy-editor-search"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<span data-qqq-id="policy-editor-count">{`${selected.size} permission${selected.size === 1 ? '' : 's'} selected`}</span>
|
||||
<button type="button" className={buttonClass('secondary', 'sm')} onClick={() => setSelected(new Set())} disabled={selected.size === 0} data-qqq-id="policy-editor-clear">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-4 @5xl:grid-cols-[minmax(0,1fr)_minmax(18rem,26rem)]">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{resourceCount === 0 && <p className="text-sm text-muted-foreground">This app has no resources that a policy can grant.</p>}
|
||||
{resourceCount > 0 && visibleGroups.length === 0 && <p className="text-sm text-muted-foreground" role="status">No resource matches the search.</p>}
|
||||
{visibleGroups.map((group) => (
|
||||
<PolicyGroupSection key={group.app ?? '(other)'} group={group} selected={selected} onSet={set} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{unknown.length > 0 && (
|
||||
<section className={WARNING_CLASS} aria-labelledby="policy-editor-unknown-title" data-qqq-id="policy-editor-unknown">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 id="policy-editor-unknown-title" className="font-medium">{`Not in this app (${unknown.length})`}</h3>
|
||||
<button type="button" className={buttonClass('secondary', 'sm')} onClick={() => set(unknown, false)}>Remove all</button>
|
||||
</div>
|
||||
<p className="mt-1">These are kept until you remove them, for example a table that no longer exists. New ones are refused when you save.</p>
|
||||
<ul className="mt-2 flex flex-col gap-1">
|
||||
{unknown.map((permission) => (
|
||||
<li key={permission} className="flex min-w-0 items-center justify-between gap-2">
|
||||
<code className="min-w-0 break-all">{permission}</code>
|
||||
<button type="button" className={buttonClass('ghost', 'sm')} onClick={() => set([permission], false)} aria-label={`Remove ${permission}`}>Remove</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-md border border-border bg-card" aria-labelledby="policy-editor-json-title">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<h3 id="policy-editor-json-title" className="text-sm font-medium text-foreground">Policy document</h3>
|
||||
<span className={cn('text-xs', valid ? 'text-muted-foreground' : 'text-destructive')} data-qqq-id="policy-editor-valid">
|
||||
{valid ? `${generated.statementCount} statement${generated.statementCount === 1 ? '' : 's'}; checked` : 'This document does not round-trip; it cannot be saved'}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-all p-3 font-mono text-xs leading-5 text-foreground" aria-label="Policy document JSON" data-qqq-id="policy-editor-json">{generated.pretty}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One app's resources with its select-all controls.
|
||||
* @param props - The group, the selection and the setter.
|
||||
* @returns The section.
|
||||
*/
|
||||
function PolicyGroupSection({ group, selected, onSet }: { group: PolicyCatalogGroup; selected: Set<string>; onSet: (permissions: string[], on: boolean) => void }) {
|
||||
const all = group.resources.flatMap((resource) => (resource.actions ?? []).map((action) => permissionName(resource.name, action)))
|
||||
const groupState = stateOf(all, selected)
|
||||
const count = all.filter((permission) => selected.has(permission)).length
|
||||
const slug = (group.app ?? 'other').replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
|
||||
return (
|
||||
<section className="@container min-w-0 rounded-md border border-border bg-card" aria-label={group.label} data-qqq-id={`policy-group-${slug}`}>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-3 py-2">
|
||||
<label className="flex min-w-0 items-center gap-2 font-medium text-foreground">
|
||||
<TriStateCheckbox checked={groupState.checked} mixed={groupState.mixed} label={`Everything in ${group.label}`} onChange={(on) => onSet(all, on)} qqqId={`policy-group-all-${slug}`} />
|
||||
<span className="min-w-0 break-words">{group.label}</span>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">{`${count} of ${all.length}`}</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden border-b border-border px-3 py-1.5 text-xs font-medium text-muted-foreground @xl:grid @xl:grid-cols-[minmax(0,1fr)_repeat(6,3.75rem)] @xl:items-center">
|
||||
<span>Resource</span>
|
||||
{POLICY_ACTIONS.map((action) => {
|
||||
const column = group.resources.filter((resource) => resource.actions?.includes(action)).map((resource) => permissionName(resource.name, action))
|
||||
const state = stateOf(column, selected)
|
||||
return (
|
||||
<span key={action} className="flex flex-col items-center gap-0.5">
|
||||
<span>{ACTION_LABELS[action]}</span>
|
||||
<TriStateCheckbox checked={state.checked} mixed={state.mixed} disabled={column.length === 0} label={`${ACTION_LABELS[action]} for everything in ${group.label}`} onChange={(on) => onSet(column, on)} qqqId={`policy-col-${slug}-${action}`} />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<span className="text-center">All</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col">
|
||||
{group.resources.map((resource) => {
|
||||
const row = (resource.actions ?? []).map((action) => permissionName(resource.name, action))
|
||||
const rowState = stateOf(row, selected)
|
||||
return (
|
||||
<li key={resource.name} className="grid grid-cols-1 gap-1 border-b border-border px-3 py-2 last:border-b-0 @xl:grid-cols-[minmax(0,1fr)_repeat(6,3.75rem)] @xl:items-center @xl:gap-0" data-qqq-id={`policy-row-${resource.name}`}>
|
||||
<div className="min-w-0">
|
||||
<div className="break-words text-sm text-foreground">{resource.label}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<span>{KIND_LABELS[resource.kind] ?? resource.kind}</span>
|
||||
<code className="break-all">{resource.name}</code>
|
||||
</div>
|
||||
{resource.note && <div className="mt-0.5 text-xs text-muted-foreground">{resource.note}</div>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 @xl:contents">
|
||||
{POLICY_ACTIONS.map((action) => {
|
||||
const permission = permissionName(resource.name, action)
|
||||
const offered = resource.actions?.includes(action) ?? false
|
||||
return (
|
||||
<span key={action} className={cn('items-center gap-1.5 @xl:justify-center', offered ? 'flex' : 'hidden @xl:flex')}>
|
||||
{offered
|
||||
? (
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 cursor-pointer accent-[var(--color-primary)]"
|
||||
checked={selected.has(permission)}
|
||||
aria-label={`${ACTION_LABELS[action]}: ${resource.label}`}
|
||||
onChange={(event) => onSet([permission], event.target.checked)}
|
||||
data-qqq-id={`policy-cell-${resource.name}-${action}`}
|
||||
/>
|
||||
<span className="@xl:sr-only">{ACTION_LABELS[action]}</span>
|
||||
</label>
|
||||
)
|
||||
: <span className="text-xs text-muted-foreground" aria-hidden="true">-</span>}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<span className="flex items-center gap-1.5 @xl:justify-center">
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<TriStateCheckbox checked={rowState.checked} mixed={rowState.mixed} label={`All actions: ${resource.label}`} onChange={(on) => onSet(row, on)} qqqId={`policy-row-all-${resource.name}`} />
|
||||
<span className="@xl:sr-only">All</span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a permission is a well-formed policy permission (exported for tests).
|
||||
* @param permission - Candidate.
|
||||
* @returns True for `{resource}.{action}`.
|
||||
*/
|
||||
export function isPolicyPermission(permission: string): boolean {
|
||||
return splitPermission(permission) !== null
|
||||
}
|
||||
|
||||
export default AuthentikPolicyEditorWidget
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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 policy document model: the same canonical form and fail-closed rules as the qbit
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { generatePolicy, parsePolicy, roundTrips, splitPermission } from './policy-document'
|
||||
|
||||
const FORMAT = { spec: 'qqq-iam', version: '1.0.0' }
|
||||
|
||||
describe('generatePolicy', () => {
|
||||
it('writes the canonical form the qbit writes (byte for byte)', () => {
|
||||
const doc = generatePolicy(['cms_page.edit', 'contentApp.hasAccess', 'cms_page.read', 'cms_media.read', 'bad', 'x.write'], FORMAT)
|
||||
expect(doc.compact).toBe('{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":['
|
||||
+ '{"Effect":"Allow","Action":["hasAccess"],"Resource":["contentApp"]},'
|
||||
+ '{"Effect":"Allow","Action":["read"],"Resource":["cms_media","cms_page"]},'
|
||||
+ '{"Effect":"Allow","Action":["edit"],"Resource":["cms_page"]}]}')
|
||||
expect(doc.pretty).toBe([
|
||||
'{',
|
||||
' "PolicySpec": "qqq-iam",',
|
||||
' "PolicyVersion": "1.0.0",',
|
||||
' "Statement": [',
|
||||
' {',
|
||||
' "Effect": "Allow",',
|
||||
' "Action": ["hasAccess"],',
|
||||
' "Resource": [',
|
||||
' "contentApp"',
|
||||
' ]',
|
||||
' },',
|
||||
' {',
|
||||
' "Effect": "Allow",',
|
||||
' "Action": ["read"],',
|
||||
' "Resource": [',
|
||||
' "cms_media",',
|
||||
' "cms_page"',
|
||||
' ]',
|
||||
' },',
|
||||
' {',
|
||||
' "Effect": "Allow",',
|
||||
' "Action": ["edit"],',
|
||||
' "Resource": [',
|
||||
' "cms_page"',
|
||||
' ]',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(doc.statementCount).toBe(3)
|
||||
})
|
||||
|
||||
it('writes an empty document', () => {
|
||||
const doc = generatePolicy([], FORMAT)
|
||||
expect(doc.compact).toBe('{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[]}')
|
||||
expect(doc.pretty).toBe('{\n "PolicySpec": "qqq-iam",\n "PolicyVersion": "1.0.0",\n "Statement": []\n}\n')
|
||||
})
|
||||
|
||||
it('sorts resources like Java (UTF-16 code units: upper case first)', () => {
|
||||
expect(generatePolicy(['b.read', 'B.read', 'a_1.read', 'a.read'], FORMAT).compact).toContain('["B","a","a_1","b"]')
|
||||
})
|
||||
|
||||
it('round-trips random sets', () => {
|
||||
const resources = ['a', 'B_2', 'cms_page', 'contentApp', 'x9']
|
||||
const actions = ['hasAccess', 'read', 'insert', 'edit', 'delete']
|
||||
let seed = 7
|
||||
const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) % 3
|
||||
for (let run = 0; run < 200; run++) {
|
||||
const set = new Set<string>()
|
||||
resources.forEach((r) => actions.forEach((a) => next() === 0 && set.add(`${r}.${a}`)))
|
||||
expect(roundTrips(set, FORMAT)).toBe(true)
|
||||
const parsed = parsePolicy(generatePolicy(set, FORMAT).pretty, FORMAT)
|
||||
expect(parsed.ok && [...parsed.permissions].sort()).toEqual([...set].sort())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePolicy', () => {
|
||||
it('grants resource.action, with * as all five', () => {
|
||||
const parsed = parsePolicy({ PolicySpec: 'qqq-iam', PolicyVersion: '1.0.0', Statement: [{ Effect: 'Allow', Action: ['*'], Resource: ['p'] }, { Effect: 'Allow', Action: ['read'], Resource: ['q', 'p'] }] }, FORMAT)
|
||||
expect(parsed.ok).toBe(true)
|
||||
if (parsed.ok) {
|
||||
expect([...parsed.permissions].sort()).toEqual(['p.delete', 'p.edit', 'p.hasAccess', 'p.insert', 'p.read', 'q.read'])
|
||||
expect(parsed.resources).toEqual(['p', 'q'])
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a missing Statement', () => {
|
||||
expect(parsePolicy('{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0"}', FORMAT)).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['', 'empty'],
|
||||
['nope', 'not a JSON object'],
|
||||
['[]', 'not a JSON object'],
|
||||
['{"PolicySpec":"x","PolicyVersion":"1.0.0"}', 'PolicySpec must be'],
|
||||
['{"PolicySpec":"qqq-iam","PolicyVersion":"2.0.0"}', 'PolicyVersion must be'],
|
||||
['{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":{}}', 'Statement must be an array'],
|
||||
['{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[1]}', 'must be an object'],
|
||||
['{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Deny","Action":["read"],"Resource":["a"]}]}', 'Deny is not supported'],
|
||||
['{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Allow","Action":"read","Resource":["a"]}]}', 'Action must be an array'],
|
||||
['{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Allow","Action":["write"],"Resource":["a"]}]}', 'unknown action "write"'],
|
||||
['{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Allow","Action":["read"],"Resource":[2]}]}', 'Resource must be an array'],
|
||||
['{"PolicySpec":"qqq-iam","PolicyVersion":"1.0.0","Statement":[{"Effect":"Allow","Action":["read"],"Resource":["a.b"]}]}', 'must match'],
|
||||
])('fails closed on %s', (text, message) => {
|
||||
const parsed = parsePolicy(text, FORMAT)
|
||||
expect(parsed.ok).toBe(false)
|
||||
if (!parsed.ok) expect(parsed.error).toContain(message)
|
||||
})
|
||||
|
||||
it('rejects non-objects', () => {
|
||||
expect(parsePolicy(null, FORMAT).ok).toBe(false)
|
||||
expect(parsePolicy(42, FORMAT).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitPermission', () => {
|
||||
it('splits valid policy permissions only', () => {
|
||||
expect(splitPermission('cms_page.read')).toEqual({ resource: 'cms_page', action: 'read' })
|
||||
expect(splitPermission('a.write')).toBeNull()
|
||||
expect(splitPermission('.read')).toBeNull()
|
||||
expect(splitPermission('a.')).toBeNull()
|
||||
expect(splitPermission('authentik.users.read')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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 The policy document format of qbit-authentik (its docs/POLICY-FORMAT.md): a strict,
|
||||
* fail-closed parser and the canonical generator, byte-for-byte the same as the qbit's Java
|
||||
* `PolicyParser` and `PolicyGenerator`, so the editor's live preview is exactly what the
|
||||
* server validates and stores. The server checks everything again; this is for the preview.
|
||||
*/
|
||||
|
||||
/** The actions a statement can grant, in canonical statement order. */
|
||||
export const POLICY_ACTIONS = ['hasAccess', 'read', 'insert', 'edit', 'delete'] as const
|
||||
|
||||
/** One policy action. */
|
||||
export type PolicyAction = (typeof POLICY_ACTIONS)[number]
|
||||
|
||||
/** A valid resource: a QQQ permission base name. */
|
||||
export const RESOURCE_PATTERN = /^[A-Za-z0-9_]+$/
|
||||
|
||||
/** The spec and version a document must carry. */
|
||||
export interface PolicyFormat {
|
||||
spec: string
|
||||
version: string
|
||||
}
|
||||
|
||||
/** The outcome of parsing a document. */
|
||||
export type PolicyParseResult =
|
||||
| { ok: true; permissions: Set<string>; resources: string[] }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Whether a string is one of the five actions.
|
||||
* @param value - Candidate.
|
||||
* @returns True for `hasAccess`, `read`, `insert`, `edit` or `delete`.
|
||||
*/
|
||||
export function isPolicyAction(value: string): value is PolicyAction {
|
||||
return (POLICY_ACTIONS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The QQQ permission an action grants on a resource.
|
||||
* @param resource - Permission base name.
|
||||
* @param action - Action.
|
||||
* @returns `{resource}.{action}`.
|
||||
*/
|
||||
export function permissionName(resource: string, action: PolicyAction): string {
|
||||
return `${resource}.${action}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a permission name into resource and action.
|
||||
* @param permission - `{resource}.{action}`.
|
||||
* @returns The parts, or null when it is not a valid policy permission.
|
||||
*/
|
||||
export function splitPermission(permission: string): { resource: string; action: PolicyAction } | null {
|
||||
const dot = permission.lastIndexOf('.')
|
||||
if (dot <= 0 || dot === permission.length - 1) return null
|
||||
const resource = permission.slice(0, dot)
|
||||
const action = permission.slice(dot + 1)
|
||||
if (!isPolicyAction(action) || !RESOURCE_PATTERN.test(resource)) return null
|
||||
return { resource, action }
|
||||
}
|
||||
|
||||
/**
|
||||
* A short description of a found value for messages.
|
||||
* @param value - Anything.
|
||||
* @returns E.g. `"x"`, `nothing` or `number`.
|
||||
*/
|
||||
function describe(value: unknown): string {
|
||||
if (value === undefined || value === null) return 'nothing'
|
||||
if (typeof value === 'string') return `"${value.length > 40 ? `${value.slice(0, 40)}...` : value}"`
|
||||
return Array.isArray(value) ? 'array' : typeof value
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of strings, or an error message.
|
||||
* @param value - Candidate.
|
||||
* @param what - Where it is, for the message.
|
||||
* @returns The strings, or `{ error }`.
|
||||
*/
|
||||
function strings(value: unknown, what: string): string[] | { error: string } {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) return { error: `${what} must be an array of strings` }
|
||||
return value as string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a document, failing closed: any problem is reported and grants nothing.
|
||||
* @param document - The document as JSON text or an object.
|
||||
* @param format - Expected spec and version.
|
||||
* @returns The permissions and resources, or the first problem.
|
||||
*/
|
||||
export function parsePolicy(document: unknown, format: PolicyFormat): PolicyParseResult {
|
||||
let json: unknown = document
|
||||
if (typeof document === 'string') {
|
||||
if (document.trim() === '') return { ok: false, error: 'The document is empty' }
|
||||
try {
|
||||
json = JSON.parse(document)
|
||||
} catch {
|
||||
return { ok: false, error: 'The document is not a JSON object' }
|
||||
}
|
||||
}
|
||||
if (typeof json !== 'object' || json === null || Array.isArray(json)) return { ok: false, error: 'The document is not a JSON object' }
|
||||
const object = json as Record<string, unknown>
|
||||
if (object.PolicySpec !== format.spec) return { ok: false, error: `PolicySpec must be "${format.spec}" (found ${describe(object.PolicySpec)})` }
|
||||
if (object.PolicyVersion !== format.version) return { ok: false, error: `PolicyVersion must be "${format.version}" (found ${describe(object.PolicyVersion)})` }
|
||||
|
||||
const permissions = new Set<string>()
|
||||
const resources: string[] = []
|
||||
if (object.Statement === undefined || object.Statement === null) return { ok: true, permissions, resources }
|
||||
if (!Array.isArray(object.Statement)) return { ok: false, error: 'Statement must be an array' }
|
||||
|
||||
for (let i = 0; i < object.Statement.length; i++) {
|
||||
const statement = object.Statement[i] as unknown
|
||||
const n = i + 1
|
||||
if (typeof statement !== 'object' || statement === null || Array.isArray(statement)) return { ok: false, error: `Statement ${n} must be an object` }
|
||||
const s = statement as Record<string, unknown>
|
||||
if (s.Effect !== 'Allow') return { ok: false, error: `Statement ${n}: Effect must be "Allow" (found ${describe(s.Effect)}); Deny is not supported yet` }
|
||||
const actionNames = strings(s.Action, `Statement ${n}: Action`)
|
||||
if (!Array.isArray(actionNames)) return { ok: false, error: actionNames.error }
|
||||
const actions = new Set<PolicyAction>()
|
||||
for (const name of actionNames) {
|
||||
if (name === '*') POLICY_ACTIONS.forEach((action) => actions.add(action))
|
||||
else if (isPolicyAction(name)) actions.add(name)
|
||||
else return { ok: false, error: `Statement ${n}: unknown action "${name}"` }
|
||||
}
|
||||
const resourceNames = strings(s.Resource, `Statement ${n}: Resource`)
|
||||
if (!Array.isArray(resourceNames)) return { ok: false, error: resourceNames.error }
|
||||
for (const resource of resourceNames) {
|
||||
if (!RESOURCE_PATTERN.test(resource)) return { ok: false, error: `Statement ${n}: resource "${resource}" must match [A-Za-z0-9_]+` }
|
||||
if (!resources.includes(resource)) resources.push(resource)
|
||||
actions.forEach((action) => permissions.add(permissionName(resource, action)))
|
||||
}
|
||||
}
|
||||
return { ok: true, permissions, resources }
|
||||
}
|
||||
|
||||
/** A generated document in both written forms. */
|
||||
export interface GeneratedPolicy {
|
||||
/** Compact JSON, as stored on the group. */
|
||||
compact: string
|
||||
/** Pretty JSON (2-space indent, trailing newline), for reading. */
|
||||
pretty: string
|
||||
/** Statements in the document. */
|
||||
statementCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare strings by UTF-16 code units, like Java's `String.compareTo` (the qbit sorts with it).
|
||||
* @param a - First.
|
||||
* @param b - Second.
|
||||
* @returns Negative, zero or positive.
|
||||
*/
|
||||
function compareJava(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical document for a set of permissions: one Allow statement per granted action in
|
||||
* the order hasAccess, read, insert, edit, delete; resources sorted; no `*`.
|
||||
* @param permissions - `{resource}.{action}` names; others are ignored.
|
||||
* @param format - Spec and version to write.
|
||||
* @returns The document, compact and pretty.
|
||||
*/
|
||||
export function generatePolicy(permissions: Iterable<string>, format: PolicyFormat): GeneratedPolicy {
|
||||
const byAction = new Map<PolicyAction, Set<string>>(POLICY_ACTIONS.map((action) => [action, new Set<string>()]))
|
||||
for (const permission of permissions) {
|
||||
const parts = splitPermission(permission)
|
||||
if (parts) byAction.get(parts.action)?.add(parts.resource)
|
||||
}
|
||||
const statements = POLICY_ACTIONS
|
||||
.map((action) => ({ action, resources: [...(byAction.get(action) ?? [])].sort(compareJava) }))
|
||||
.filter((statement) => statement.resources.length > 0)
|
||||
|
||||
const q = (value: string) => JSON.stringify(value)
|
||||
const compactStatements = statements.map((s) => `{"Effect":"Allow","Action":[${q(s.action)}],"Resource":[${s.resources.map(q).join(',')}]}`)
|
||||
const compact = `{"PolicySpec":${q(format.spec)},"PolicyVersion":${q(format.version)},"Statement":[${compactStatements.join(',')}]}`
|
||||
|
||||
const prettyStatements = statements.map((s) => [
|
||||
' {',
|
||||
' "Effect": "Allow",',
|
||||
` "Action": [${q(s.action)}],`,
|
||||
' "Resource": [',
|
||||
s.resources.map((r) => ` ${q(r)}`).join(',\n'),
|
||||
' ]',
|
||||
' }',
|
||||
].join('\n'))
|
||||
const pretty = [
|
||||
'{',
|
||||
` "PolicySpec": ${q(format.spec)},`,
|
||||
` "PolicyVersion": ${q(format.version)},`,
|
||||
statements.length === 0 ? ' "Statement": []' : ` "Statement": [\n${prettyStatements.join(',\n')}\n ]`,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
return { compact, pretty, statementCount: statements.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a document round-trips: parsing the generated document gives back exactly the
|
||||
* permissions it was generated from.
|
||||
* @param permissions - Valid policy permissions.
|
||||
* @param format - Spec and version.
|
||||
* @returns True when parse(generate(x)) equals x.
|
||||
*/
|
||||
export function roundTrips(permissions: Set<string>, format: PolicyFormat): boolean {
|
||||
const parsed = parsePolicy(generatePolicy(permissions, format).compact, format)
|
||||
if (!parsed.ok || parsed.permissions.size !== permissions.size) return false
|
||||
for (const permission of permissions) if (!parsed.permissions.has(permission)) return false
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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 Paged audit and collaboration activity with expandable permitted details. */
|
||||
'use client'
|
||||
import { Activity, History } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import type { CollaborationRecord } from '@/types/collaboration'
|
||||
import { useTimeline } from '@/lib/hooks/use-collaboration-record'
|
||||
|
||||
/** Renders the unified timeline without a nested scrolling container.
|
||||
* @param props - Parent identity.
|
||||
* @returns Activity list.
|
||||
*/
|
||||
export function ActivityTab({ record }: { record: CollaborationRecord }) {
|
||||
const query = useTimeline(record)
|
||||
const items = query.data?.pages.flatMap((page) => page.items) ?? []
|
||||
return (
|
||||
<div className="space-y-4" data-qqq-id="collaboration-activity">
|
||||
{query.isPending && <p role="status">Loading activity…</p>}
|
||||
{query.isError && (
|
||||
<p role="alert">
|
||||
Could not load activity.{' '}
|
||||
<button data-qqq-id="activity-retry" onClick={() => void query.refetch()}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{query.isSuccess && !items.length && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No activity yet.</p>
|
||||
)}
|
||||
{items.map((item, index) => {
|
||||
const Icon = item.kind === 'AUDIT' ? History : Activity
|
||||
const validTime = item.at && Number.isFinite(Date.parse(item.at))
|
||||
return (
|
||||
<article
|
||||
key={`${item.kind}-${item.id ?? index}`}
|
||||
className="flex min-w-0 gap-3 border-b border-border pb-4 text-sm"
|
||||
>
|
||||
<Icon aria-hidden="true" className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<p className="font-medium">{item.actorName ?? item.actorUserId ?? 'System'}</p>
|
||||
<p>{item.summary}</p>
|
||||
<time
|
||||
className="text-xs text-muted-foreground"
|
||||
dateTime={item.at ?? undefined}
|
||||
title={item.at ?? undefined}
|
||||
>
|
||||
{validTime
|
||||
? formatDistanceToNow(new Date(item.at!), { addSuffix: true })
|
||||
: 'Time unknown'}
|
||||
</time>
|
||||
{!!item.details?.length && (
|
||||
<details className="mt-2">
|
||||
<summary
|
||||
data-qqq-id={`activity-details-${item.kind}-${item.id}`}
|
||||
className="cursor-pointer text-xs text-primary"
|
||||
>
|
||||
Details
|
||||
</summary>
|
||||
<dl className="mt-2 space-y-2 text-xs">
|
||||
{item.details.map((detail, i) => (
|
||||
<div key={i}>
|
||||
<dt className="font-medium">{detail.fieldName ?? 'Change'}</dt>
|
||||
<dd>
|
||||
{detail.message ??
|
||||
`${detail.oldValue ?? 'Empty'} → ${detail.newValue ?? 'Empty'}`}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
{query.hasNextPage && (
|
||||
<button
|
||||
disabled={query.isFetchingNextPage}
|
||||
onClick={() => void query.fetchNextPage()}
|
||||
className="w-full rounded border border-border py-2 text-sm"
|
||||
data-qqq-id="activity-more"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 Navigation icons shared by favorites menu and command palette. */
|
||||
|
||||
import {
|
||||
Bookmark,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Table2,
|
||||
Workflow,
|
||||
ChartNoAxesCombined,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { CollaborationTargetType } from '@/types/collaboration'
|
||||
|
||||
const ICONS: Record<CollaborationTargetType, LucideIcon> = {
|
||||
TABLE: Table2,
|
||||
APP: LayoutGrid,
|
||||
RECORD: FileText,
|
||||
PROCESS: Workflow,
|
||||
REPORT: ChartNoAxesCombined,
|
||||
SAVED_VIEW: Bookmark,
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the icon corresponding to a navigation target's type.
|
||||
* @param props - Target type.
|
||||
* @returns A decorative icon.
|
||||
*/
|
||||
export function CollaborationIcon({ type }: { type: CollaborationTargetType }) {
|
||||
const Icon = ICONS[type]
|
||||
return <Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* 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 Reuses the assistant's sanitized renderer with comment mention chips. */
|
||||
'use client'
|
||||
import { AssistantMarkdown } from '@/components/assistant/AssistantMarkdown'
|
||||
|
||||
/** Safe comment markdown; the shared renderer strips HTML and never loads embedded images.
|
||||
* @param props - Comment body.
|
||||
* @returns Sanitized markdown and mention chips.
|
||||
*/
|
||||
export function CollaborationMarkdown({ body }: { body: string }) {
|
||||
return (
|
||||
<div data-qqq-id="comment-markdown">
|
||||
<AssistantMarkdown text={body} mentions />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* 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 Markdown composer with cursor-aware keyboard mention completion and access warnings. */
|
||||
'use client'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import type { CollaborationRecord, MentionCandidate } from '@/types/collaboration'
|
||||
import { useMentionCandidates } from '@/lib/hooks/use-collaboration-record'
|
||||
import { CollaborationMarkdown } from './CollaborationMarkdown'
|
||||
|
||||
/** Checks access for pasted or previously saved mentions as well as autocomplete inserts.
|
||||
* @param props - Mention identity, display name and any already-known access decision.
|
||||
* @returns Access warning, if the recipient cannot read.
|
||||
*/
|
||||
function MentionWarning({
|
||||
record,
|
||||
userId,
|
||||
name,
|
||||
known,
|
||||
}: {
|
||||
record: CollaborationRecord
|
||||
userId: string
|
||||
name: string
|
||||
known?: MentionCandidate
|
||||
}) {
|
||||
const candidates = useMentionCandidates(record, known ? null : name)
|
||||
const candidate =
|
||||
known ??
|
||||
candidates.data?.pages
|
||||
.flatMap((page) => page.items)
|
||||
.find((item) => item.person.userId === userId)
|
||||
return candidate?.canRead === 'false' ? (
|
||||
<p
|
||||
className="rounded-lg bg-muted px-2 py-1 text-xs"
|
||||
role="status"
|
||||
data-qqq-id="mention-warning"
|
||||
>
|
||||
{name} can't see this record and won't be notified.
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
|
||||
/** Writes or edits a comment without losing the draft on failure.
|
||||
* @param props - Identity, initial draft and save callback.
|
||||
* @returns Composer.
|
||||
*/
|
||||
export function CommentComposer({
|
||||
record,
|
||||
initialBody = '',
|
||||
submitLabel = 'Post comment',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
disabled = false,
|
||||
}: {
|
||||
record: CollaborationRecord
|
||||
initialBody?: string
|
||||
submitLabel?: string
|
||||
onSubmit: (body: string) => Promise<unknown>
|
||||
onCancel?: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const id = useId()
|
||||
const [body, setBody] = useState(initialBody)
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [caret, setCaret] = useState(initialBody.length)
|
||||
const [closed, setClosed] = useState(false)
|
||||
const [active, setActive] = useState(0)
|
||||
const [selected, setSelected] = useState<MentionCandidate[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const input = useRef<HTMLTextAreaElement>(null)
|
||||
const match = body.slice(0, caret).match(/(?:^|\s)@([^@\n[\]]*)$/)
|
||||
const term = !closed && !preview && match ? match[1] : null
|
||||
const candidates = useMentionCandidates(record, term)
|
||||
const options = candidates.data?.pages.flatMap((page) => page.items) ?? []
|
||||
const mentions = [
|
||||
...new Map(
|
||||
Array.from(body.matchAll(/@\[([^\]]+)\]\(user:([^\)]+)\)/g), (match) => [
|
||||
match[2],
|
||||
{ name: match[1], userId: match[2] },
|
||||
])
|
||||
).values(),
|
||||
]
|
||||
const insert = (candidate: MentionCandidate) => {
|
||||
const start = caret - (term?.length ?? 0) - 1
|
||||
const token = `@[${candidate.person.displayName.replace(/[\[\]\n]/g, '')}](user:${candidate.person.userId}) `
|
||||
setBody(body.slice(0, start) + token + body.slice(caret))
|
||||
setCaret(start + token.length)
|
||||
setSelected((items) => [
|
||||
...items.filter((item) => item.person.userId !== candidate.person.userId),
|
||||
candidate,
|
||||
])
|
||||
setClosed(true)
|
||||
requestAnimationFrame(() => {
|
||||
input.current?.focus()
|
||||
input.current?.setSelectionRange(start + token.length, start + token.length)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<form
|
||||
className="space-y-2"
|
||||
data-qqq-id="comment-composer"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (!body.trim() || saving || disabled) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSubmit(body)
|
||||
setBody('')
|
||||
setSelected([])
|
||||
setPreview(false)
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Could not save comment.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1" role="group" aria-label="Comment editor mode">
|
||||
{['Write', 'Preview'].map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
aria-pressed={preview === Boolean(index)}
|
||||
className="rounded px-3 py-2 text-sm aria-pressed:bg-muted"
|
||||
data-qqq-id={`comment-${label.toLowerCase()}`}
|
||||
onClick={() => setPreview(Boolean(index))}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{preview ? (
|
||||
<div className="rounded border border-border p-3" data-qqq-id="comment-preview-body">
|
||||
<CollaborationMarkdown body={body || 'Nothing to preview.'} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor={id} className="sr-only">
|
||||
Comment
|
||||
</label>
|
||||
<textarea
|
||||
id={id}
|
||||
ref={input}
|
||||
value={body}
|
||||
rows={4}
|
||||
maxLength={20000}
|
||||
disabled={disabled || saving}
|
||||
placeholder="Write a comment… Use @ to mention someone."
|
||||
className="w-full resize-y rounded-md border border-border bg-background p-3 text-sm"
|
||||
data-qqq-id="comment-body"
|
||||
aria-controls={term !== null ? `${id}-mentions` : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
term !== null && options[active] ? `${id}-mention-${active}` : undefined
|
||||
}
|
||||
onSelect={(event) => setCaret(event.currentTarget.selectionStart)}
|
||||
onChange={(event) => {
|
||||
setBody(event.target.value)
|
||||
setCaret(event.target.selectionStart)
|
||||
setClosed(false)
|
||||
setActive(0)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (term === null) return
|
||||
if (event.key === 'Escape') {
|
||||
setClosed(true)
|
||||
event.preventDefault()
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setActive((value) =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(options.length - 1, value + (event.key === 'ArrowDown' ? 1 : -1))
|
||||
)
|
||||
)
|
||||
}
|
||||
if (event.key === 'Enter' && options[active]) {
|
||||
event.preventDefault()
|
||||
insert(options[active])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{term !== null && (
|
||||
<div
|
||||
role="listbox"
|
||||
id={`${id}-mentions`}
|
||||
aria-label="Mention candidates"
|
||||
className="rounded border border-border bg-popover p-1"
|
||||
>
|
||||
{options.map((candidate, index) => (
|
||||
<button
|
||||
key={candidate.person.userId}
|
||||
type="button"
|
||||
role="option"
|
||||
id={`${id}-mention-${index}`}
|
||||
aria-selected={active === index}
|
||||
data-qqq-id={`mention-${candidate.person.userId}`}
|
||||
className="flex w-full flex-wrap gap-2 rounded px-2 py-2 text-left text-sm aria-selected:bg-accent"
|
||||
onClick={() => insert(candidate)}
|
||||
>
|
||||
{candidate.person.displayName}
|
||||
{candidate.canRead !== 'true' && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{candidate.canRead === 'false' ? "can't open this record" : 'unknown'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{candidates.isPending && <p role="status">Finding people…</p>}
|
||||
{candidates.isError && <p role="alert">Could not load mention candidates.</p>}
|
||||
{candidates.hasNextPage && (
|
||||
<button
|
||||
type="button"
|
||||
data-qqq-id="mention-more"
|
||||
disabled={candidates.isFetchingNextPage}
|
||||
onClick={() => void candidates.fetchNextPage()}
|
||||
>
|
||||
More people
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mentions.map((mention) => (
|
||||
<MentionWarning
|
||||
key={mention.userId}
|
||||
record={record}
|
||||
{...mention}
|
||||
known={selected.find((candidate) => candidate.person.userId === mention.userId)}
|
||||
/>
|
||||
))}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={disabled || saving || !body.trim()}
|
||||
className="rounded bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
||||
data-qqq-id="comment-submit"
|
||||
>
|
||||
{saving ? 'Saving…' : submitLabel}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
data-qqq-id="comment-cancel"
|
||||
className="px-3 py-2 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* 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 Paged comment threads, replies, author controls and edit history. */
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import type {
|
||||
CollaborationComment,
|
||||
CollaborationRecord,
|
||||
RecordFeaturesV1,
|
||||
} from '@/types/collaboration'
|
||||
import {
|
||||
useComments,
|
||||
useCommentActions,
|
||||
useResolveComment,
|
||||
useCommentHistory,
|
||||
} from '@/lib/hooks/use-collaboration-record'
|
||||
import { CommentComposer } from './CommentComposer'
|
||||
import { CollaborationMarkdown } from './CollaborationMarkdown'
|
||||
|
||||
/** One comment with actions authorized by server capabilities.
|
||||
* @param props - Comment, parent and capabilities.
|
||||
* @returns Comment and optional reply editor.
|
||||
*/
|
||||
function Comment({
|
||||
comment,
|
||||
record,
|
||||
canComment,
|
||||
fieldLabels,
|
||||
root = false,
|
||||
}: {
|
||||
comment: CollaborationComment
|
||||
record: CollaborationRecord
|
||||
canComment: boolean
|
||||
fieldLabels?: Record<string, string>
|
||||
root?: boolean
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [replying, setReplying] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const actions = useCommentActions(record)
|
||||
const resolve = useResolveComment(record)
|
||||
const history = useCommentHistory(comment.id, historyOpen)
|
||||
const collapsed = root && comment.resolved && !expanded
|
||||
return (
|
||||
<article
|
||||
className="min-w-0 space-y-2 rounded-lg border border-border p-3"
|
||||
data-qqq-id={`comment-${comment.id}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{comment.authorDisplayName ?? comment.authorUserId ?? 'Unknown'}
|
||||
</span>
|
||||
<time dateTime={comment.createdAt ?? undefined}>
|
||||
{comment.createdAt ? new Date(comment.createdAt).toLocaleString() : ''}
|
||||
</time>
|
||||
{comment.fieldName && (
|
||||
<span className="rounded bg-muted px-2 py-1">
|
||||
{fieldLabels?.[comment.fieldName] ?? comment.fieldName}
|
||||
</span>
|
||||
)}
|
||||
{comment.editedAt && (
|
||||
<Popover.Root open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<Popover.Trigger className="underline" data-qqq-id={`comment-history-${comment.id}`}>
|
||||
edited
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="z-50 w-80 max-w-[90vw] space-y-3 rounded border border-border bg-popover p-3 shadow-lg"
|
||||
sideOffset={5}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">Edit history</h3>
|
||||
{history.isPending && <p role="status">Loading history…</p>}
|
||||
{history.isError && <p role="alert">Could not load history.</p>}
|
||||
{history.data?.pages
|
||||
.flatMap((page) => page.items)
|
||||
.map((revision, index) => (
|
||||
<div key={revision.id ?? index}>
|
||||
<time className="text-xs">
|
||||
{revision.editedAt ? new Date(revision.editedAt).toLocaleString() : ''}
|
||||
</time>
|
||||
<CollaborationMarkdown body={revision.body ?? ''} />
|
||||
</div>
|
||||
))}
|
||||
{history.hasNextPage && (
|
||||
<button
|
||||
data-qqq-id="comment-history-more"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
<Popover.Close data-qqq-id="comment-history-close">Close</Popover.Close>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)}
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<button
|
||||
className="text-sm"
|
||||
data-qqq-id={`comment-expand-${comment.id}`}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
Resolved thread · Show
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{editing ? (
|
||||
<CommentComposer
|
||||
record={record}
|
||||
initialBody={comment.body ?? ''}
|
||||
submitLabel="Save comment"
|
||||
onCancel={() => setEditing(false)}
|
||||
onSubmit={async (body) => {
|
||||
await actions.edit.mutateAsync({ id: comment.id, body })
|
||||
setEditing(false)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CollaborationMarkdown
|
||||
body={comment.deleted ? 'Comment deleted' : (comment.body ?? '')}
|
||||
/>
|
||||
)}
|
||||
{!comment.deleted && (
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{comment.canEdit && !editing && (
|
||||
<button data-qqq-id={`comment-edit-${comment.id}`} onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{comment.canDelete && (
|
||||
<button
|
||||
data-qqq-id={`comment-delete-${comment.id}`}
|
||||
disabled={actions.remove.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm('Delete this comment?')) actions.remove.mutate(comment.id)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{root && canComment && (
|
||||
<button
|
||||
data-qqq-id={`comment-reply-${comment.id}`}
|
||||
onClick={() => setReplying(!replying)}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{comment.replies.map((reply) => (
|
||||
<Comment
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
record={record}
|
||||
canComment={canComment}
|
||||
fieldLabels={fieldLabels}
|
||||
/>
|
||||
))}
|
||||
{replying && (
|
||||
<CommentComposer
|
||||
record={record}
|
||||
submitLabel="Post reply"
|
||||
onCancel={() => setReplying(false)}
|
||||
onSubmit={async (body) => {
|
||||
await actions.post.mutateAsync({ body, parentCommentId: comment.id })
|
||||
setReplying(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{root && canComment && (
|
||||
<button
|
||||
className="text-xs font-medium text-primary"
|
||||
disabled={resolve.isPending}
|
||||
data-qqq-id={`comment-resolve-${comment.id}`}
|
||||
onClick={() => {
|
||||
setExpanded(false)
|
||||
resolve.mutate({ id: comment.id, resolved: !comment.resolved })
|
||||
}}
|
||||
>
|
||||
{comment.resolved ? 'Reopen' : 'Resolve'}
|
||||
</button>
|
||||
)}
|
||||
{(actions.remove.isError || resolve.isError) && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not update comment. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
/** Thread list in server latest-activity order with cursor paging.
|
||||
* @param props - Record identity and feature metadata.
|
||||
* @returns Comments tab.
|
||||
*/
|
||||
export function CommentsTab({
|
||||
record,
|
||||
features,
|
||||
fieldLabels,
|
||||
}: {
|
||||
record: CollaborationRecord
|
||||
features: RecordFeaturesV1
|
||||
fieldLabels?: Record<string, string>
|
||||
}) {
|
||||
const query = useComments(record)
|
||||
const actions = useCommentActions(record)
|
||||
const threads = query.data?.pages.flatMap((page) => page.threads) ?? []
|
||||
const canComment = features.capabilities?.comment === true
|
||||
return (
|
||||
<div className="space-y-4" data-qqq-id="collaboration-comments">
|
||||
{canComment ? (
|
||||
<CommentComposer
|
||||
key={`${record.table}/${record.pk}`}
|
||||
record={record}
|
||||
onSubmit={(body) => actions.post.mutateAsync({ body })}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You don't have permission to comment on this record.
|
||||
</p>
|
||||
)}
|
||||
{query.isPending && <p role="status">Loading comments…</p>}
|
||||
{query.isError && (
|
||||
<p role="alert">
|
||||
Could not load comments.{' '}
|
||||
<button data-qqq-id="comments-retry" onClick={() => void query.refetch()}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{query.isSuccess && !threads.length && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No comments yet.</p>
|
||||
)}
|
||||
{threads.map((thread) => (
|
||||
<Comment
|
||||
key={thread.id}
|
||||
comment={thread}
|
||||
record={record}
|
||||
canComment={canComment}
|
||||
fieldLabels={fieldLabels}
|
||||
root
|
||||
/>
|
||||
))}
|
||||
{query.hasNextPage && (
|
||||
<button
|
||||
data-qqq-id="comments-more"
|
||||
className="w-full rounded border border-border py-2 text-sm"
|
||||
disabled={query.isFetchingNextPage}
|
||||
onClick={() => void query.fetchNextPage()}
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 Accessible favorite toggle shared by record, table and app headers. */
|
||||
|
||||
'use client'
|
||||
|
||||
import { Star } from 'lucide-react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import type { CollaborationTarget } from '@/types/collaboration'
|
||||
import {
|
||||
sameFavorite,
|
||||
useCollaborationAvailable,
|
||||
useFavorites,
|
||||
useToggleFavorite,
|
||||
} from '@/lib/hooks/use-collaboration'
|
||||
|
||||
/**
|
||||
* Renders a star only when collaboration is installed.
|
||||
* @param props - Target identity and metadata-derived display information.
|
||||
* @returns Keyboard-accessible pressed button with a tooltip, or nothing.
|
||||
*/
|
||||
export function FavoriteToggle({
|
||||
target,
|
||||
label,
|
||||
href,
|
||||
}: {
|
||||
target: CollaborationTarget
|
||||
label: string
|
||||
href: string
|
||||
}) {
|
||||
const available = useCollaborationAvailable()
|
||||
const favorites = useFavorites()
|
||||
const toggle = useToggleFavorite()
|
||||
if (!available.data) return null
|
||||
const starred = favorites.data?.items.some((item) => sameFavorite(item, target)) ?? false
|
||||
const title = starred ? 'Remove from favorites' : 'Add to favorites'
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-qqq-id="favorite-toggle"
|
||||
aria-label={title}
|
||||
aria-pressed={starred}
|
||||
disabled={!favorites.isSuccess || toggle.isPending}
|
||||
onClick={() => toggle.mutate({ ...target, starred, label, href })}
|
||||
className="inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-lg text-primary hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
>
|
||||
<Star className="h-5 w-5" fill={starred ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
sideOffset={4}
|
||||
className="z-[200] rounded bg-popover px-3 py-2 text-sm text-popover-foreground shadow"
|
||||
>
|
||||
{title}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CollaborationFavorite, CollaborationList } from '@/types/collaboration'
|
||||
import * as api from '@/lib/api/collaboration'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { FavoriteToggle } from './FavoriteToggle'
|
||||
import { FavoritesMenu } from './FavoritesMenu'
|
||||
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
isCollaborationInstalled: vi.fn(),
|
||||
listFavorites: vi.fn(),
|
||||
starFavorite: vi.fn(),
|
||||
unstarFavorite: vi.fn(),
|
||||
}))
|
||||
const target = { targetType: 'RECORD' as const, targetName: 'person', recordId: '1' }
|
||||
const alice: CollaborationFavorite = {
|
||||
...target,
|
||||
id: 1,
|
||||
label: 'Alice Johnson',
|
||||
href: '/app/person/1',
|
||||
available: true,
|
||||
folder: 'Customers',
|
||||
sortOrder: 0,
|
||||
}
|
||||
|
||||
function setup(ui: React.ReactElement, items: CollaborationFavorite[] = [], available = true) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
client.setQueryData(queryKeys.collaborationAvailable(), available)
|
||||
client.setQueryData<CollaborationList>(queryKeys.collaborationFavorites(), {
|
||||
items,
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.listFavorites).mockResolvedValue({ items, nextCursor: null })
|
||||
return { client, ...render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(api.starFavorite).mockResolvedValue({ success: true })
|
||||
vi.mocked(api.unstarFavorite).mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
describe('FavoriteToggle', () => {
|
||||
it('supports keyboard activation and exposes its pressed state and tooltip label', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup(<FavoriteToggle target={target} label={alice.label} href={alice.href!} />)
|
||||
const button = screen.getByRole('button', { name: 'Add to favorites' })
|
||||
expect(button).toHaveAttribute('data-qqq-id', 'favorite-toggle')
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false')
|
||||
await user.tab()
|
||||
expect(button).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() => expect(api.starFavorite).toHaveBeenCalledWith(target))
|
||||
})
|
||||
it('shows a pressed star and removes an existing favorite', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup(<FavoriteToggle target={target} label={alice.label} href={alice.href!} />, [alice])
|
||||
const button = screen.getByRole('button', { name: 'Remove from favorites' })
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(button)
|
||||
await waitFor(() => expect(api.unstarFavorite).toHaveBeenCalledWith(target))
|
||||
})
|
||||
it('is hidden when collaboration is absent', () => {
|
||||
const { container } = setup(
|
||||
<FavoriteToggle target={target} label={alice.label} href={alice.href!} />,
|
||||
[],
|
||||
false
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FavoritesMenu', () => {
|
||||
it('groups by folder, mutes unavailable targets and supports their removal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const unavailable = {
|
||||
...alice,
|
||||
id: 2,
|
||||
recordId: 'deleted',
|
||||
available: false,
|
||||
href: null,
|
||||
label: 'Unavailable',
|
||||
folder: 'Operations',
|
||||
}
|
||||
setup(<FavoritesMenu />, [alice, unavailable])
|
||||
expect(api.listFavorites).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Favorites' }))
|
||||
expect(screen.getByRole('heading', { name: 'Customers' })).toBeVisible()
|
||||
expect(screen.getByRole('heading', { name: 'Operations' })).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: 'Alice Johnson' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: 'Unavailable' })).toBeNull()
|
||||
expect(screen.getByText('Unavailable')).toHaveClass('text-muted-foreground')
|
||||
expect(screen.getByRole('link', { name: 'Manage' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/collaboration_favorite'
|
||||
)
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-w-[calc(100vw-32px)]')
|
||||
await user.click(screen.getByRole('button', { name: 'Remove Unavailable from favorites' }))
|
||||
await waitFor(() =>
|
||||
expect(api.unstarFavorite).toHaveBeenCalledWith({ ...target, recordId: 'deleted' })
|
||||
)
|
||||
})
|
||||
it('has a useful empty state and restores focus on Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup(<FavoritesMenu />)
|
||||
const trigger = screen.getByRole('button', { name: 'Favorites' })
|
||||
await user.click(trigger)
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(
|
||||
'No favorites yet. Use the star on any table, app or record.'
|
||||
)
|
||||
).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
it('is hidden when unavailable', () => {
|
||||
const { container } = setup(<FavoritesMenu />, [], false)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 Personal favorites grouped by folder in a viewport-bounded header popover. */
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { Star, X } from 'lucide-react'
|
||||
import {
|
||||
useCollaborationAvailable,
|
||||
useFavorites,
|
||||
useToggleFavorite,
|
||||
} from '@/lib/hooks/use-collaboration'
|
||||
import { withTrailingSlash } from '@/lib/utils/material-links'
|
||||
import { CollaborationIcon } from './CollaborationIcon'
|
||||
|
||||
/**
|
||||
* Shows favorites on demand, with removals for entries whose visibility has changed.
|
||||
* @returns The header trigger and its popover, or nothing when unavailable.
|
||||
*/
|
||||
export function FavoritesMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const available = useCollaborationAvailable()
|
||||
const favorites = useFavorites(open)
|
||||
const toggle = useToggleFavorite()
|
||||
if (!available.data) return null
|
||||
const groups = new Map<string, NonNullable<typeof favorites.data>['items']>()
|
||||
for (const item of favorites.data?.items ?? []) {
|
||||
const folder = item.folder ?? 'Unfiled'
|
||||
groups.set(folder, [...(groups.get(folder) ?? []), item])
|
||||
}
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Favorites"
|
||||
data-qqq-id="favorites-menu"
|
||||
className="flex items-center justify-center rounded-lg p-2 hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Star className="h-5 w-5 text-foreground/60" aria-hidden="true" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
aria-label="Favorites"
|
||||
className="z-[160] w-80 max-w-[calc(100vw-32px)] rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg"
|
||||
data-qqq-id="favorites-popover"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="px-2 font-semibold">Favorites</h2>
|
||||
<Popover.Close
|
||||
aria-label="Close favorites"
|
||||
data-qqq-id="favorites-close"
|
||||
className="flex min-h-11 min-w-11 items-center justify-center rounded hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Popover.Close>
|
||||
</div>
|
||||
<div className="max-h-[min(60vh,24rem)] overflow-y-auto">
|
||||
{favorites.isPending && (
|
||||
<p role="status" className="p-2 text-sm">
|
||||
Loading favorites…
|
||||
</p>
|
||||
)}
|
||||
{favorites.isError && (
|
||||
<p role="alert" className="p-2 text-sm">
|
||||
Could not load favorites.{' '}
|
||||
<button
|
||||
type="button"
|
||||
data-qqq-id="favorites-retry"
|
||||
onClick={() => favorites.refetch()}
|
||||
className="underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{favorites.isSuccess && groups.size === 0 && (
|
||||
<p className="p-2 text-sm text-muted-foreground">
|
||||
No favorites yet. Use the star on any table, app or record.
|
||||
</p>
|
||||
)}
|
||||
{[...groups].map(([folder, items]) => (
|
||||
<section key={folder} aria-label={folder} className="mt-2 first:mt-0">
|
||||
<h3 className="break-words px-2 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{folder}
|
||||
</h3>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id ?? index}
|
||||
className="flex min-w-0 items-center gap-2 pl-2"
|
||||
data-qqq-id="favorite-row"
|
||||
>
|
||||
<CollaborationIcon type={item.targetType} />
|
||||
{item.available && item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
data-qqq-id="favorite-link"
|
||||
className="min-w-0 flex-1 truncate rounded py-3 text-sm hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate py-3 text-sm text-muted-foreground">
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-qqq-id="favorite-remove"
|
||||
aria-label={`Remove ${item.label} from favorites`}
|
||||
disabled={toggle.isPending}
|
||||
onClick={() => toggle.mutate({ ...item, starred: true })}
|
||||
className="flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={withTrailingSlash('/app/collaboration_favorite')}
|
||||
onClick={() => setOpen(false)}
|
||||
data-qqq-id="favorites-manage"
|
||||
className="mt-2 block rounded border-t border-border px-2 py-3 text-sm font-medium hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 The header favorites menu slot. The dashboard layout loads this module after hydration;
|
||||
* it renders the menu (with its popover) only once collaboration is known to be installed, and a
|
||||
* same-size star while the menu's chunk loads, so the header does not shift.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Star } from 'lucide-react'
|
||||
import { useCollaborationAvailable } from '@/lib/hooks/use-collaboration-core'
|
||||
|
||||
/**
|
||||
* Header slot stand-in, sized like the menu trigger and the header's other icon buttons.
|
||||
* @returns A decorative star.
|
||||
*/
|
||||
function MenuPlaceholder() {
|
||||
return (
|
||||
<span aria-hidden="true" className="flex items-center justify-center p-2" data-qqq-id="favorites-menu-placeholder">
|
||||
<Star className="h-5 w-5 text-foreground/60" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const FavoritesMenu = dynamic(() => import('./FavoritesMenu').then((module) => module.FavoritesMenu), {
|
||||
ssr: false,
|
||||
loading: MenuPlaceholder,
|
||||
})
|
||||
|
||||
/**
|
||||
* The header favorites menu, loaded when collaboration is installed.
|
||||
* @returns The menu (or its placeholder while loading), or nothing when unavailable.
|
||||
*/
|
||||
export function LazyFavoritesMenu() {
|
||||
const available = useCollaborationAvailable()
|
||||
return available.data ? <FavoritesMenu /> : null
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* 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 Attachment uploads with per-file progress and the shared media preview. */
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { Attachment, CollaborationRecord, RecordFeaturesV1 } from '@/types/collaboration'
|
||||
import type { FilePreviewKind } from '@/lib/utils/file-preview'
|
||||
import { formatBytes } from '@/lib/utils/file-preview'
|
||||
import { attachmentUrl } from '@/lib/api/collaboration'
|
||||
import {
|
||||
useAttachments,
|
||||
useAttachmentActions,
|
||||
useAttachmentUrl,
|
||||
} from '@/lib/hooks/use-collaboration-record'
|
||||
import { FileKindIcon } from '@/components/preview/FileKindIcon'
|
||||
const Lightbox = dynamic(
|
||||
() => import('@/components/preview/FilePreviewLightbox').then((m) => m.FilePreviewLightbox),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
/** Maps MIME types to existing media preview kinds.
|
||||
* @param type - MIME type.
|
||||
* @returns Preview kind.
|
||||
*/
|
||||
function kindFor(type?: string | null): FilePreviewKind {
|
||||
if (type?.startsWith('image/')) return 'image'
|
||||
if (type?.startsWith('video/')) return 'video'
|
||||
if (type?.startsWith('audio/')) return 'audio'
|
||||
return type === 'application/pdf' ? 'pdf' : 'other'
|
||||
}
|
||||
/** One attachment, with authorized metadata changes and a fresh preview URL.
|
||||
* @param props - Attachment and parent identity.
|
||||
* @returns File row.
|
||||
*/
|
||||
function AttachmentRow({ file, record }: { file: Attachment; record: CollaborationRecord }) {
|
||||
const actions = useAttachmentActions(record)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState(file.fileName ?? '')
|
||||
const [description, setDescription] = useState(file.description ?? '')
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const kind = kindFor(file.contentType)
|
||||
const url = useAttachmentUrl(file.id, 'preview', kind === 'image' || preview)
|
||||
const open = async () => {
|
||||
setError('')
|
||||
try {
|
||||
if (kind === 'other') {
|
||||
const result = await attachmentUrl(file.id, 'download')
|
||||
window.location.assign(result.url)
|
||||
} else {
|
||||
setPreview(true)
|
||||
void url.refetch()
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Could not open file.')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<li
|
||||
className="space-y-2 rounded-lg border border-border p-3"
|
||||
data-qqq-id={`attachment-${file.id}`}
|
||||
>
|
||||
<button
|
||||
className="flex w-full min-w-0 items-start gap-3 text-left"
|
||||
data-qqq-id={`attachment-open-${file.id}`}
|
||||
onClick={() => void open()}
|
||||
>
|
||||
{kind === 'image' &&
|
||||
url.data /* Backend-authorized short-lived preview, no image optimizer. */ ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={url.data.url} alt="" className="h-12 w-12 shrink-0 rounded object-cover" />
|
||||
) : (
|
||||
<FileKindIcon kind={kind} className="h-9 w-9 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 break-words">
|
||||
<span className="block text-sm font-medium">{file.fileName ?? 'Attachment'}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{formatBytes(file.sizeBytes ?? 0)} · {file.createdByUserId ?? 'Unknown'}
|
||||
</span>
|
||||
<time className="text-xs text-muted-foreground" dateTime={file.createDate ?? undefined}>
|
||||
{file.createDate ? new Date(file.createDate).toLocaleDateString() : ''}
|
||||
</time>
|
||||
</span>
|
||||
</button>
|
||||
{file.description && !editing && (
|
||||
<p className="break-words text-xs text-muted-foreground">{file.description}</p>
|
||||
)}
|
||||
{file.canDelete && (
|
||||
<div className="flex gap-3 text-xs">
|
||||
<button
|
||||
data-qqq-id={`attachment-edit-${file.id}`}
|
||||
onClick={() => {
|
||||
setName(file.fileName ?? '')
|
||||
setDescription(file.description ?? '')
|
||||
setEditing(!editing)
|
||||
}}
|
||||
>
|
||||
Rename / describe
|
||||
</button>
|
||||
<button
|
||||
data-qqq-id={`attachment-delete-${file.id}`}
|
||||
disabled={actions.remove.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete ${file.fileName ?? 'this file'}?`))
|
||||
actions.remove.mutate(file.id)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
actions.patch.mutate(
|
||||
{ id: file.id, patch: { fileName: name, description } },
|
||||
{ onSuccess: () => setEditing(false) }
|
||||
)
|
||||
}}
|
||||
>
|
||||
<label className="block text-xs">
|
||||
File name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="mt-1 w-full rounded border border-border bg-background p-2"
|
||||
data-qqq-id="attachment-name"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
Description
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className="mt-1 w-full rounded border border-border bg-background p-2"
|
||||
data-qqq-id="attachment-description"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
disabled={actions.patch.isPending || !name.trim()}
|
||||
className="rounded border border-border px-3 py-2 text-xs"
|
||||
data-qqq-id="attachment-save"
|
||||
>
|
||||
Save file details
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(error || actions.patch.isError || actions.remove.isError || (preview && url.isError)) && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error || 'Could not update or open this file. Please try again.'}
|
||||
</p>
|
||||
)}
|
||||
{preview && url.data && (
|
||||
<Lightbox
|
||||
items={[
|
||||
{
|
||||
url: url.data.url,
|
||||
fileName: file.fileName ?? 'Attachment',
|
||||
mimeType: file.contentType ?? undefined,
|
||||
kind,
|
||||
alt: file.description ?? file.fileName ?? 'Attachment',
|
||||
sizeBytes: file.sizeBytes ?? undefined,
|
||||
previewable: true,
|
||||
thumbnailSize: 48,
|
||||
},
|
||||
]}
|
||||
index={0}
|
||||
onIndexChange={() => undefined}
|
||||
onClose={() => setPreview(false)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
/** Drag-and-drop uploads queue one multipart request per file.
|
||||
* @param props - Record and metadata limits/capabilities.
|
||||
* @returns Files tab.
|
||||
*/
|
||||
export function FilesTab({
|
||||
record,
|
||||
features,
|
||||
}: {
|
||||
record: CollaborationRecord
|
||||
features: RecordFeaturesV1
|
||||
}) {
|
||||
const query = useAttachments(record)
|
||||
const actions = useAttachmentActions(record)
|
||||
const [uploads, setUploads] = useState<
|
||||
Array<{ id: string; name: string; progress: number; error?: string; done?: boolean }>
|
||||
>([])
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const canAttach = features.capabilities?.attach === true
|
||||
const setting = features.settings?.ATTACHMENTS?.maxBytes
|
||||
const maxBytes = typeof setting === 'number' && setting > 0 ? setting : 100 * 1024 * 1024
|
||||
const files = query.data?.pages.flatMap((page) => page.items) ?? []
|
||||
const upload = async (selection: File[]) => {
|
||||
if (!canAttach) return
|
||||
for (const file of selection) {
|
||||
const id = crypto.randomUUID()
|
||||
const error =
|
||||
file.size > maxBytes
|
||||
? `${file.name} is too large. Maximum file size is ${formatBytes(maxBytes)}.`
|
||||
: undefined
|
||||
setUploads((items) => [...items, { id, name: file.name, progress: 0, error }])
|
||||
if (error) continue
|
||||
try {
|
||||
await actions.upload.mutateAsync({
|
||||
file,
|
||||
onProgress: (progress) =>
|
||||
setUploads((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, progress } : item))
|
||||
),
|
||||
})
|
||||
setUploads((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, progress: 100, done: true } : item))
|
||||
)
|
||||
} catch (error) {
|
||||
setUploads((items) =>
|
||||
items.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Upload failed. Choose the file again to retry.',
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4" data-qqq-id="collaboration-files">
|
||||
{canAttach ? (
|
||||
<div
|
||||
className={`rounded-lg border-2 border-dashed p-4 text-center ${dragging ? 'border-primary bg-accent' : 'border-border'}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setDragging(false)
|
||||
void upload(Array.from(event.dataTransfer.files))
|
||||
}}
|
||||
data-qqq-id="files-dropzone"
|
||||
>
|
||||
<p className="text-sm font-medium">Drop files here</p>
|
||||
{/* the native picker's "Choose Files / No file chosen" text ignores the theme; a styled label opens it */}
|
||||
<label className="mt-3 inline-flex min-h-11 cursor-pointer items-center rounded-md border border-border bg-background px-4 text-sm font-medium hover:bg-accent focus-within:ring-2 focus-within:ring-ring">
|
||||
Choose files
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="sr-only"
|
||||
data-qqq-id="files-picker"
|
||||
onChange={(event) => {
|
||||
void upload(Array.from(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Up to {formatBytes(maxBytes)} per file
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You don't have permission to attach files to this record.
|
||||
</p>
|
||||
)}
|
||||
{!!uploads.length && (
|
||||
<ul aria-label="Uploads" className="space-y-2">
|
||||
{uploads.map((item) => (
|
||||
<li key={item.id} className="break-words text-xs">
|
||||
<p>
|
||||
{item.name} {item.done ? 'Uploaded' : item.error ? '' : `${item.progress}%`}
|
||||
</p>
|
||||
{item.error ? (
|
||||
<p role="alert" className="text-destructive">
|
||||
{item.error}
|
||||
</p>
|
||||
) : (
|
||||
<progress
|
||||
aria-label={`Upload ${item.name}`}
|
||||
value={item.progress}
|
||||
max={100}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{query.isPending && <p role="status">Loading files…</p>}
|
||||
{query.isError && (
|
||||
<p role="alert">
|
||||
Could not load files.{' '}
|
||||
<button data-qqq-id="files-retry" onClick={() => void query.refetch()}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{query.isSuccess && !files.length && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No files yet.</p>
|
||||
)}
|
||||
<ul className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<AttachmentRow key={file.id} file={file} record={record} />
|
||||
))}
|
||||
</ul>
|
||||
{query.hasNextPage && (
|
||||
<button
|
||||
data-qqq-id="files-more"
|
||||
disabled={query.isFetchingNextPage}
|
||||
className="w-full rounded border border-border py-2 text-sm"
|
||||
onClick={() => void query.fetchNextPage()}
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 Follow toggle and notification preferences beside the record favorite. */
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { Bell, ChevronDown } from 'lucide-react'
|
||||
import type { CollaborationRecord } from '@/types/collaboration'
|
||||
import { useFollow, useUpdateFollow, useFollowers } from '@/lib/hooks/use-collaboration-record'
|
||||
|
||||
/** Follow state and settings are shared and updated optimistically.
|
||||
* @param props - Parent identity.
|
||||
* @returns Bell with preference popover.
|
||||
*/
|
||||
export function FollowBell({ record }: { record: CollaborationRecord }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const query = useFollow(record)
|
||||
const update = useUpdateFollow(record)
|
||||
const followers = useFollowers(record, open)
|
||||
const following = query.data?.following === true
|
||||
return (
|
||||
<div className="inline-flex items-center" data-qqq-id="record-follow">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={following ? 'Unfollow record' : 'Follow record'}
|
||||
aria-pressed={following}
|
||||
disabled={update.isPending || !query.isSuccess}
|
||||
className="inline-flex min-h-11 min-w-11 items-center justify-center rounded text-primary hover:bg-accent disabled:opacity-50"
|
||||
data-qqq-id="follow-toggle"
|
||||
onClick={() =>
|
||||
update.mutate(following ? null : { notifyOnChange: true, notifyOnComment: true })
|
||||
}
|
||||
>
|
||||
<Bell className={`h-5 w-5 ${following ? 'fill-current' : ''}`} aria-hidden="true" />
|
||||
</button>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger
|
||||
aria-label="Follow preferences"
|
||||
data-qqq-id="follow-preferences"
|
||||
className="min-h-11 rounded px-1 hover:bg-accent"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={5}
|
||||
align="end"
|
||||
className="z-50 w-72 max-w-[90vw] space-y-3 rounded-lg border border-border bg-popover p-4 text-sm shadow-lg"
|
||||
>
|
||||
<h3 className="font-semibold">Follow preferences</h3>
|
||||
{(['notifyOnChange', 'notifyOnComment'] as const).map((key) => (
|
||||
<label key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={following && query.data?.follow?.[key] === true}
|
||||
disabled={update.isPending || !query.isSuccess}
|
||||
data-qqq-id={`follow-${key}`}
|
||||
onChange={(event) =>
|
||||
update.mutate({
|
||||
notifyOnChange: query.data?.follow?.notifyOnChange ?? true,
|
||||
notifyOnComment: query.data?.follow?.notifyOnComment ?? true,
|
||||
[key]: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{key === 'notifyOnChange' ? 'Notify me about changes' : 'Notify me about comments'}
|
||||
</label>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{followers.isPending
|
||||
? 'Loading followers…'
|
||||
: `${followers.data?.pages.reduce((count, page) => count + page.items.length, 0) ?? 0}${followers.hasNextPage ? '+' : ''} followers`}
|
||||
</p>
|
||||
{followers.hasNextPage && (
|
||||
<button
|
||||
data-qqq-id="followers-more"
|
||||
disabled={followers.isFetchingNextPage}
|
||||
onClick={() => void followers.fetchNextPage()}
|
||||
>
|
||||
Load more followers
|
||||
</button>
|
||||
)}
|
||||
{followers.isError && <p role="alert">Could not load followers.</p>}
|
||||
<Popover.Close data-qqq-id="follow-close">Close</Popover.Close>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
{(query.isError || update.isError) && (
|
||||
<span role="alert" className="text-xs text-destructive">
|
||||
Could not update follow.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 The favorites star for table, app and record headers. The real toggle (and its tooltip)
|
||||
* loads only once collaboration is known to be installed, so those screens keep it out of their
|
||||
* first load. A same-size star stands in while the chunk loads, so the title does not shift.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Star } from 'lucide-react'
|
||||
import type { CollaborationTarget } from '@/types/collaboration'
|
||||
import { useCollaborationAvailable } from '@/lib/hooks/use-collaboration-core'
|
||||
|
||||
/**
|
||||
* Page-title stand-in, sized like the favorite toggle.
|
||||
* @returns A decorative star.
|
||||
*/
|
||||
function TogglePlaceholder() {
|
||||
return (
|
||||
<span aria-hidden="true" className="inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center text-primary opacity-50" data-qqq-id="favorite-toggle-placeholder">
|
||||
<Star className="h-5 w-5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const FavoriteToggle = dynamic(() => import('./FavoriteToggle').then((module) => module.FavoriteToggle), {
|
||||
ssr: false,
|
||||
loading: TogglePlaceholder,
|
||||
})
|
||||
|
||||
/**
|
||||
* The favorite star for a table, app or record header, loaded when collaboration is installed.
|
||||
* @param props - Target identity and metadata-derived display information.
|
||||
* @returns The toggle (or its placeholder while loading), or nothing when unavailable.
|
||||
*/
|
||||
export function LazyFavoriteToggle(props: { target: CollaborationTarget; label: string; href: string }) {
|
||||
const available = useCollaborationAvailable()
|
||||
return available.data ? <FavoriteToggle {...props} /> : null
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Small record-entry gates; feature UI and hooks live outside the initial route chunks. */
|
||||
'use client'
|
||||
import type { ReactNode } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { QTableMetaData } from '@/types'
|
||||
import { useCollaborationAvailable } from '@/lib/hooks/use-collaboration-core'
|
||||
|
||||
const Panel = dynamic(
|
||||
() => import('./RecordCollaborationPanel').then((m) => m.RecordCollaborationPanel),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<aside
|
||||
className="min-h-40 animate-pulse rounded-xl border border-border bg-muted xl:w-[360px]"
|
||||
aria-label="Loading collaboration"
|
||||
/>
|
||||
),
|
||||
}
|
||||
)
|
||||
const Tags = dynamic(() => import('./TagChips').then((m) => m.TagChips), {
|
||||
ssr: false,
|
||||
loading: () => <div className="mt-2 h-7 w-32 animate-pulse rounded bg-muted" />,
|
||||
})
|
||||
const Follow = dynamic(() => import('./FollowBell').then((m) => m.FollowBell), {
|
||||
ssr: false,
|
||||
loading: () => <span className="inline-block h-11 w-14 animate-pulse rounded bg-muted" />,
|
||||
})
|
||||
/** Shared entry props contain only metadata and identity. */
|
||||
interface Props {
|
||||
table: QTableMetaData
|
||||
pk: string
|
||||
}
|
||||
/** Lays out the record and lazy panel, keeping one DOM copy across breakpoints.
|
||||
* @param props - Record sections and table metadata.
|
||||
* @returns Responsive record layout.
|
||||
*/
|
||||
export function LazyRecordCollaborationLayout({
|
||||
table,
|
||||
pk,
|
||||
children,
|
||||
}: Props & { children: ReactNode }) {
|
||||
const enabled = table.recordFeatures?.features?.some((feature) =>
|
||||
['COMMENTS', 'ATTACHMENTS', 'TIMELINE'].includes(feature)
|
||||
)
|
||||
const available = useCollaborationAvailable(enabled)
|
||||
const show = enabled && available.data === true
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
show ? 'grid min-w-0 grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_auto]' : 'min-w-0'
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 space-y-5">{children}</div>
|
||||
{show && (
|
||||
<Panel
|
||||
key={`${table.name}/${pk}`}
|
||||
record={{ table: table.name, pk }}
|
||||
features={table.recordFeatures!}
|
||||
fieldLabels={Object.fromEntries(
|
||||
Object.values(table.fields).map((field) => [field.name, field.label])
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/** Loads tag controls only for opted-in tables and installed collaboration.
|
||||
* @param props - Metadata and identity.
|
||||
* @returns Tags or nothing.
|
||||
*/
|
||||
export function LazyRecordTags({ table, pk }: Props) {
|
||||
const enabled = table.recordFeatures?.features?.includes('TAGS')
|
||||
const available = useCollaborationAvailable(enabled)
|
||||
return enabled && available.data ? (
|
||||
<Tags
|
||||
key={`${table.name}/${pk}`}
|
||||
record={{ table: table.name, pk }}
|
||||
features={table.recordFeatures!}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/** Loads the follow bell only for opted-in tables.
|
||||
* @param props - Metadata and identity.
|
||||
* @returns Bell or nothing.
|
||||
*/
|
||||
export function LazyRecordFollow({ table, pk }: Props) {
|
||||
const enabled = table.recordFeatures?.features?.includes('FOLLOW')
|
||||
const available = useCollaborationAvailable(enabled)
|
||||
return enabled && available.data ? (
|
||||
<Follow key={`${table.name}/${pk}`} record={{ table: table.name, pk }} />
|
||||
) : null
|
||||
}
|
||||
Loaded 100 of 427 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user