Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7398f1aa01 | ||
|
|
596815c605
|
||
|
|
5de69be7e3 | ||
|
|
3508c505f5
|
||
|
|
b32a29d25b | ||
|
|
98409c85af | ||
|
|
ddc66c6b63
|
||
|
|
2ed5239642
|
||
|
|
725ef87efd
|
||
|
|
a724fc191c | ||
|
|
5b4ef76b01
|
||
|
|
c1be9c61b3
|
||
|
|
c8b7e9b16a
|
||
|
|
72377ac30b | ||
|
|
4d8b93ed42 | ||
|
|
cf6a326bca
|
||
|
|
c2aa695044
|
||
|
|
6c1e3a67b1
|
||
|
|
9d87b6dfee
|
||
|
|
21ba08bcd5
|
||
|
|
89add474cf | ||
|
|
3f161f6fd5 | ||
|
|
c183249a4b
|
||
|
|
9ce3b81b04
|
||
|
|
a62b10ae12
|
||
|
|
71bcc37c9e
|
||
|
|
a9975487f6
|
||
|
|
95931a7c7e
|
||
|
|
38207b3ef5
|
||
|
|
1f4841bf12
|
||
|
|
5629bcb3c7 | ||
|
|
4fe5ee94f2 | ||
|
|
ed1dc64330 | ||
|
|
ef6f0a9219 | ||
|
|
3ae29246c9 | ||
|
|
6eac444a22 | ||
|
|
46158c42cc
|
||
|
|
03e7415aa2 | ||
|
|
b89d406419
|
||
|
|
b699204d87 | ||
|
|
51c2db74f8
|
||
|
|
f34269bd38
|
||
|
|
930804d36c
|
||
|
|
2f745d578b
|
||
|
|
b50513c3b4 | ||
|
|
60b110a0b1
|
||
|
|
148f9d653b
|
||
|
|
ace6579fc0
|
||
|
|
66edf51719
|
||
|
|
3175b47165
|
||
|
|
fabbad15a7
|
||
|
|
baf7e0a554
|
||
|
|
85da466255
|
||
|
|
a7acea381a
|
||
|
|
4d2b9f685c | ||
|
|
36382059bc | ||
|
|
51e00b4195
|
||
|
|
84c95e524a | ||
|
|
06d5aacd91 | ||
|
|
0449ff315a
|
||
|
|
577bd3dee8
|
||
|
|
4586f5b4f2 | ||
|
|
effee583b7
|
||
|
|
1f1c5f546a
|
||
|
|
a42887f6e5
|
||
|
|
a163dc53b1
|
||
|
|
77eef1c816
|
||
|
|
9a905890a8
|
||
|
|
fe0077e86c
|
||
|
|
61be012fc2
|
||
|
|
bc01d10a27 | ||
|
|
d3daa4d067
|
||
|
|
49788dd4e8
|
||
|
|
1412af5f69 | ||
|
|
9150fb732f
|
||
|
|
e540b6fcbd
|
||
|
|
7f03ba1f4c
|
||
|
|
4e17d720f4
|
||
|
|
984c26b62b
|
||
|
|
d1eae03894
|
||
|
|
310c23f9cd
|
||
|
|
de387a61f7
|
||
|
|
e333223470 | ||
|
|
c064048c6f
|
||
|
|
2b4d82309c
|
||
|
|
4fc3dfd7aa
|
||
|
|
8c9619e7f7
|
||
|
|
d3298cca2b
|
||
|
|
edaa1ffb33
|
||
|
|
c564a29ce8
|
||
|
|
3ab0ed88fa
|
||
|
|
12ef8571f1
|
||
|
|
a4c8eaf8c9
|
||
|
|
b0b0d469fe | ||
|
|
3ceed91214
|
||
|
|
4e12a5d524
|
No files matched your search
+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,2 @@
|
||||
# Default reviewers for every change.
|
||||
* @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]
|
||||
@@ -7,7 +7,7 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
|
||||
|
||||
**This checkout targets Next UI 1.0.0-RC.11**, with searchable-selector and script-completion input-race corrections. Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing; see the [RC11 notes](docs/releases/1.0.0-RC.11.md) for the preparation checkpoint and verification scope. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
**This checkout targets Next UI 1.0.0-RC.15**, with 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. Confirm availability in the [RC15 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.15) before installing; see the [RC15 notes](docs/releases/1.0.0-RC.15.md) for the preparation checkpoint and verification scope. [RC14](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14) 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.11</version>
|
||||
<version>1.0.0-RC.15</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
The explicit version overrides an older BOM-managed frontend. RC11 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
|
||||
The explicit version overrides an older BOM-managed frontend. RC15 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). 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
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ The ID may be on a layout-neutral wrapper around a native button or menu item. S
|
||||
- App home and chrome: `app-card-{appName}-icon`, `.banner.info`/`.banner.warning`/`.banner.error`, and `.qqq-branded-header-bar`.
|
||||
- Query: `button-filter-builder`, `button-views`, `quick-views-container`, `button-query-mode-basic`, `button-query-mode-advanced`, `table-header-{field}`, and `menu-item-{action}`.
|
||||
- Record view: `.recordView`, `record-sidebar`, `record-view-header-{table}`, `record-view-title-{table}`, `record-view-button-bar-{table}`, `record-view-actions-menu`, and `delete-confirmation-*` with `button-delete-yes`/`button-delete-no`.
|
||||
- File previews: a `FILE_PREVIEW` value keeps its cell or value ID (`grid-cell-{field}`, `field-value-{field}`) on the thumbnail button with `data-preview-kind`; the box is `{id}-thumbnail`. The dialog uses `file-preview-dialog`, `file-preview-image`/`-pdf`/`-video`/`-audio`/`-unavailable`, `file-preview-details`, and `file-preview-{previous,next,zoom-in,zoom-out,zoom-fit,open,download,close}`.
|
||||
- Forms: `.entityForm`, `record-create-{table}`/`record-edit-{table}`, `form-section-{section}`, `.field-wrapper.is-visible`, and `.stickyBottomButtonBar`. Default action buttons expose `data-button-variant` values such as `gradient`, `contained`, and `outlined`.
|
||||
|
||||
The record view has a phone accordion and action sheet, a tablet tab list without the large-screen sidebar, and a large-screen tab list with the sidebar. A selector can be present in the DOM while its control is hidden at a narrower width. Test custom CSS at all three widths, including focus and contrast, before shipping it.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Next UI agent guide
|
||||
|
||||
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
> **This checkout targets RC15.** Confirm availability in the [RC15 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.15) before installing. [RC15 notes](../releases/1.0.0-RC.15.md) record the preparation checkpoint and verification scope; RC14 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
|
||||
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
|
||||
|
||||
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) at `12cf774`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
|
||||
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the previously verified public candidate at the RC13 preparation checkpoint was [1.0.0-RC.12](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) at `e333223`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
|
||||
|
||||
## Operating rule: metadata is the product boundary
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Next UI developer guide
|
||||
|
||||
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
> **This checkout targets RC15.** Confirm availability in the [RC15 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.15) before installing. [RC15 notes](../releases/1.0.0-RC.15.md) record the preparation checkpoint and verification scope; RC14 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
|
||||
> **RC9:** application code is unchanged from RC8. See [RC9 notes](../releases/1.0.0-RC.9.md) and [published releases](https://github.com/QRun-IO/qqq-frontend-next/releases) for versioned verification and public delivery status. Earlier RC8 receipts below remain historical evidence.
|
||||
|
||||
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
|
||||
|
||||
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) (commit `12cf774`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
|
||||
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The previously verified public candidate at the RC15 preparation checkpoint was [1.0.0-RC.14](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14) (commit `03e7415`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
|
||||
|
||||
The visual baseline is the September 25 Next UI design (`e42ad2b2`). Material remains the reference for backend contracts and functional behavior. The layout defaults below describe current source; they do not certify a completed visual review.
|
||||
|
||||
@@ -25,7 +25,7 @@ The source of truth for the frontend shape is [`src/types/metadata.ts`](../../sr
|
||||
}
|
||||
```
|
||||
|
||||
`appTree` controls the sidebar; `apps[name].widgets` controls ordered dashboard widgets; `apps[name].sections` controls the app home cards. The server may omit denied objects or send disabled permission flags; the frontend also checks flags before requesting data. The server must still enforce authorization. A widget can also be placed in a table section through `QTableSection.widgetName`, or embedded in a process step of type `WIDGET`. `QInstance.redirects` can map old exact or wildcard Material paths to new targets. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
|
||||
`appTree` controls the sidebar; `apps[name].widgets` controls ordered dashboard widgets; `apps[name].sections` controls the app home cards. The server may omit denied objects or send disabled permission flags; the frontend also checks flags before requesting data. The server must still enforce authorization. A widget can also be placed in a table section through `QTableSection.widgetName`, or embedded in a process step of type `WIDGET`. `QInstance.redirects` can map old exact or wildcard Material paths to new targets. `QInstance.homeAppName` (QQQ [#1059](https://github.com/QRun-IO/qqq/issues/1059)) designates the app whose dashboard is the instance's home page; it is published only to users who may open that app. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
|
||||
|
||||
## 2. Install and run
|
||||
|
||||
@@ -35,7 +35,7 @@ The preferred QQQ-hosted distribution is the `com.kingsrook.qqq:qqq-frontend-nex
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.11</version>
|
||||
<version>1.0.0-RC.15</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@@ -48,7 +48,7 @@ pnpm build:export
|
||||
pnpm build:jar
|
||||
```
|
||||
|
||||
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). This checkout targets `1.0.0-RC.11`, overriding an older BOM-managed frontend; confirm its availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. It requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for its verified backend commit. Use a matching local backend/frontend build to test later branch changes.
|
||||
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). This checkout targets `1.0.0-RC.13`, overriding an older BOM-managed frontend; confirm its availability in the [RC13 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) before installing. It requires a published QQQ 4.1 candidate (4.1.0-RC.1 or later; 4.1.0-RC.2 or later for standalone branding images, QQQ #1009; 4.1.0-RC.3 or later for FILE_PREVIEW thumbnails and the instance home app, QQQ #1056/#1059); see the release notes for the verified backend. Use a matching local backend/frontend build to test later branch changes.
|
||||
|
||||
| Setting | Behavior in this checkout |
|
||||
|---|---|
|
||||
@@ -77,7 +77,7 @@ Permissions apply to metadata and data: check table capabilities and the corresp
|
||||
|---|---|
|
||||
| `/` | Opens `/app`, or handles an Auth0 authorization callback. |
|
||||
| `/login`, `/callback`, `/token` | Sign-in and supported authorization callback routes. |
|
||||
| `/app` | Instance dashboard / app entry point. |
|
||||
| `/app` | Instance home page: the `homeAppName` app's dashboard when the instance designates one the user may open (client-side redirect to `/app/{homeAppName}`; the sidebar logo and breadcrumb Home crumb link there and the sidebar has no separate Dashboard entry), otherwise Next's overview. |
|
||||
| `/app/{appName}` | App home and its ordered widgets/sections. |
|
||||
| `/app/{tableName}` | Query or key lookup, according to table capability. |
|
||||
| `/app/{tableName}/create` | Create record. |
|
||||
@@ -138,7 +138,7 @@ For a fixed choice list, put `inlinePossibleValueSource` on the QQQ field with `
|
||||
|
||||
The query page supports basic quick-filter chips and an advanced nested filter builder, quick search, sort, column visibility/order/width/pinning, density, pagination, CSV export, row selection, bulk process launch, column statistics, and table variants where declared. On desktop, Filter opens a panel containing the Basic/Advanced switch, sort controls, quick-filter chips, and advanced builder. On phones, Filter opens a sheet containing the same mode, sort, and quick-filter controls; Advanced shows the nested builder inside that sheet. Opening Filter preserves the selected mode. Advanced mode uses a single editable condition list with per-row removal and one confirmed Clear action in the footer. Complex criteria remain in Advanced mode; the basic chips expose metadata-selected fields and let the user add more. The Columns popup supports keyboard entry, search, reordering and focus return on close; embedded report column editors keep the surrounding editor focus. Query columns start at 150 px unless a `SIZE` adornment or a saved/resized width supplies another value. `TABLE_QUERY`, `TABLE_GET`, `TABLE_COUNT`, `TABLE_INSERT`, `TABLE_UPDATE`, `TABLE_DELETE`, `TABLE_EXPORT`, and `QUERY_STATS` gate operations alongside permissions. For tables with `usesVariants`, choose a backend-provided variant before querying; it is stored under `qqq.tableVariant.<table>` and sent to query, process and record view/edit/copy GET requests. Backend field-download URLs carry the same variant; inline BLOB data comes from the variant-specific record response. [REC-063](../acceptance/feature-matrix.md) verifies view/edit/copy against duplicate record IDs in two variants. URL parameters `page`, `pageSize`, `filter`, and `q` carry shareable query state ([`use-record-query.ts`](../../src/lib/hooks/use-record-query.ts)). Saved views have backend persistence, quick-view shortcuts and counts, modified-state comparison, and their own routes when the backend capabilities exist ([`use-saved-views.ts`](../../src/lib/hooks/use-saved-views.ts)). Browser local storage holds personal display preferences and last view state, not authoritative records or permissions.
|
||||
|
||||
Record pages render sections, field values, associated records, widgets, metadata-defined actions, help, and audit history. `QTableSection.alternatives.RECORD_VIEW` replaces the base section for this screen. Visible sections appear in metadata order; fields, sections and widget cards use `gridColumns` as twelfths on large screens and stack on narrow screens. When widths are not configured, Overview uses half-width cards on large screens; a selected section uses a full-width card with two field columns from the small breakpoint, stacking fields on phones. Explicit field, section, or widget widths take precedence over those presentation defaults. A collapsible section uses `collapsible.isCollapsible` and `initiallyOpen`, then remembers the choice under `qqq.recordView.collapsibleSectionOpenStates.<table>.<section>`; a section's rule overrides its widget's rule. The sticky desktop section sidebar is off by default; only `showRecordSidebar: true` in the table dashboard settings enables it. `recordViewActionsPlacement` can be set on the table or `supplementalInstanceMetaData.materialDashboard`, with the instance winning; `INLINE_WITH_PAGE_TITLE` places desktop actions beside the local record title, while the default `IN_IDENTITY_SECTION` keeps them in the identity controls. The identity card omits the primary-key T1 field and T1 values already contained in the record label; display values take precedence over raw values for that comparison. Desktop Copy and Delete buttons are omitted when the Actions menu already contains the corresponding enabled action, including in a submenu. An absent or disabled menu action preserves its available direct button. The Audit action reads through `GetAuditsForRecord` when permitted, otherwise through the `audit` table; its dialog groups entries by the viewer's local date, supports oldest/newest sort saved under `audit.sortDirection`, and reports when the 1,000-detail cap truncates results. Create/edit/copy forms use a centered container capped at `max-w-4xl` and follow field metadata, section layout, adjusters, possible-value choices, and server validation; `FILE_UPLOAD` width `full` spans the form grid, while `half` uses the default half width. Copy defaults to Base Copy, which duplicates editable fields; choose Full Copy to edit and submit loaded named associations with the new record. A widget section can edit association rows or form values through `WidgetFormContext` when its type and defaults allow it. Process launch actions can receive selected record IDs or a filter. `gotoFieldNames` in Material supplemental table metadata enables a Go To dialog for the primary key or configured unique keys. Developer views are capability and permission dependent. Consult [record and query acceptance rows](../acceptance/feature-matrix.md) for exact supported cases; do not infer coverage from the existence of a component alone.
|
||||
Record pages render sections, field values, associated records, widgets, metadata-defined actions, help, and audit history. `QTableSection.alternatives.RECORD_VIEW` replaces the base section for this screen. Visible sections appear in metadata order; fields, sections and widget cards use `gridColumns` as twelfths on large screens and stack on narrow screens. When widths are not configured, Overview uses half-width cards on large screens; a selected section uses a full-width card with two field columns from the small breakpoint, stacking fields on phones. Explicit field, section, or widget widths take precedence over those presentation defaults. A collapsible section uses `collapsible.isCollapsible` and `initiallyOpen`, then remembers the choice under `qqq.recordView.collapsibleSectionOpenStates.<table>.<section>`; a section's rule overrides its widget's rule. The sticky desktop section sidebar is off by default; only `showRecordSidebar: true` in the table dashboard settings enables it. `recordViewActionsPlacement` can be set on the table or `supplementalInstanceMetaData.materialDashboard`, with the instance winning; `INLINE_WITH_PAGE_TITLE` places desktop actions beside the local record title, while the default `IN_IDENTITY_SECTION` keeps them in the identity controls. The identity card omits the primary-key T1 field and T1 values already contained in the record label; display values take precedence over raw values for that comparison. Desktop Copy and Delete buttons are omitted when the Actions menu already contains the corresponding enabled action, including in a submenu. An absent or disabled menu action preserves its available direct button. The Audit action reads through `GetAuditsForRecord` when permitted, otherwise through the `audit` table; its dialog groups entries by the viewer's local date, supports oldest/newest sort saved under `audit.sortDirection`, and reports when the 1,000-detail cap truncates results. Create/edit/copy forms use a centered container capped at `max-w-4xl` and follow field metadata, section layout, adjusters, possible-value choices, and server validation; `FILE_UPLOAD` width `full` spans the form grid, while `half` uses the default half width. Copy defaults to Base Copy, which duplicates editable fields; choose Full Copy to edit and submit loaded named associations with the new record. A widget section can edit association rows or form values through `WidgetFormContext` when its type and defaults allow it. Process launch actions can receive selected record IDs or a filter. `gotoFieldNames` in Material supplemental table metadata enables a Go To dialog for the primary key or configured unique keys. Developer views are capability and permission dependent. The record developer view (`/app/{table}/{id}/dev`) keeps the record screen's header and actions and adds a sticky section navigation over fields, API access (v1 routes and application APIs from their OpenAPI specs, filtered by the user's table permissions, with curl, fetch and Java examples that only use placeholder credentials), the panels returned by `GET /qqq/v1/table/{table}/{pk}/developer/panels` (rendered generically from their blocks: KEY_VALUES, TEXT, CODE, TABLE, LINKS and ACTIONS; app-relative or http(s) links only), relations, recent audits, associated scripts, raw JSON and a searchable table-metadata explorer. Modules and qbits add panels by registering a `DeveloperPanelProviderInterface` in QQQ; no frontend change is needed. Consult [record and query acceptance rows](../acceptance/feature-matrix.md) for exact supported cases; do not infer coverage from the existence of a component alone.
|
||||
|
||||
Query changes replace the current URL immediately, preserving unrelated parameters and the hash. Reload and browser back/forward restore the visible filter, sort, search and pagination state; changing those controls does not add a separate history entry.
|
||||
|
||||
@@ -312,6 +312,21 @@ These examples are payload fragments emitted by an application's backend rendere
|
||||
{"gridColumns":6,"defaultValues":{"gridCols:sizeClass:xs":12,"gridCols:sizeClass:md":6}}
|
||||
```
|
||||
|
||||
**Readable widths (Next UI).** Without per-breakpoint overrides, a widget's span follows the width of the dashboard grid itself (CSS container queries, so an open sidebar counts), not the viewport. Two `defaultValues` hints tune it, and neither needs a QQQ change:
|
||||
|
||||
- `gridMinWidth`: the narrowest the widget may be, in rem (a number, or `"28rem"` / `"448px"`). The widget shares a row only when it, and a neighbour of the same minimum, each get at least that much; otherwise it takes the full row. Values snap up to the tiers 16, 20, 24, 28, 32, 40, 48 or 64 rem; anything wider always takes the full row. Two `gridColumns: 6` widgets with `gridMinWidth: 28` pair from a 57.5rem (920 px) grid and go to thirds (`gridColumns: 4`) from 86.5rem.
|
||||
- `gridProfile`: `widgets` (charts, statistics: half from a 40rem grid), `cards` or `table` (tables: half from 66rem), or `fields` (small tiles: half from 30rem). The default is `cards` for table-type widgets and `widgets` for the rest.
|
||||
|
||||
`gridCols:sizeClass:*` wins over `gridMinWidth`, which wins over `gridProfile`.
|
||||
|
||||
```json
|
||||
{"type":"table","gridColumns":6,"defaultValues":{"gridMinWidth":28}}
|
||||
```
|
||||
|
||||
Table widgets (`table`, `multiTable`) stop growing at 96rem (1536 px) so columns stay readable on ultra-wide screens; set `defaultValues.tableMaxWidth` to another width (rem, or a CSS length in `rem`, `px`, `ch`, `em` or `%`) or to `"none"`. Where a table widget is narrower than 30rem, or than its declared column widths need (6rem per `fr` or unsized column plus the fixed widths), each row renders as a stacked card of `label: value` lines instead of a table that scrolls sideways.
|
||||
|
||||
To cap a whole dashboard page on ultra-wide screens, give the app a supplemental app metadata of type `dashboardLayout` (a backend `QSupplementalAppMetaData` subclass whose `getType()` returns `dashboardLayout` and that is included in frontend metadata) with `maxContentWidth` (rem, or a CSS length). The page then centres at that width: `{"supplementalAppMetaData":{"dashboardLayout":{"maxContentWidth":160}}}`.
|
||||
|
||||
See [`widgetColumnClasses`](../../src/components/widgets/widget-utils.ts). Parent grids use the same sizing. A `parentWidget` payload such as `{"childWidgetNameList":["totals","trend"],"layoutType":"TABS"}` resolves those names in the instance widget registry. Children inherit parent request parameters and dropdown selections and fetch their own data; tab selection is remembered locally. A child omitted from permitted metadata cannot be configured into existence by listing its name.
|
||||
|
||||
**Dropdown data.** Metadata and response lists are paired by position; keep their lengths and order aligned. For the `region` dropdown example above, a response fragment is:
|
||||
|
||||
@@ -10,11 +10,11 @@ RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-nex
|
||||
|
||||
The owner authorizes publishing new RCs at verified stability or daily milestones. Each RC gets a new immutable version and notes describing its fixes, backend prerequisites, verification, and known gaps. RC publication does not authorize final 1.0.
|
||||
|
||||
Previously verified public candidate at the RC11 preparation checkpoint: **[1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC9 remain available and unchanged.
|
||||
Previously verified public candidate at the RC15 preparation checkpoint: **[1.0.0-RC.14](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC13 remain available and unchanged.
|
||||
|
||||
## Fixes during testing
|
||||
|
||||
This checkout targets [RC11](1.0.0-RC.11.md); confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. The candidate adds searchable-selector and script-completion input-race corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24). These changes are absent from immutable RC10. Candidate notes distinguish versioned local checks from public delivery and retain the historical browser failures and remaining RC limits. Local previews may intentionally preserve an earlier version.
|
||||
This checkout targets [RC15](1.0.0-RC.15.md); confirm availability in the [RC15 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.15) before installing. RC15 adds 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 is absent from immutable RC13. Candidate notes distinguish versioned local checks from public delivery and retain the historical browser failures and remaining RC limits. Local previews may intentionally preserve an earlier version.
|
||||
|
||||
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Next UI 1.0.0-RC.12 — preparation
|
||||
|
||||
At this preparation checkpoint, RC12 is being prepared for application testing and **is not yet published**. [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.12` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.12`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `b0b0d469feb0b1bd473fbf189540e712ffc09807` (merge of [PR46](https://github.com/QRun-IO/qqq-frontend-next/pull/46)). Merged since RC11: PR25, PR27, PR28, PR29, PR30, PR32, PR35, PR36, PR38, PR40, PR42, PR43, PR44 and PR46. The SQL Workbench work in draft PR47 is not included.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
RC12 runs on a published QQQ 4.1 candidate: **4.1.0-RC.1** or later. Standalone branding images (below) need **4.1.0-RC.2** or later, which contains QQQ #1009's `/qqq/branding/logo` and `/qqq/branding/icon` endpoints. RC12 does not publish QQQ 4.1.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Always show the record view's Actions menu when it has entries. Tables without processes or a custom `VIEW_SCREEN_ACTIONS` menu, and read-only tables, again reach Developer Mode and Audit (Material parity); Copy and Delete move into that menu instead of also appearing as separate buttons. [#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)
|
||||
- Clear remembered query filters and saved-view selections on logout or a detected user identity change. Same-user resumes keep their query state; global appearance, density and legacy column preferences remain intact. This correction awaits its own branch acceptance. [#1006](https://github.com/QRun-IO/qqq/issues/1006)
|
||||
- In searchable possible-value fields, invalidate old results immediately when the search term changes, so a debounced request cannot remove an option during its selection. [#970](https://github.com/QRun-IO/qqq/issues/970)
|
||||
- Keep code suggestions open when a delayed textarea scroll event reports the position already used for the popup; dismiss them when the scroll position actually changes. [#954](https://github.com/QRun-IO/qqq/issues/954)
|
||||
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
|
||||
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
|
||||
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
|
||||
|
||||
## Standalone branding and CSP (QQQ #1009, #1017)
|
||||
|
||||
The standalone Node server can route the exact root-relative `branding.logo` and
|
||||
`branding.icon` paths published by authentication metadata through fixed backend
|
||||
image roles. Local Node `public` files keep precedence; external image URLs and
|
||||
Java dashboard serving are unchanged. This does not expose the whole application
|
||||
overlay or add a general proxy.
|
||||
|
||||
This requires **a QQQ middleware-javalin build containing QQQ #1009's
|
||||
`/qqq/branding/logo` and `/qqq/branding/icon` endpoints**. They are absent from
|
||||
published **4.1.0-RC.1**. The minimum numbered release is not yet assigned; a
|
||||
source build must record the exact backend commit/JAR hashes. Older deployments
|
||||
must supply branding through their Node `public` files or an existing external
|
||||
image URL. No automatic compatibility with old relative backend assets is claimed.
|
||||
|
||||
The preserved standalone qualification against public RC1 remains **5 passed,
|
||||
38 failed** out of 43 cases because of missing branding assets. The original
|
||||
staging failure is also retained. The corrected cross-repository pairing has
|
||||
not yet received its browser qualification; neither #1009 nor #734 is closed by
|
||||
focused unit/HTTP results.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Pin the official Playwright runner containing the fixed Linux WebKit network library, and retain native browser diagnostics for navigation failures. The previous bundle's heap corruption was reproduced; migration to a stable runner remains tracked separately. [#904](https://github.com/QRun-IO/qqq/issues/904), [#973](https://github.com/QRun-IO/qqq/issues/973)
|
||||
- Correlate interrupted Firefox font diagnostics with real request and decode evidence, wait for initial fonts before the deliberate redirect, and observe transient feedback during the action that produces it. [#951](https://github.com/QRun-IO/qqq/issues/951), [#953](https://github.com/QRun-IO/qqq/issues/953), [#967](https://github.com/QRun-IO/qqq/issues/967)
|
||||
- Wait for nested report metadata and the destination's owned iframe before advancing acceptance navigation. [#961](https://github.com/QRun-IO/qqq/issues/961), [#972](https://github.com/QRun-IO/qqq/issues/972)
|
||||
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
|
||||
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
|
||||
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
|
||||
- Select all ten ESB scenarios for both phone and tablet acceptance, retaining real broker delivery, lifecycle, replay, SQL and permission checks. Matrix references now use the central QQQ tickets. [#986](https://github.com/QRun-IO/qqq/issues/986), [#990](https://github.com/QRun-IO/qqq/issues/990)
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC12 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the full real-backend acceptance workflow) are recorded on the preparation PR. Tag acceptance and public-artifact verification are recorded in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) after delivery. No RC11 test count or artifact digest is attributed to RC12.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC11 limits carry forward unchanged. The RC11 tag acceptance recorded 2,990 passes and 2 owner-accepted failures (Firefox NAV-014 font diagnostic, tablet NAV-056 access-control diagnostics) under the documented secondary-browser/tablet exception ([#985](https://github.com/QRun-IO/qqq/issues/985), [#798](https://github.com/QRun-IO/qqq/issues/798)); it does not waive new product defects or authorize GA. Historical REC-054/PRC-061 observations remain under [#954](https://github.com/QRun-IO/qqq/issues/954) and [#970](https://github.com/QRun-IO/qqq/issues/970). Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Next UI 1.0.0-RC.13 — preparation
|
||||
|
||||
At this preparation checkpoint, RC13 is being prepared for application testing and **is not yet published**. [RC12](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.13` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.13`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `84c95e524a93056864e60863f5ab45357969e786` (merge of [PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53)). Merged since RC12: [PR47](https://github.com/QRun-IO/qqq-frontend-next/pull/47) (SQL Workbench), [PR49](https://github.com/QRun-IO/qqq-frontend-next/pull/49) (FILE_PREVIEW thumbnails and lightbox), [PR50](https://github.com/QRun-IO/qqq-frontend-next/pull/50) (responsive layouts, readable child lists, compact dates) and [PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53) (instance home app). The single page scroll container ([PR54](https://github.com/QRun-IO/qqq-frontend-next/pull/54), its WebKit acceptance not yet green) and widget width readability ([PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55)) are not included; they follow in RC14.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
RC13 runs on a published QQQ 4.1 candidate: **4.1.0-RC.1** or later. Standalone branding images need **4.1.0-RC.2** or later (QQQ #1009). **FILE_PREVIEW thumbnails and the instance home app need QQQ 4.1.0-RC.3 or later**, which carries the `FILE_PREVIEW` adornment ([qqq#1056](https://github.com/QRun-IO/qqq/issues/1056)) and `QInstance.homeAppName` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)); on older backends neither appears and the rest of RC13 works unchanged. The SQL Workbench page needs the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit, **1.0.0-RC.1** or later, on the backend. RC13 does not publish QQQ 4.1.
|
||||
|
||||
## New features
|
||||
|
||||
- SQL Workbench: render the `SQL_WORKBENCH` widget of the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit as a super-user SQL notebook.
|
||||
- Personal and shared notebooks with SQL and markdown cells, `:parameters`, versions and pinned results.
|
||||
- A CodeMirror 6 editor with dialect highlighting, schema and QQQ-name autocomplete, and formatting.
|
||||
- A typed, sortable, paged result grid with CSV/XLSX/JSON export, quick charts, and EXPLAIN plan trees.
|
||||
- Read-only runs by default, and a two-step write confirmation for users with the write permission.
|
||||
- CodeMirror and the formatter load only on that page (new `lazyChunks` budgets).
|
||||
|
||||
## Features
|
||||
|
||||
- Support an instance home app. When the backend designates one with `QInstance.homeAppName` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)), and the user may open it, `/app` shows that app's dashboard (a client-side redirect, matching backends that redirect full page loads of `/app/`), the sidebar logo and the breadcrumb Home crumb link there, and the sidebar drops its separate Dashboard entry. Without a home app, or with one the user may not open, `/app` remains Next's overview.
|
||||
- Show `FILE_PREVIEW`-adorned fields as thumbnails, and open a preview on click or Enter. Thumbnails appear in the query grid and card view, the record view (a larger thumbnail with the file name), associated-record tables, child-record list widgets and record-grid widgets. They load lazily at a fixed size, so loading does not shift the layout, with a loading pulse and a file-type icon fallback. The preview is an accessible dialog: focus is trapped and restored, Escape closes it, and Left/Right/Home/End step through the other previews on the same screen. It offers zoom and fit, open-original and download links, and shows type, size, dimensions and caption. PDFs served by the backend (same-origin `/qqq/` paths) and video/audio play inline; other types, and other or cross-origin PDFs (frames are limited to `'self'`), get a file card or open in a new tab. Thumbnails are shown only for explicitly adorned fields; plain URL values are never auto-detected. URLs are limited to http(s), same-origin paths and inline images. The lightbox loads on demand (about 3.5 KB gzip). Requires the QQQ backend adornment from [qqq#1056](https://github.com/QRun-IO/qqq/issues/1056).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Size record-view cards, dashboard widgets and form fields by the width of their own grid (container queries) rather than the viewport: table panels sit side by side only when each is readable and otherwise stack, cards keep their own height, fields share a row only when they fit, and very wide screens use the room. No horizontal page scroll from 360px up.
|
||||
- Make child-record lists in record-view panels readable: they show the columns that matter most and fit (the record name, status and boolean columns, the most recently changed date; the key and long text last), with `+N more columns` and View All for the rest. Cells align to the top and long text is clamped with the full text on hover. A widget's `onlyIncludeFieldNames` order is respected.
|
||||
- Show dates compactly everywhere: `6:33 PM` today, `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before, on one line with the full zoned timestamp on hover, in the viewer's zone and locale. DATE values are no longer shifted a day west of UTC. Exports are unchanged.
|
||||
|
||||
## Bundle budget
|
||||
|
||||
The record developer route `/app/_/_/dev` and the table developer route `/app/_/dev` each have a 360 KB first-load cap (measured 354.9 KB and 350.8 KB on the combined tree); every other route keeps the 350 KB default. The SQL Workbench editor (CodeMirror 6) and formatter load on demand under separate `lazyChunks` budgets; the FILE_PREVIEW lightbox is a lazy chunk.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC13 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the full real-backend acceptance workflow) are recorded on the preparation PR. During integration the WebKit/tablet NAV-016, NAV-023 and NAV-056 access-control diagnostics and one Firefox image-decode diagnostic (SEC-026) recurred intermittently and passed on rerun; they belong to the tracked [#985](https://github.com/QRun-IO/qqq/issues/985) / [#951](https://github.com/QRun-IO/qqq/issues/951) investigations and no assertion was relaxed. Tag acceptance and public-artifact verification are recorded in the [RC13 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) after delivery. No RC12 test count or artifact digest is attributed to RC13.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC12 limits carry forward unchanged: Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Next UI 1.0.0-RC.14 — preparation
|
||||
|
||||
At this preparation checkpoint, RC14 is being prepared as an urgent fix for application testing and **is not yet published**. [RC13](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.14` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.14`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `b699204d8736f5cfcc61358c38e86d89ab3d5541` (merge of [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57)). Merged since RC13: [PR52](https://github.com/QRun-IO/qqq-frontend-next/pull/52) (Next.js 16.3.8 security update and sharp) and [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57) (record search tolerates results without a label). RC14 is cut early because global record search is broken in production on RC13. The README adoption guide (PR58), the record developer view (PR61), the single page scroll container (PR54), widget width readability (PR55) and the NAV-056 diagnostic fix are not included; they follow in RC15.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
Unchanged from RC13: a published QQQ 4.1 candidate, **4.1.0-RC.1** or later; **4.1.0-RC.2** or later for standalone branding images (QQQ #1009); **4.1.0-RC.3** or later for FILE_PREVIEW thumbnails ([qqq#1056](https://github.com/QRun-IO/qqq/issues/1056)) and the instance home app ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)); [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) **1.0.0-RC.1** or later for the SQL Workbench page. The search fix needs no backend change; the server-side companion [qqq#1068](https://github.com/QRun-IO/qqq/pull/1068) stops emitting blank labels. RC14 does not publish QQQ 4.1.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Record search no longer fails when one result has no label. QQQ serializes JSON without empty values, so a record whose label is empty arrived without `recordLabel`, and the strict response schema rejected the whole response ("Record search failed. Try again."). The response envelope is still checked strictly, but each result is now validated on its own: `tableName` and `recordId` are required (a numeric `recordId` is accepted), a result without a label is shown as "{table label} {record id}", and a result that cannot link to a record is skipped with a console warning while the others render.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Next.js 16.3.6 to 16.3.8 (security fixes, including the image-optimization SSRF advisory GHSA-cjq9-62q9-8jv4) and the matching `sharp` update ([PR52](https://github.com/QRun-IO/qqq-frontend-next/pull/52)).
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC14 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the full real-backend acceptance workflow) are recorded on the preparation PR. During integration the WebKit/tablet NAV-023 diagnostic recurred intermittently and passed on rerun. **Known failure accepted for this release:** the WebKit NAV-056 diagnostic (appearance preferences explain an application theme override) failed on [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57) and also fails on main without RC14's changes; the owner explicitly approved merging and releasing RC14 with it because production record search was broken. It is tracked under [#985](https://github.com/QRun-IO/qqq/issues/985), a fix is in progress for RC15, and no assertion was relaxed. Tag acceptance and public-artifact verification are recorded in the [RC14 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14) after delivery. No RC13 test count or artifact digest is attributed to RC14.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC13 limits carry forward unchanged: Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Next UI 1.0.0-RC.15 — preparation
|
||||
|
||||
At this preparation checkpoint RC15 **is not yet published**. [RC14](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.15` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.15`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `5de69be`. Merged since RC14: [PR58](https://github.com/QRun-IO/qqq-frontend-next/pull/58) (README adoption guide), [PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55) (readable dashboard widget widths), [PR54](https://github.com/QRun-IO/qqq-frontend-next/pull/54) (one page scroll container), [PR77](https://github.com/QRun-IO/qqq-frontend-next/pull/77) (Dependabot limited to three open PRs per ecosystem), [PR78](https://github.com/QRun-IO/qqq-frontend-next/pull/78) (SQL Workbench dark mode and standard controls), [PR61](https://github.com/QRun-IO/qqq-frontend-next/pull/61) (record developer view), [PR60](https://github.com/QRun-IO/qqq-frontend-next/pull/60) (QQQ Assistant panel, phase 1), [PR59](https://github.com/QRun-IO/qqq-frontend-next/pull/59) (search-as-you-type quick search, richer results and a search page).
|
||||
|
||||
## Backend requirements
|
||||
|
||||
A published QQQ 4.1 candidate, **4.1.0-RC.1** or later; **4.1.0-RC.3** or later for FILE_PREVIEW and the instance home app; **4.1.0-RC.4** or later for record developer panels ([qqq#1076](https://github.com/QRun-IO/qqq/pull/1076)); [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) 1.0.0-RC.1 or later for the SQL Workbench page (1.0.0-RC.4 or later opens developer-panel SQL in the Runner); [qbit-assistant](https://github.com/QRun-IO/qbit-assistant) 0.1.0-RC.3 or later for the assistant panel (without it nothing renders). RC15 does not publish QQQ 4.1.
|
||||
|
||||
## Features
|
||||
|
||||
- **QQQ Assistant panel (Phase 1: read-only questions and navigation).** When the `qbit-assistant` backend is installed and `GET /qqq/v1/assistant/config` enables it for the user, a header button (with unread badge), `Ctrl/⌘+J` and a command palette "Ask Assistant" item open a chat panel. It docks beside the page from 1024 px (resizable 360-640 px, its own scrolling message list) and is a full-height sheet below that. Replies stream over SSE with resume after a dropped connection and a Stop button; tool steps, record chips, records/record/count/history renders and chart widgets come from structured payloads, and markdown renders without raw HTML or images, with links only to same-origin `/app` paths. `?assistant=open&assistantPrompt=...` opens a new conversation with the prompt pre-filled. Without the backend nothing renders and the shortcut is inert. The panel code loads on first open (about 1.7 KB added to first load).
|
||||
- **Search** ([PR59](https://github.com/QRun-IO/qqq-frontend-next/pull/59)): search-as-you-type quick search with richer results (highlights, details, per-table status from QQQ 4.1.0-RC.4 record search) and a URL-addressable search page; the search page and previews load on demand.
|
||||
- **SQL Workbench dark mode and standard controls** ([PR78](https://github.com/QRun-IO/qqq-frontend-next/pull/78)).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- **Readable dashboard widget widths** ([PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55)): per-widget minimum widths, ultra-wide caps and a table card layout.
|
||||
- **Record developer view** (`/app/{table}/{id}/dev`): the record screen's navigation (back to the record, record title, Edit, Actions, Audit, breadcrumbs) and a sticky section bar (a section picker on phones, wrapping links elsewhere) over Fields (display and raw values, types, possible-value sources and adornments, filterable), **API Access** (the v1 routes this screen uses and every application API that exposes the table, read from its OpenAPI spec and limited to what the user may do, with copyable curl, fetch and Java examples built from the record's values, placeholder credentials only, and links to the API docs and specs), **developer panels** from QQQ's provider contract ([qqq#1076](https://github.com/QRun-IO/qqq/pull/1076): record security, ESB events, and any qbit's panel such as quick search, SQL workbench or the assistant, shown only when installed and active for the table; QQQ before 4.1.0-RC.4 has no panels endpoint, so the view shows none and asks for it only once per session), Relations, recent Audit, associated scripts, raw JSON and a searchable Table Metadata explorer. Readable from 360 to 3200px with no sideways scrolling. The view loads on demand, keeping the route's first-load JavaScript at 333.5 KB (budget 360 KB).
|
||||
- One scroll container per page: on long pages the document no longer grows a second scrollbar beside the content area's (and no longer slides the header off-screen). The app shell and `<main>` are now positioned, so absolutely positioned descendants such as `sr-only` text (the company footer's "opens in a new tab") stay inside the scrolling area instead of stretching the document. Covered by `tests/e2e/single-scroll-container.spec.ts` at 390-3200px.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Dependabot keeps at most three open PRs per ecosystem ([PR77](https://github.com/QRun-IO/qqq-frontend-next/pull/77)).
|
||||
- The WebKit NAV-056 diagnostic race is fixed in the acceptance tests ([PR63](https://github.com/QRun-IO/qqq-frontend-next/pull/63)).
|
||||
- README: "Adopting in another app" ([PR58](https://github.com/QRun-IO/qqq-frontend-next/pull/58)).
|
||||
|
||||
## Bundle budget
|
||||
|
||||
Owner decision 2026-10-12: the total JavaScript cap (every on-demand feature) is 1100 KB gzip and grows with features; first-load route caps, the 23 KB shared CSS cap and the lazy-chunk caps stay strict. See `perf-budget.json`.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC15 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the real-backend acceptance workflow) are recorded on the preparation PR. Tag acceptance and public-artifact verification are recorded in the RC15 GitHub release after delivery. No RC14 test count or artifact digest is attributed to RC15.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC14 limits carry forward unchanged: Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904).
|
||||
@@ -1,62 +1,17 @@
|
||||
# Next UI — unreleased
|
||||
|
||||
[RC11 is published and immutable](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11). It includes the searchable-selector and script-completion corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24); its release details remain in [RC11 notes](1.0.0-RC.11.md). Current main also contains the later menu, copy-focus and grid fixes, which are not in that published artifact. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
[RC15 preparation](1.0.0-RC.15.md) collects every change merged since [RC14](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14). RC14 is published and immutable. Changes merged after the RC15 preparation commit are listed below. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
|
||||
## New features
|
||||
## Features
|
||||
|
||||
- SQL Workbench: render the `SQL_WORKBENCH` widget of the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit as a super-user SQL notebook.
|
||||
- Personal and shared notebooks with SQL and markdown cells, `:parameters`, versions and pinned results.
|
||||
- A CodeMirror 6 editor with dialect highlighting, schema and QQQ-name autocomplete, and formatting.
|
||||
- A typed, sortable, paged result grid with CSV/XLSX/JSON export, quick charts, and EXPLAIN plan trees.
|
||||
- Read-only runs by default, and a two-step write confirmation for users with the write permission.
|
||||
- CodeMirror and the formatter load only on that page (new `lazyChunks` budgets).
|
||||
None yet.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Clear remembered query filters and saved-view selections on logout or a detected user identity change. Same-user resumes keep their query state; global appearance, density and legacy column preferences remain intact. This correction awaits its own branch acceptance. [#1006](https://github.com/QRun-IO/qqq/issues/1006)
|
||||
- In searchable possible-value fields, invalidate old results immediately when the search term changes, so a debounced request cannot remove an option during its selection. [#970](https://github.com/QRun-IO/qqq/issues/970)
|
||||
- Keep code suggestions open when a delayed textarea scroll event reports the position already used for the popup; dismiss them when the scroll position actually changes. [#954](https://github.com/QRun-IO/qqq/issues/954)
|
||||
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
|
||||
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
|
||||
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
|
||||
None yet.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Pin the official Playwright runner containing the fixed Linux WebKit network library, and retain native browser diagnostics for navigation failures. The previous bundle's heap corruption was reproduced; migration to a stable runner remains tracked separately. [#904](https://github.com/QRun-IO/qqq/issues/904), [#973](https://github.com/QRun-IO/qqq/issues/973)
|
||||
- Correlate interrupted Firefox font diagnostics with real request and decode evidence, wait for initial fonts before the deliberate redirect, and observe transient feedback during the action that produces it. [#951](https://github.com/QRun-IO/qqq/issues/951), [#953](https://github.com/QRun-IO/qqq/issues/953), [#967](https://github.com/QRun-IO/qqq/issues/967)
|
||||
- Wait for nested report metadata and the destination's owned iframe before advancing acceptance navigation. [#961](https://github.com/QRun-IO/qqq/issues/961), [#972](https://github.com/QRun-IO/qqq/issues/972)
|
||||
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
|
||||
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
|
||||
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
|
||||
- Select all ten ESB scenarios for both phone and tablet acceptance, retaining real broker delivery, lifecycle, replay, SQL and permission checks. Matrix references now use the central QQQ tickets. [#986](https://github.com/QRun-IO/qqq/issues/986), [#990](https://github.com/QRun-IO/qqq/issues/990)
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
Current main `5ca3e0b` has a complete [3,010-case first-attempt acceptance pass](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36989591498): 620 per desktop browser and 575 per touch profile. That run qualifies the main tree before the query-state correction above. The earlier checkpoints below are retained separately.
|
||||
|
||||
The [combined acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) on head `7ff90f1` (the same tree as merge `289eca19`) passed all 2,972 executed cases: 614 each in Chromium, Firefox and WebKit, and 565 each on phone and tablet. All desktop gates covered 401 required rows. The workflow still failed its touch coverage gate because ten ESB rows were not selected; that original failure remains retained under [#990](https://github.com/QRun-IO/qqq/issues/990). After adding the selection markers, a separate native Linux run passed all 20 ESB phone/tablet cases, with zero retries, skips, flaky outcomes or report errors. Its frozen frontend export is unchanged; its freshly rebuilt backend is `55b33e6`. Complete touch collection is 575 cases per profile. The subsequent [complete PR23 outcome](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159) covers all 401 required IDs on each profile with zero missing and all 20 ESB touch cases passing, completing #990. Touch totals are 1,149 passes and one unrelated tablet NAV-056 failure; the full gate and workflow remain failed. [NAV-056 investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383) remains separate, with no shared-cause claim or new waiver.
|
||||
|
||||
Each linked issue and PR records the source commit, reproduction and scoped verification. Preserve failed full-run snapshots separately from later focused passes. The broader historical navigation inventory [#904](https://github.com/QRun-IO/qqq/issues/904), same-user session stall [#952](https://github.com/QRun-IO/qqq/issues/952), legacy process-navigation stall [#960](https://github.com/QRun-IO/qqq/issues/960), and original toast-paint uncertainty [#967](https://github.com/QRun-IO/qqq/issues/967) remain open. The [RC10 preparation notes](1.0.0-RC.10.md) retain their historical checkpoint; its [public release entry](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) records final delivery verification. See [RC11 notes](1.0.0-RC.11.md) for the successor candidate. No final-release approval is claimed by these results.
|
||||
None yet.
|
||||
|
||||
Final 1.0 remains held for real-application soak and explicit owner approval under [#712](https://github.com/QRun-IO/qqq/issues/712). This work does not authorize a QQQ 4.1 release. See the [testing-period policy](1.0-testing.md) and [issue lifecycle](../ISSUE-TRACKING.md).
|
||||
|
||||
## Pending standalone branding compatibility (QQQ #1009)
|
||||
|
||||
The standalone Node server can route the exact root-relative `branding.logo` and
|
||||
`branding.icon` paths published by authentication metadata through fixed backend
|
||||
image roles. Local Node `public` files keep precedence; external image URLs and
|
||||
Java dashboard serving are unchanged. This does not expose the whole application
|
||||
overlay or add a general proxy.
|
||||
|
||||
This requires **a QQQ middleware-javalin build containing QQQ #1009's
|
||||
`/qqq/branding/logo` and `/qqq/branding/icon` endpoints**. They are absent from
|
||||
published **4.1.0-RC.1**. The minimum numbered release is not yet assigned; a
|
||||
source build must record the exact backend commit/JAR hashes. Older deployments
|
||||
must supply branding through their Node `public` files or an existing external
|
||||
image URL. No automatic compatibility with old relative backend assets is claimed.
|
||||
|
||||
The preserved standalone qualification against public RC1 remains **5 passed,
|
||||
38 failed** out of 43 cases because of missing branding assets. The original
|
||||
staging failure is also retained. The corrected cross-repository pairing has
|
||||
not yet received its browser qualification; neither #1009 nor #734 is closed by
|
||||
focused unit/HTTP results.
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.11",
|
||||
"version": "1.0.0-RC.15",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
@@ -54,12 +54,14 @@
|
||||
"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",
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "15.6.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
+31
-5
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. Raise a cap only with a measured reason in the commit. SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB), so that route's cap is 355 KB.",
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Readable widget widths (feature/widget-width-readability, on PR #50): the gridMinWidth container-query ladder (about 80 @container rules) and the table-widget card layout raise shared CSS from 20.4 KB (PR #50 CI) to 21.1 KB gzip, over the 21 KB cap, so the CSS cap becomes 22 KB (about 4% headroom); JS routes are unchanged. Raise a cap only with a measured reason in the commit. QQQ Assistant panel (2026-10-12): its Tailwind utilities (panel, sheet, markdown typography) add 0.9 KB gzip to the shared CSS (21.3 KB measured against the 21 KB cap); merged with the widget width ladder the shared CSS measured 21.3 KB (2026-10-12 local export), inside the 22 KB cap; the panel's JS is budgeted under lazyChunks.assistant. Assistant panel plus SQL Workbench dark mode (#78, 2026-10-12): main with #78 measured 21.5 KB of shared CSS, and merging the assistant panel measured 22.3 KB (local export), over the 22 KB cap, so the CSS cap becomes 23 KB (about 3% headroom); JS routes and lazy chunks are unchanged. Owner decision 2026-10-12 (\"raise the limits on size as needed, but still keeping things healthy and small as possible\"): totalJsGzipKB is 1100. The total includes every on-demand feature and grows with features; first-load route caps (255/350/360/390), the 23 KB CSS cap and the lazy-chunk caps stay strict because they govern page speed. Any other cap raise still needs a measured reason, and feature PRs keep code-splitting and report their bundle delta.",
|
||||
"firstLoadJsGzipKB": 350,
|
||||
"routes": {
|
||||
"/": 255,
|
||||
@@ -12,13 +12,14 @@
|
||||
"/app/_": 350,
|
||||
"/app/_/_": 350,
|
||||
"/app/_/_/copy": 390,
|
||||
"/app/_/_/dev": 360,
|
||||
"/app/_/_/edit": 350,
|
||||
"/app/_/dev": 360,
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350,
|
||||
"/app/_/_/dev": 355
|
||||
"/app/_/savedView/_": 350
|
||||
},
|
||||
"cssGzipKB": 21,
|
||||
"totalJsGzipKB": 1050,
|
||||
"cssGzipKB": 23,
|
||||
"totalJsGzipKB": 1100,
|
||||
"largestChunkGzipKB": 100,
|
||||
"lazyChunks": {
|
||||
"rapidoc": {
|
||||
@@ -35,6 +36,31 @@
|
||||
"$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
|
||||
},
|
||||
"recordPreview": {
|
||||
"$comment": "The search result record preview (first-tier fields as text, Yes/No and links; quick actions), loaded when a preview first shows: import() in SearchPage. Measured after merging main b32a29d: with FieldValue +17 KB, with the useRecord/useTableMetaData hooks +12.4 KB, with file thumbnails and chips +9.3 KB of total JS, because Turbopack re-bundles those shared modules into more screen chunks; so it runs its own queries (same query keys) and renders values itself. Measured 3.7 KB gzip; budget about 8% above.",
|
||||
"marker": "search-record-preview",
|
||||
"gzipKB": 4.0
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1027
-291
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.11</revision>
|
||||
<revision>1.0.0-RC.15</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -48,6 +48,9 @@ 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'
|
||||
|
||||
/**
|
||||
* Inner layout that renders the full dashboard chrome.
|
||||
@@ -98,8 +101,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 +187,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 +200,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 +228,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
setHelpOpen(true)
|
||||
}
|
||||
}
|
||||
}, [isInputFocused])
|
||||
}, [isInputFocused, assistantEnabled, toggleAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
@@ -254,8 +267,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 +292,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 +303,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 +313,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 +327,14 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
helpButtonRef={helpButtonRef}
|
||||
pathToLabelMap={pathToLabelMap}
|
||||
ancestorAppMap={ancestorAppMap}
|
||||
homePath={homePath}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
assistantButton={<AssistantHeaderButton />}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
@@ -330,17 +353,27 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</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,180 @@
|
||||
/*
|
||||
* 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 { MapPin, SendHorizontal, Square, X } from 'lucide-react'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 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 }: AssistantComposerProps,
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||
) {
|
||||
const [text, setText] = useState('')
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const localRef = useRef<HTMLTextAreaElement | 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>
|
||||
)}
|
||||
<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">
|
||||
<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,124 @@
|
||||
/*
|
||||
* 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, { type Components } from 'react-markdown'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
|
||||
import { internalAppHref } from '@/lib/utils/assistant-links'
|
||||
|
||||
/** The default (GitHub) sanitize schema without inline styles, classes or ids from the model. */
|
||||
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} 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} 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>
|
||||
),
|
||||
}
|
||||
|
||||
/** 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',
|
||||
'[&_th]:border-b [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_td]:border-b [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
|
||||
].join(' ')
|
||||
|
||||
/**
|
||||
* Renders assistant markdown.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.text - Markdown from the model.
|
||||
* @returns The rendered markdown.
|
||||
*/
|
||||
export function AssistantMarkdown({ text }: { text: string }) {
|
||||
return (
|
||||
<div className={PROSE} data-qqq-id="assistant-markdown">
|
||||
<ReactMarkdown skipHtml rehypePlugins={[[rehypeSanitize, SCHEMA]]} components={COMPONENTS}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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-[400px] 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,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* 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 { 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 }) => 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 }>> } = {
|
||||
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 }: { part: AssistantPart }) {
|
||||
const render = (PART_RENDERERS as Record<string, PartRenderer<AssistantPart> | undefined>)[part.type]
|
||||
return render ? <>{render({ part })}</> : 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 }: { message: AssistantMessage }) {
|
||||
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} />)}
|
||||
{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,353 @@
|
||||
/*
|
||||
* 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('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('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,565 @@
|
||||
/*
|
||||
* 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, MoreHorizontal, PanelLeft, Pencil, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { AssistantConversation, AssistantConversationDetail, AssistantConversationStatus, AssistantConversationUpdate } from '@/types/assistant'
|
||||
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 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 context = info && dismissedRoute !== info.context.route ? info.context : null
|
||||
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 title = activeConversationId === null ? 'Assistant' : 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 && (
|
||||
<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">Ask about your data</p>
|
||||
<p className="mt-1">Find records, count and explain them, or ask to be taken to a screen. The assistant sees only what you can see.</p>
|
||||
</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} />)}
|
||||
</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}
|
||||
/>
|
||||
</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,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,309 @@
|
||||
/*
|
||||
* 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 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} />
|
||||
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,123 @@
|
||||
/*
|
||||
* 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('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,183 @@
|
||||
/*
|
||||
* 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 '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,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain text of a message's text parts.
|
||||
*
|
||||
* @param message - A message.
|
||||
* @returns The concatenated text.
|
||||
*/
|
||||
export function messageText(message: AssistantMessage): string {
|
||||
return message.parts.map((part) => (part.type === 'text' ? part.text : '')).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,274 @@
|
||||
/*
|
||||
* 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 DeveloperPanelView — renders one provider's developer panel (QRun-IO/qqq#1076) from its
|
||||
* generic blocks: key/values, text, code, table, links and process actions. No provider-specific
|
||||
* code: any module or qbit's panel renders the same way.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle, ExternalLink, Info, Play } from 'lucide-react'
|
||||
|
||||
import type { DeveloperPanel, DeveloperPanelBlock, DeveloperPanelTone } from '@/lib/api/developer-panels'
|
||||
import type { CodeLanguage } from '@/lib/utils/code-highlight'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { processRunHref } from '@/lib/utils/material-links'
|
||||
import { CodeBlock } from '@/components/scripts/HighlightedCode'
|
||||
import { CopyButton, DeveloperSection } from './DeveloperSection'
|
||||
|
||||
const CODE_LANGUAGES = new Set<CodeLanguage>(['javascript', 'java', 'python', 'json', 'sql', 'velocity', 'html', 'text'])
|
||||
|
||||
/**
|
||||
* A safe link target: app-relative paths and http(s) URLs only.
|
||||
* @param href - Target from the backend.
|
||||
* @returns The target, or null for anything else (e.g. `javascript:`).
|
||||
*/
|
||||
export function safeHref(href: string | undefined): string | null {
|
||||
if (!href) return null
|
||||
if (href.startsWith('/') && !href.startsWith('//')) return href
|
||||
try {
|
||||
const url = new URL(href)
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A link that uses client navigation for app paths and a new tab for other sites.
|
||||
* @param props - Target, label and styling.
|
||||
* @param props.href - Target.
|
||||
* @param props.external - Force a new tab.
|
||||
* @param props.children - Link content.
|
||||
* @param props.className - Extra classes.
|
||||
* @returns The link, or the content alone for an unsafe target.
|
||||
*/
|
||||
export function PanelLink({ href, external, children, className }: { href?: string; external?: boolean; children: React.ReactNode; className?: string }) {
|
||||
const target = safeHref(href)
|
||||
if (!target) return <>{children}</>
|
||||
if (target.startsWith('/') && !external) return <Link href={target} className={cn('text-primary hover:underline', className)}>{children}</Link>
|
||||
return (
|
||||
<a href={target} target="_blank" rel="noopener noreferrer" className={cn('inline-flex items-center gap-1 text-primary hover:underline', className)}>
|
||||
{children}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
<span className="sr-only">(opens in a new tab)</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const TEXT_TONES: Record<DeveloperPanelTone, string> = {
|
||||
INFO: 'border-border bg-muted/50 text-foreground',
|
||||
SUCCESS: 'border-green-600/30 bg-green-600/5 text-foreground',
|
||||
WARNING: 'border-amber-500/40 bg-amber-500/10 text-foreground',
|
||||
ERROR: 'border-destructive/40 bg-destructive/10 text-destructive',
|
||||
}
|
||||
|
||||
/** Props for {@link DeveloperPanelView}. */
|
||||
export interface DeveloperPanelViewProps {
|
||||
panel: DeveloperPanel
|
||||
/** The record's table (default table for process actions). */
|
||||
tableName: string
|
||||
/** The record's primary key (selected record for process actions). */
|
||||
primaryKey: string | number
|
||||
/** Path to return to after a launched process (this page). */
|
||||
returnTo: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a developer panel as a collapsible section.
|
||||
*
|
||||
* @param props - See {@link DeveloperPanelViewProps}.
|
||||
* @returns The panel section.
|
||||
*/
|
||||
export function DeveloperPanelView({ panel, tableName, primaryKey, returnTo }: DeveloperPanelViewProps) {
|
||||
return (
|
||||
<DeveloperSection
|
||||
id={`panel-${panel.id}`}
|
||||
title={panel.title}
|
||||
description={panel.description}
|
||||
badge={panel.error ? { label: 'Error', tone: 'ERROR' } : panel.statusLabel ? { label: panel.statusLabel, tone: panel.statusTone } : undefined}
|
||||
>
|
||||
{panel.error && (
|
||||
<p role="alert" className={cn('flex items-start gap-2 rounded-md border px-3 py-2 text-sm', TEXT_TONES.ERROR)}>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
{panel.error}
|
||||
</p>
|
||||
)}
|
||||
{panel.blocks.map((block, index) => (
|
||||
<PanelBlock key={index} block={block} tableName={tableName} primaryKey={primaryKey} returnTo={returnTo} panelId={panel.id} index={index} />
|
||||
))}
|
||||
</DeveloperSection>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one block.
|
||||
*
|
||||
* @param props - The block and its context.
|
||||
* @returns The block's markup.
|
||||
*/
|
||||
function PanelBlock({ block, tableName, primaryKey, returnTo, panelId, index }: {
|
||||
block: DeveloperPanelBlock
|
||||
tableName: string
|
||||
primaryKey: string | number
|
||||
returnTo: string
|
||||
panelId: string
|
||||
index: number
|
||||
}) {
|
||||
const qqqId = `dev-panel-${panelId}-block-${index}`
|
||||
const heading = block.label ? <h3 className="mb-2 text-sm font-semibold text-foreground">{block.label}</h3> : null
|
||||
|
||||
switch (block.type) {
|
||||
case 'TEXT':
|
||||
return (
|
||||
<div className={cn('flex items-start gap-2 rounded-md border px-3 py-2 text-sm', TEXT_TONES[block.tone ?? 'INFO'])} data-qqq-id={qqqId}>
|
||||
{block.tone === 'WARNING' || block.tone === 'ERROR'
|
||||
? <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
: <Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
<div className="min-w-0 whitespace-pre-line break-words">{block.label ? <strong>{block.label}: </strong> : null}{block.text}</div>
|
||||
</div>
|
||||
)
|
||||
case 'KEY_VALUES':
|
||||
return (
|
||||
<div data-qqq-id={qqqId}>
|
||||
{heading}
|
||||
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-[minmax(8rem,max-content)_1fr]">
|
||||
{(block.items ?? []).map((item, itemIndex) => (
|
||||
<React.Fragment key={itemIndex}>
|
||||
<dt className="text-xs font-medium text-muted-foreground sm:pt-0.5">{item.label}</dt>
|
||||
<dd className={cn('m-0 min-w-0 break-words text-sm text-foreground [overflow-wrap:anywhere]', item.monospace && 'font-mono text-xs')}>
|
||||
{item.value == null || item.value === ''
|
||||
? <span className="italic text-muted-foreground">—</span>
|
||||
: <PanelLink href={item.href}>{item.value}</PanelLink>}
|
||||
</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
case 'CODE': {
|
||||
const language = CODE_LANGUAGES.has(block.language as CodeLanguage) ? block.language as CodeLanguage : 'text'
|
||||
const code = block.code ?? ''
|
||||
return (
|
||||
<div data-qqq-id={qqqId}>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
{block.label ? <h3 className="m-0 text-sm font-semibold text-foreground">{block.label}</h3> : <span />}
|
||||
<CopyButton text={code} label={`Copy ${block.label ?? 'code'}`} />
|
||||
</div>
|
||||
<CodeBlock code={code} language={language} className="max-h-96" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'TABLE':
|
||||
return (
|
||||
<div data-qqq-id={qqqId}>
|
||||
{heading}
|
||||
<PanelTable columns={block.columns ?? []} rows={block.rows ?? []} />
|
||||
</div>
|
||||
)
|
||||
case 'LINKS':
|
||||
return (
|
||||
<div data-qqq-id={qqqId}>
|
||||
{heading}
|
||||
<ul className="m-0 list-none space-y-1.5 p-0">
|
||||
{(block.links ?? []).map((link, linkIndex) => (
|
||||
<li key={linkIndex} className="text-sm">
|
||||
<PanelLink href={link.href} external={link.external}>{link.label}</PanelLink>
|
||||
{link.description && <span className="text-muted-foreground"> — {link.description}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
case 'ACTIONS':
|
||||
return (
|
||||
<div data-qqq-id={qqqId}>
|
||||
{heading}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(block.actions ?? []).map((action, actionIndex) => (
|
||||
<Link
|
||||
key={actionIndex}
|
||||
href={processRunHref(action.processName, {
|
||||
recordId: action.includeRecordId === false ? undefined : primaryKey,
|
||||
returnTo,
|
||||
tableName: action.tableName ?? tableName,
|
||||
})}
|
||||
title={action.description}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-input bg-card px-3 py-2 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id={`dev-panel-action-${action.processName}`}
|
||||
>
|
||||
<Play className="h-4 w-4" aria-hidden="true" />
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A responsive table: a real table from the small breakpoint up, stacked label/value cards on
|
||||
* phones (no sideways scrolling at 360px).
|
||||
*
|
||||
* @param props - Columns and rows of display strings.
|
||||
* @param props.columns - Column headings.
|
||||
* @param props.rows - Rows.
|
||||
* @returns The table.
|
||||
*/
|
||||
export function PanelTable({ columns, rows }: { columns: string[]; rows: string[][] }) {
|
||||
if (rows.length === 0) return <p className="text-sm italic text-muted-foreground">None.</p>
|
||||
return (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
<tr>
|
||||
{columns.map((column) => <th key={column} scope="col" className="whitespace-nowrap px-3 py-2 text-left text-xs font-semibold text-foreground">{column}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t border-border align-top">
|
||||
{columns.map((column, columnIndex) => (
|
||||
<td key={column} className="max-w-[28rem] break-words px-3 py-2 text-xs text-foreground [overflow-wrap:anywhere]">{row[columnIndex] ?? ''}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul className="m-0 list-none space-y-2 p-0 sm:hidden">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<li key={rowIndex} className="rounded-lg border border-border p-3">
|
||||
<dl className="m-0 space-y-1">
|
||||
{columns.map((column, columnIndex) => (
|
||||
<div key={column} className="flex gap-2 text-xs">
|
||||
<dt className="w-28 flex-shrink-0 font-medium text-muted-foreground">{column}</dt>
|
||||
<dd className="m-0 min-w-0 break-words text-foreground [overflow-wrap:anywhere]">{row[columnIndex] ?? ''}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* 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 Building blocks of the record developer view: an anchored, collapsible section card, the
|
||||
* sticky section navigation, a status badge and a copy-to-clipboard button.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ChevronRight, ClipboardCopy } from 'lucide-react'
|
||||
|
||||
import type { DeveloperPanelTone } from '@/lib/api/developer-panels'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/**
|
||||
* DOM id of a developer section's anchor.
|
||||
* @param id - The section's id.
|
||||
* @returns The element id.
|
||||
*/
|
||||
export function developerSectionId(id: string): string {
|
||||
return `dev-section-${id.replace(/[^A-Za-z0-9_-]/g, '-')}`
|
||||
}
|
||||
|
||||
/** Props for {@link DeveloperSection}. */
|
||||
export interface DeveloperSectionProps {
|
||||
/** Stable id (anchor and nav target). */
|
||||
id: string
|
||||
/** Heading. */
|
||||
title: string
|
||||
/** Optional one-line description under the heading. */
|
||||
description?: string
|
||||
/** Optional status badge beside the heading. */
|
||||
badge?: { label: string; tone?: DeveloperPanelTone }
|
||||
/** Starts collapsed. */
|
||||
defaultCollapsed?: boolean
|
||||
/** Controls beside the heading (shown while expanded). */
|
||||
actions?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled, collapsible card with a scroll anchor; the heading button toggles the body.
|
||||
*
|
||||
* @param props - See {@link DeveloperSectionProps}.
|
||||
* @returns The section.
|
||||
*/
|
||||
export function DeveloperSection({ id, title, description, badge, defaultCollapsed = false, actions, children }: DeveloperSectionProps) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
const bodyId = `${developerSectionId(id)}-body`
|
||||
return (
|
||||
<section
|
||||
id={developerSectionId(id)}
|
||||
aria-labelledby={`${developerSectionId(id)}-title`}
|
||||
className="scroll-mt-24 rounded-xl border border-border bg-card"
|
||||
data-qqq-id={`dev-section-${id}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<h2 className="m-0 min-w-0 flex-1" id={`${developerSectionId(id)}-title`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={bodyId}
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-md text-left text-base font-semibold text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id={`dev-section-toggle-${id}`}
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-4 w-4 flex-shrink-0" aria-hidden="true" /> : <ChevronDown className="h-4 w-4 flex-shrink-0" aria-hidden="true" />}
|
||||
<span className="min-w-0 break-words">{title}</span>
|
||||
{badge && <StatusBadge label={badge.label} tone={badge.tone} />}
|
||||
</button>
|
||||
</h2>
|
||||
{!collapsed && actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div id={bodyId} className="min-w-0 space-y-4 border-t border-border px-4 py-4">
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const TONE_CLASSES: Record<DeveloperPanelTone, string> = {
|
||||
INFO: 'border-border bg-muted text-muted-foreground',
|
||||
SUCCESS: 'border-green-600/30 bg-green-600/10 text-green-700 dark:text-green-400',
|
||||
WARNING: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400',
|
||||
ERROR: 'border-destructive/40 bg-destructive/10 text-destructive',
|
||||
}
|
||||
|
||||
/**
|
||||
* A small status pill.
|
||||
*
|
||||
* @param props - Label and tone.
|
||||
* @param props.label - Text.
|
||||
* @param props.tone - Color tone (INFO by default).
|
||||
* @returns The badge.
|
||||
*/
|
||||
export function StatusBadge({ label, tone = 'INFO' }: { label: string; tone?: DeveloperPanelTone }) {
|
||||
return (
|
||||
<span className={cn('inline-flex flex-shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium', TONE_CLASSES[tone])} data-qqq-id="dev-status-badge">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A button that copies text to the clipboard and briefly shows a check mark.
|
||||
*
|
||||
* @param props - Text and accessible label.
|
||||
* @param props.text - What to copy.
|
||||
* @param props.label - Accessible label (default "Copy").
|
||||
* @param props.caption - Visible text (default "Copy"); set it when a row has more than one copy button.
|
||||
* @returns The button.
|
||||
*/
|
||||
export function CopyButton({ text, label = 'Copy', caption = 'Copy' }: { text: string; label?: string; caption?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const timer = setTimeout(() => setCopied(false), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [copied])
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(text).then(() => setCopied(true), () => setCopied(false))
|
||||
}}
|
||||
aria-label={copied ? `${label}: copied` : label}
|
||||
title={label}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-input bg-card px-2 py-1 text-xs font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="button-dev-copy"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-600" aria-hidden="true" /> : <ClipboardCopy className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{copied ? 'Copied' : caption}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** One entry of the section navigation. */
|
||||
export interface DeveloperNavItem {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky navigation to the page's sections; highlights the section nearest the top of the
|
||||
* viewport. Phones get a section picker, wider screens a wrapping row of links, so the bar
|
||||
* never scrolls sideways.
|
||||
*
|
||||
* @param props - The sections.
|
||||
* @param props.items - Sections in page order.
|
||||
* @returns The navigation bar.
|
||||
*/
|
||||
export function DeveloperSectionNav({ items }: { items: DeveloperNavItem[] }) {
|
||||
const [active, setActive] = useState<string | null>(items[0]?.id ?? null)
|
||||
// the section last picked from this bar: at the bottom of the page it stays highlighted
|
||||
// while it is in view, since the last sections can never reach the top
|
||||
const chosen = useRef<string | null>(null)
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
const ids = items.map((item) => item.id).join('|')
|
||||
|
||||
useEffect(() => {
|
||||
const sectionIds = ids.split('|')
|
||||
const scroller = navRef.current?.closest<HTMLElement>('[data-qqq-id="main-content"]') ?? null
|
||||
let frame = 0
|
||||
const update = () => {
|
||||
frame = 0
|
||||
// a section is current once its top passes below the bar, or reaches where a jump puts it
|
||||
// (its scroll-mt-24 margin: 96px)
|
||||
const top = scroller ? scroller.getBoundingClientRect().top : 0
|
||||
const line = Math.max(navRef.current?.getBoundingClientRect().bottom ?? 0, top + 96) + 8
|
||||
const viewBottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight
|
||||
const atBottom = scroller
|
||||
? scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2
|
||||
: window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2
|
||||
let current: string | null = sectionIds[0] ?? null
|
||||
for (const id of sectionIds) {
|
||||
const sectionTop = document.getElementById(developerSectionId(id))?.getBoundingClientRect().top
|
||||
if (sectionTop !== undefined && sectionTop <= line) current = id
|
||||
}
|
||||
if (atBottom) {
|
||||
const pick = chosen.current
|
||||
const pickTop = pick ? document.getElementById(developerSectionId(pick))?.getBoundingClientRect().top : undefined
|
||||
current = pick && pickTop !== undefined && pickTop < viewBottom ? pick : sectionIds[sectionIds.length - 1] ?? current
|
||||
}
|
||||
setActive(current)
|
||||
}
|
||||
const onScroll = () => {
|
||||
if (!frame) frame = requestAnimationFrame(update)
|
||||
}
|
||||
const target: HTMLElement | Window = scroller ?? window
|
||||
target.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
target.removeEventListener('scroll', onScroll)
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [ids])
|
||||
|
||||
const go = (id: string) => {
|
||||
chosen.current = id
|
||||
setActive(id)
|
||||
document.getElementById(developerSectionId(id))?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label="Developer view sections"
|
||||
className="sticky -top-6 z-10 -mx-1 border-b border-border bg-background/95 px-1 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
data-qqq-id="dev-section-nav"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground sm:hidden">
|
||||
<span className="flex-shrink-0">Go to</span>
|
||||
<select
|
||||
value={active ?? ''}
|
||||
onChange={(event) => go(event.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-input bg-card px-2 py-1.5 text-sm font-medium text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="dev-nav-select"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ul className="m-0 hidden list-none flex-wrap gap-1 p-0 sm:flex">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex-shrink-0">
|
||||
<a
|
||||
href={`#${developerSectionId(item.id)}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
go(item.id)
|
||||
}}
|
||||
aria-current={active === item.id ? 'location' : undefined}
|
||||
className={cn(
|
||||
'block whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active === item.id ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
data-qqq-id={`dev-nav-${item.id}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* 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 RecordApiAccess — the record developer view's "API Access": the requests a developer can
|
||||
* make for this record through the v1 data API (the UI's own routes, session cookie) and through
|
||||
* each application API that exposes the table (from its OpenAPI spec, token auth), limited to
|
||||
* what the current user may do, with copyable curl, fetch and Java examples built from the
|
||||
* record's values and links to the API's docs and specs. Credentials are placeholders only.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { QProcessMetaData, QRecord, QTableMetaData } from '@/types'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { apiMiddlewareBaseURL, apiTableSpecUrl, getApiTableSpec, getApiVersions, getTableApis, type ApiInstance } from '@/lib/api/developer'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { API_KEY_ENV, SESSION_PLACEHOLDER, TOKEN_ENV, buildSnippet, specOperations, v1Operations, type ApiAuth, type ApiOperation, type SnippetLanguage } from '@/lib/utils/api-examples'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { CodeBlock } from '@/components/scripts/HighlightedCode'
|
||||
import { CopyButton } from './DeveloperSection'
|
||||
import { PanelLink } from './DeveloperPanelView'
|
||||
|
||||
const LANGUAGE_KEY = 'qqq.recordDeveloperView.snippetLanguage'
|
||||
const LANGUAGES: Array<{ id: SnippetLanguage; label: string }> = [
|
||||
{ id: 'curl', label: 'curl' },
|
||||
{ id: 'fetch', label: 'JavaScript (fetch)' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Reads the remembered snippet language.
|
||||
* @returns The language, curl by default.
|
||||
*/
|
||||
function storedLanguage(): SnippetLanguage {
|
||||
try {
|
||||
const value = window.localStorage.getItem(LANGUAGE_KEY)
|
||||
return value === 'fetch' || value === 'java' ? value : 'curl'
|
||||
} catch {
|
||||
return 'curl'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL of the v1 data API the UI uses.
|
||||
* @returns e.g. `https://admin.example.com/qqq/v1`.
|
||||
*/
|
||||
function v1BaseUrl(): string {
|
||||
const configured = apiClient.getInstance().defaults.baseURL ?? '/qqq/v1'
|
||||
const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin
|
||||
return new URL(configured, origin).toString().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL of an application API path.
|
||||
* @param path - Root-relative path.
|
||||
* @returns The absolute URL.
|
||||
*/
|
||||
function middlewareUrl(path: string): string {
|
||||
const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin
|
||||
return new URL(`${apiMiddlewareBaseURL() ?? ''}${path}`, origin).toString()
|
||||
}
|
||||
|
||||
/** Props for {@link RecordApiAccess}. */
|
||||
export interface RecordApiAccessProps {
|
||||
table: QTableMetaData
|
||||
record: QRecord
|
||||
/** Processes the record view offers (permission-filtered). */
|
||||
processes: QProcessMetaData[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The API Access section body.
|
||||
*
|
||||
* @param props - See {@link RecordApiAccessProps}.
|
||||
* @returns Source tabs, the language picker and the operations.
|
||||
*/
|
||||
export function RecordApiAccess({ table, record, processes }: RecordApiAccessProps) {
|
||||
const [language, setLanguageState] = useState<SnippetLanguage>(() => (typeof window === 'undefined' ? 'curl' : storedLanguage()))
|
||||
const [source, setSource] = useState<string>('v1')
|
||||
|
||||
const setLanguage = (value: SnippetLanguage) => {
|
||||
setLanguageState(value)
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_KEY, value)
|
||||
} catch {
|
||||
// storage unavailable: the choice is simply not remembered
|
||||
}
|
||||
}
|
||||
|
||||
const apisQuery = useQuery({
|
||||
queryKey: queryKeys.tableApis(table.name),
|
||||
queryFn: () => getTableApis(table.name),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
retry: false,
|
||||
})
|
||||
const apis = apisQuery.data ?? []
|
||||
const selectedApi = apis.find((api) => api.name === source)
|
||||
|
||||
const v1 = useMemo(() => v1Operations({ baseUrl: v1BaseUrl(), table, record, processes }), [table, record, processes])
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-qqq-id="dev-api-access">
|
||||
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="API">
|
||||
<SourceTab id="v1" label="QQQ data API (v1)" selected={source === 'v1'} onSelect={setSource} />
|
||||
{apis.map((api) => <SourceTab key={api.name} id={api.name} label={api.label} selected={source === api.name} onSelect={setSource} />)}
|
||||
{apisQuery.isLoading && <span role="status" className="text-xs text-muted-foreground">Looking for application APIs…</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span id="dev-snippet-language" className="text-xs font-medium text-muted-foreground">Examples in</span>
|
||||
<div className="flex rounded-lg border border-border bg-muted/50 p-0.5" role="radiogroup" aria-labelledby="dev-snippet-language">
|
||||
{LANGUAGES.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={language === option.id}
|
||||
onClick={() => setLanguage(option.id)}
|
||||
className={cn('rounded-md px-2.5 py-1 text-xs font-medium', language === option.id ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')}
|
||||
data-qqq-id={`dev-snippet-language-${option.id}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedApi
|
||||
? <ApplicationApiOperations api={selectedApi} table={table} record={record} language={language} />
|
||||
: (
|
||||
<div role="tabpanel" className="space-y-3">
|
||||
<AuthNote auth="session" />
|
||||
<OperationList operations={v1} auth="session" language={language} empty="You have no permission to read or change records in this table." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One API source tab.
|
||||
* @param props - Tab state.
|
||||
* @returns The tab button.
|
||||
*/
|
||||
function SourceTab({ id, label, selected, onSelect }: { id: string; label: string; selected: boolean; onSelect: (id: string) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onSelect(id)}
|
||||
className={cn('rounded-md border px-3 py-1.5 text-sm font-medium', selected ? 'border-primary bg-primary/10 text-primary' : 'border-input bg-card text-foreground hover:bg-accent')}
|
||||
data-qqq-id={`dev-api-source-${id}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Explains the placeholder credentials of a source.
|
||||
* @param props - The auth kind.
|
||||
* @param props.auth - How requests authenticate.
|
||||
* @returns The note.
|
||||
*/
|
||||
function AuthNote({ auth }: { auth: ApiAuth }) {
|
||||
const text = auth === 'session'
|
||||
? <>These are the routes this screen uses. From a browser on this site they use your session cookie; elsewhere, send the <code className="font-mono">sessionUUID</code> cookie (shown as <code className="font-mono">{SESSION_PLACEHOLDER}</code>).</>
|
||||
: auth === 'apiKey'
|
||||
? <>Authenticate with an API key in the <code className="font-mono">x-api-key</code> header (shown as <code className="font-mono">${API_KEY_ENV}</code>).</>
|
||||
: <>Authenticate with a bearer token (shown as <code className="font-mono">${TOKEN_ENV}</code>).</>
|
||||
return <p className="text-xs text-muted-foreground">{text} Only the operations you are allowed to perform are listed.</p>
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations of an application API version that exposes the table, read from its OpenAPI spec.
|
||||
* @param props - The API, record and snippet language.
|
||||
* @returns Version picker, doc links and operations.
|
||||
*/
|
||||
function ApplicationApiOperations({ api, table, record, language }: { api: ApiInstance; table: QTableMetaData; record: QRecord; language: SnippetLanguage }) {
|
||||
const [versionChoice, setVersionChoice] = useState<string | null>(null)
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: queryKeys.apiVersions(api.path),
|
||||
queryFn: () => getApiVersions(api.path),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
retry: false,
|
||||
})
|
||||
const versions = versionsQuery.data?.supportedVersions ?? []
|
||||
const version = versionChoice && versions.includes(versionChoice) ? versionChoice : versionsQuery.data?.currentVersion ?? versions[0]
|
||||
|
||||
const specQuery = useQuery({
|
||||
queryKey: queryKeys.apiTableSpec(api.path, version ?? '', table.name),
|
||||
queryFn: () => getApiTableSpec(api, version!, table.name),
|
||||
enabled: Boolean(version),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
retry: false,
|
||||
})
|
||||
const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin
|
||||
const parsed = useMemo(() => specQuery.data ? specOperations({ spec: specQuery.data, origin, table, record }) : null, [specQuery.data, origin, table, record])
|
||||
|
||||
return (
|
||||
<div role="tabpanel" className="space-y-3" data-qqq-id={`dev-api-${api.name}`}>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||
{versions.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
Version
|
||||
<select
|
||||
value={version}
|
||||
onChange={(event) => setVersionChoice(event.target.value)}
|
||||
className="rounded border border-input bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
data-qqq-id="dev-api-version"
|
||||
>
|
||||
{versions.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{version && (
|
||||
<>
|
||||
<PanelLink href={middlewareUrl(`${api.path}${version}/`)} external>API docs</PanelLink>
|
||||
<PanelLink href={middlewareUrl(`${api.path}${version}/openapi.json`)} external>OpenAPI spec</PanelLink>
|
||||
<PanelLink href={new URL(apiTableSpecUrl(api, version, table.name), origin).toString()} external>{table.label} spec</PanelLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(versionsQuery.isLoading || specQuery.isLoading) && <p role="status" className="text-sm text-muted-foreground">Loading the API specification…</p>}
|
||||
{(versionsQuery.isError || specQuery.isError) && (
|
||||
<p role="alert" className="text-sm text-destructive">The API specification could not be loaded: {getErrorMessage(versionsQuery.error ?? specQuery.error)}</p>
|
||||
)}
|
||||
{parsed && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">Base URL <code className="break-all font-mono">{parsed.serverUrl}</code></p>
|
||||
<AuthNote auth={parsed.auth} />
|
||||
<OperationList operations={parsed.operations} auth={parsed.auth} language={language} empty="This API version offers no operations on this table that you may perform." />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const METHOD_CLASSES: Record<ApiOperation['method'], string> = {
|
||||
GET: 'bg-sky-600/10 text-sky-700 dark:text-sky-400',
|
||||
POST: 'bg-green-600/10 text-green-700 dark:text-green-400',
|
||||
PUT: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
|
||||
PATCH: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
|
||||
DELETE: 'bg-destructive/10 text-destructive',
|
||||
}
|
||||
|
||||
/**
|
||||
* The operations, each expandable to its snippet.
|
||||
* @param props - Operations, auth, language and empty text.
|
||||
* @returns The list.
|
||||
*/
|
||||
function OperationList({ operations, auth, language, empty }: { operations: ApiOperation[]; auth: ApiAuth; language: SnippetLanguage; empty: string }) {
|
||||
if (operations.length === 0) return <p className="text-sm italic text-muted-foreground">{empty}</p>
|
||||
return (
|
||||
<ul className="m-0 list-none space-y-2 p-0">
|
||||
{operations.map((operation, index) => {
|
||||
const snippet = buildSnippet(operation, auth, language)
|
||||
return (
|
||||
<li key={operation.id} className="rounded-lg border border-border">
|
||||
<details open={index === 0} className="group">
|
||||
<summary className="flex cursor-pointer list-none flex-wrap items-center gap-2 px-3 py-2 marker:hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-qqq-id={`dev-api-op-${operation.id}`}>
|
||||
<span className={cn('rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold', METHOD_CLASSES[operation.method])}>{operation.method}</span>
|
||||
<span className="text-sm font-medium text-foreground">{operation.label}</span>
|
||||
<code className="min-w-0 basis-full break-all font-mono text-xs text-muted-foreground sm:basis-auto">{operation.url}</code>
|
||||
</summary>
|
||||
<div className="space-y-2 border-t border-border px-3 py-3">
|
||||
{operation.description && <p className="text-xs text-muted-foreground">{operation.description}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<CopyButton text={operation.url} label="Copy URL" caption="Copy URL" />
|
||||
<CopyButton text={snippet} label={`Copy ${language} example`} caption="Copy example" />
|
||||
</div>
|
||||
<CodeBlock code={snippet} language={language === 'java' ? 'java' : language === 'fetch' ? 'javascript' : 'text'} className="max-h-96" dataQqqId={`dev-api-snippet-${operation.id}`} />
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file RecordDeveloperView — the record developer view (`/app/[slug]/[recordId]/dev`, loaded on
|
||||
* demand by its page so the route's first load stays small): the record screen's header and
|
||||
* actions, then a sticky section navigation over the record's fields (display and raw values),
|
||||
* API access (endpoints with curl, fetch and Java examples), the panels that developer panel
|
||||
* providers contribute (record security, ESB, quick search, SQL workbench, assistant, ... — each
|
||||
* only when its module is installed and active for the table), relations, recent audits,
|
||||
* associated scripts, raw JSON and a searchable view of the table metadata.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Code } from 'lucide-react'
|
||||
|
||||
import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { SCRIPT_LOG_TABLE, getRecordDeveloperData } from '@/lib/api/developer'
|
||||
import { getRecordDeveloperPanels } from '@/lib/api/developer-panels'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { auditSource } from '@/lib/api/audits'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { getProcessesForRecordView, getRecordActionProcesses } from '@/lib/utils/process-utils'
|
||||
import { isSafeRedirectPath } from '@/lib/utils/string-utils'
|
||||
import { AssociatedScriptViewer } from '@/components/records/AssociatedScriptViewer'
|
||||
import { RecordViewHeader } from '@/components/records/RecordViewHeader'
|
||||
import { scriptLogsQueryHref } from '@/components/scripts/script-utils'
|
||||
import { CodeBlock } from '@/components/scripts/HighlightedCode'
|
||||
import { CopyButton, DeveloperSection, DeveloperSectionNav, type DeveloperNavItem } from '@/components/developer/DeveloperSection'
|
||||
import { DeveloperPanelView } from '@/components/developer/DeveloperPanelView'
|
||||
import { RecordApiAccess } from '@/components/developer/RecordApiAccess'
|
||||
import { RecordFieldValues } from '@/components/developer/RecordFieldValues'
|
||||
import { RecentAuditSummary, RecordRelations, hasRelations } from '@/components/developer/RecordRelations'
|
||||
import { TableMetadataExplorer } from '@/components/developer/TableMetadataExplorer'
|
||||
|
||||
/**
|
||||
* The view-mode setter the shared header needs; this screen has no card/list modes.
|
||||
* @returns Nothing.
|
||||
*/
|
||||
const NO_VIEW_MODE = () => undefined
|
||||
|
||||
/**
|
||||
* Renders the developer view of the record named by the route.
|
||||
*
|
||||
* @returns The page: loading and error states, then the record header and the developer sections.
|
||||
*/
|
||||
export function RecordDeveloperView() {
|
||||
const params = useRouteParams<{ slug: string; recordId: string }>()
|
||||
const { setPageHeader, setTableMetaData } = useQContext()
|
||||
const { slug, recordId } = params
|
||||
const queryClient = useQueryClient()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const { data: instance } = useMetaData()
|
||||
const { data: table, isLoading: tableLoading, error: tableError } = useQuery({
|
||||
queryKey: queryKeys.tableMetadata(slug),
|
||||
queryFn: () => loadTableMetaData(slug),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
// The developer-mode route returns the record (with display values) and its 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
|
||||
const panelsQuery = useQuery({
|
||||
queryKey: queryKeys.recordDeveloperPanels(slug, recordId),
|
||||
queryFn: () => getRecordDeveloperPanels(slug, recordId),
|
||||
enabled: Boolean(record),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
retry: false,
|
||||
})
|
||||
const panels = useMemo(() => panelsQuery.data ?? [], [panelsQuery.data])
|
||||
|
||||
const recordLabel = record?.recordLabel || (table ? `${table.label} #${recordId}` : `${slug} #${recordId}`)
|
||||
useEffect(() => {
|
||||
setPageHeader(`${recordLabel} Developer Mode`)
|
||||
if (table) setTableMetaData(table)
|
||||
}, [recordLabel, table, setPageHeader, setTableMetaData])
|
||||
|
||||
const reloadRecord = useCallback(() => Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.recordDeveloper(slug, recordId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(slug, recordId) }),
|
||||
]), [queryClient, slug, recordId])
|
||||
|
||||
const processes = useMemo(() => (instance ? getProcessesForRecordView(instance, slug) : []), [instance, slug])
|
||||
const actionProcesses = useMemo(() => getRecordActionProcesses(processes, slug), [processes, slug])
|
||||
const audits = auditSource(instance)
|
||||
|
||||
// Back goes to the record screen (or to where the user came from on it, when that is the record screen).
|
||||
const fromPath = searchParams.get('from')
|
||||
const recordPath = `/app/${encodeURIComponent(slug)}/${encodeURIComponent(recordId)}`
|
||||
const backHref = fromPath && isSafeRedirectPath(fromPath) && fromPath.startsWith(recordPath) ? fromPath : recordPath
|
||||
const navigateFrom = useMemo(() => ({ path: pathname, label: `${recordLabel} (developer)` }), [pathname, recordLabel])
|
||||
|
||||
if (recordLoading || tableLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" role="status" aria-live="polite" aria-busy="true">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<span className="sr-only">Loading developer view</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (recordError || tableError || !record || !table) {
|
||||
return (
|
||||
<div className="space-y-4" data-qqq-id={`record-dev-${slug}-${recordId}`}>
|
||||
<BackLink href={backHref} label={recordLabel} />
|
||||
<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 ?? tableError ?? new Error('Record not found'))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const primaryKey = (record.values[table.primaryKeyField] ?? recordId) as string | number
|
||||
const showRelations = hasRelations(table, record, instance?.tables)
|
||||
const associatedScripts = developerData?.associatedScripts ?? []
|
||||
const logsViewAllHref = instance?.tables?.[SCRIPT_LOG_TABLE] ? scriptLogsQueryHref : undefined
|
||||
const rawJson = JSON.stringify(record.values, null, 2)
|
||||
|
||||
const navItems: DeveloperNavItem[] = [
|
||||
{ id: 'fields', label: 'Fields' },
|
||||
{ id: 'api', label: 'API' },
|
||||
...panels.map((panel) => ({ id: `panel-${panel.id}`, label: panel.title })),
|
||||
...(showRelations ? [{ id: 'relations', label: 'Relations' }] : []),
|
||||
...(audits ? [{ id: 'audit', label: 'Audit' }] : []),
|
||||
...(associatedScripts.length ? [{ id: 'scripts', label: 'Scripts' }] : []),
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'metadata', label: 'Table Metadata' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4" data-qqq-id={`record-dev-${slug}-${recordId}`}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<BackLink href={backHref} label={recordLabel} />
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground" data-qqq-id="record-dev-badge">
|
||||
<Code className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Developer view · <span className="font-mono">{table.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RecordViewHeader
|
||||
tableMetaData={table}
|
||||
record={record}
|
||||
t1Fields={[]}
|
||||
viewMode="tabs"
|
||||
setViewMode={NO_VIEW_MODE}
|
||||
showViewModeToggle={false}
|
||||
hideActions={false}
|
||||
processes={processes}
|
||||
allProcesses={instance?.processes}
|
||||
allTables={instance?.tables}
|
||||
navigateFrom={navigateFrom}
|
||||
auditSource={audits}
|
||||
widgetMetaDataMap={instance?.widgets}
|
||||
onRecordChanged={reloadRecord}
|
||||
/>
|
||||
|
||||
<DeveloperSectionNav items={navItems} />
|
||||
|
||||
<DeveloperSection id="fields" title="Fields" description="Each field's value as the record screen shows it, and its raw value.">
|
||||
<RecordFieldValues table={table} record={record} allTables={instance?.tables} widgetMetaDataMap={instance?.widgets} navigateFrom={navigateFrom} />
|
||||
</DeveloperSection>
|
||||
|
||||
<DeveloperSection id="api" title="API Access" description="Requests you can make for this record, with examples built from its values.">
|
||||
<RecordApiAccess table={table} record={record} processes={actionProcesses} />
|
||||
</DeveloperSection>
|
||||
|
||||
{panelsQuery.isLoading && <p role="status" className="text-sm text-muted-foreground">Loading developer panels…</p>}
|
||||
{panelsQuery.isError && (
|
||||
<p role="alert" className="rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
Developer panels could not be loaded: {getErrorMessage(panelsQuery.error)}
|
||||
</p>
|
||||
)}
|
||||
{panels.map((panel) => (
|
||||
<DeveloperPanelView key={panel.id} panel={panel} tableName={table.name} primaryKey={primaryKey} returnTo={pathname} />
|
||||
))}
|
||||
|
||||
{showRelations && (
|
||||
<DeveloperSection id="relations" title="Relations" description="Records this record references, its associations and the joins exposed on its table.">
|
||||
<RecordRelations table={table} record={record} allTables={instance?.tables} />
|
||||
</DeveloperSection>
|
||||
)}
|
||||
|
||||
{audits && (
|
||||
<DeveloperSection id="audit" title="Audit" description="The record's most recent changes.">
|
||||
<RecentAuditSummary source={audits} table={table} primaryKey={primaryKey} recordLabel={recordLabel} />
|
||||
</DeveloperSection>
|
||||
)}
|
||||
|
||||
{associatedScripts.length > 0 && (
|
||||
<DeveloperSection id="scripts" title="Associated Scripts">
|
||||
{associatedScripts.map((associated) => {
|
||||
const fieldName = associated.associatedScript.fieldName
|
||||
const scriptId = record.values[fieldName]
|
||||
return (
|
||||
<AssociatedScriptViewer
|
||||
key={fieldName}
|
||||
tableName={slug}
|
||||
recordId={recordId}
|
||||
fieldLabel={table.fields[fieldName]?.label ?? fieldName}
|
||||
data={associated}
|
||||
scriptId={typeof scriptId === 'string' || typeof scriptId === 'number' ? scriptId : null}
|
||||
canCreate={Boolean(table.editPermission)}
|
||||
canEdit={Boolean(instance?.processes?.storeScriptRevision)}
|
||||
canTest={Boolean(instance?.processes?.testScript)}
|
||||
logsViewAllHref={logsViewAllHref}
|
||||
onChanged={reloadRecord}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DeveloperSection>
|
||||
)}
|
||||
|
||||
<DeveloperSection id="json" title="Record Raw Values as JSON" actions={<CopyButton text={rawJson} label="Copy record JSON" />}>
|
||||
<CodeBlock code={rawJson} language="json" className="max-h-[32rem]" dataQqqId="json-output" />
|
||||
</DeveloperSection>
|
||||
|
||||
<DeveloperSection id="metadata" title="Table Metadata" defaultCollapsed>
|
||||
<TableMetadataExplorer table={table} />
|
||||
</DeveloperSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Link back to the record screen.
|
||||
*
|
||||
* @param props - Target and record label.
|
||||
* @param props.href - The record screen path.
|
||||
* @param props.label - The record's label.
|
||||
* @returns The link.
|
||||
*/
|
||||
function BackLink({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex items-center gap-1.5 rounded-md text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="record-dev-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to {label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 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 RecordFieldValues — every field of the record on the developer view: label and name, type
|
||||
* (with possible-value source and adornments), the value as the record screen shows it, and the
|
||||
* raw value. A filter box narrows the list; phones get stacked cards instead of a wide table.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
import type { QFieldMetaData, QRecord, QTableMetaData, QWidgetMetaData } from '@/types'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
|
||||
/** Props for {@link RecordFieldValues}. */
|
||||
export interface RecordFieldValuesProps {
|
||||
table: QTableMetaData
|
||||
record: QRecord
|
||||
allTables?: Record<string, QTableMetaData>
|
||||
widgetMetaDataMap?: Record<string, QWidgetMetaData>
|
||||
navigateFrom: { path: string; label: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a raw value as JSON text (strings unquoted).
|
||||
* @param value - Raw value.
|
||||
* @returns Display text.
|
||||
*/
|
||||
export function rawText(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'null'
|
||||
if (typeof value === 'string') return value
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a field's type extras: possible-value source and adornments.
|
||||
* @param field - The field.
|
||||
* @returns Short tags.
|
||||
*/
|
||||
export function fieldTags(field: QFieldMetaData): string[] {
|
||||
const tags: string[] = []
|
||||
if (field.possibleValueSourceName) tags.push(`PVS ${field.possibleValueSourceName}`)
|
||||
else if (field.inlinePossibleValueSource) tags.push('inline PVS')
|
||||
for (const adornment of field.adornments ?? []) tags.push(adornment.type)
|
||||
if (field.isRequired) tags.push('required')
|
||||
if (!field.isEditable) tags.push('read-only')
|
||||
if (field.isHidden) tags.push('hidden')
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* The field list.
|
||||
*
|
||||
* @param props - See {@link RecordFieldValuesProps}.
|
||||
* @returns Filter box, table (sm and up) and cards (phones).
|
||||
*/
|
||||
export function RecordFieldValues({ table, record, allTables, widgetMetaDataMap, navigateFrom }: RecordFieldValuesProps) {
|
||||
const [filter, setFilter] = useState('')
|
||||
// the table's fields in metadata order, then any value the record carries without a field (joins)
|
||||
const names = useMemo(() => [...new Set([...Object.keys(table.fields ?? {}), ...Object.keys(record.values)])], [table.fields, record.values])
|
||||
const needle = filter.trim().toLowerCase()
|
||||
const shown = names.filter((name) => {
|
||||
if (!needle) return true
|
||||
const field = table.fields?.[name]
|
||||
return name.toLowerCase().includes(needle) || field?.label?.toLowerCase().includes(needle) || rawText(record.values[name]).toLowerCase().includes(needle)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="flex max-w-sm flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||
Filter fields
|
||||
<input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="Name, label or value"
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id="dev-field-filter"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground" aria-live="polite">{shown.length} of {names.length} fields</p>
|
||||
|
||||
<div className="hidden overflow-x-auto rounded-lg border border-border md:block">
|
||||
<table className="min-w-full text-sm" data-qqq-id="record-dev-field-table">
|
||||
<thead className="bg-muted/60">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 text-left text-xs font-semibold text-foreground">Field</th>
|
||||
<th scope="col" className="px-3 py-2 text-left text-xs font-semibold text-foreground">Type</th>
|
||||
<th scope="col" className="px-3 py-2 text-left text-xs font-semibold text-foreground">Value</th>
|
||||
<th scope="col" className="px-3 py-2 text-left text-xs font-semibold text-foreground">Raw</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((name) => {
|
||||
const field = table.fields?.[name]
|
||||
return (
|
||||
<tr key={name} className="border-t border-border align-top" data-qqq-id={`dev-field-${name}`}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="text-sm text-foreground">{field?.label ?? name}</div>
|
||||
<div className="font-mono text-xs text-primary">{name}{name === table.primaryKeyField ? ' (primary key)' : ''}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-mono text-xs text-muted-foreground">{field?.type ?? '—'}</div>
|
||||
{field && <TagList tags={fieldTags(field)} />}
|
||||
</td>
|
||||
<td className="max-w-[24rem] px-3 py-2 text-sm [overflow-wrap:anywhere]">
|
||||
{field ? <FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={table} /> : rawText(record.displayValues?.[name] ?? record.values[name])}
|
||||
</td>
|
||||
<td className="max-w-[24rem] px-3 py-2 font-mono text-xs text-foreground [overflow-wrap:anywhere]">
|
||||
<RawValue value={record.values[name]} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ul className="m-0 list-none space-y-2 p-0 md:hidden">
|
||||
{shown.map((name) => {
|
||||
const field = table.fields?.[name]
|
||||
return (
|
||||
<li key={name} className="rounded-lg border border-border p-3" data-qqq-id={`dev-field-card-${name}`}>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-2">
|
||||
<span className="text-sm font-medium text-foreground">{field?.label ?? name}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{field?.type ?? ''}</span>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-primary">{name}</div>
|
||||
{field && <TagList tags={fieldTags(field)} />}
|
||||
<div className="mt-2 text-sm [overflow-wrap:anywhere]">
|
||||
{field ? <FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={table} /> : rawText(record.values[name])}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-xs text-muted-foreground [overflow-wrap:anywhere]">raw: <RawValue value={record.values[name]} /></div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw value, with null in italics.
|
||||
* @param props - The value.
|
||||
* @param props.value - Raw value.
|
||||
* @returns The text.
|
||||
*/
|
||||
function RawValue({ value }: { value: unknown }) {
|
||||
if (value === null || value === undefined) return <span className="italic text-muted-foreground">null</span>
|
||||
return <>{rawText(value)}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Small tags under a type.
|
||||
* @param props - The tags.
|
||||
* @param props.tags - Tag texts.
|
||||
* @returns The tag list, or nothing.
|
||||
*/
|
||||
function TagList({ tags }: { tags: string[] }) {
|
||||
if (tags.length === 0) return null
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tags.map((tag) => <span key={tag} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">{tag}</span>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Record developer view sections for how the record relates to others (associations,
|
||||
* exposed joins and the records its fields reference) and a summary of its recent audits.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { QRecord, QTableMetaData } from '@/types'
|
||||
import { getAuditRecords, type AuditSource } from '@/lib/api/audits'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { AuditHistoryDialog } from '@/components/records/AuditHistoryDialog'
|
||||
import { PanelTable } from './DeveloperPanelView'
|
||||
|
||||
/**
|
||||
* Whether the record has anything to show in {@link RecordRelations}.
|
||||
* @param table - Table metadata.
|
||||
* @param record - The record.
|
||||
* @param allTables - Tables the user can see.
|
||||
* @returns True when there are associations, joins or references.
|
||||
*/
|
||||
export function hasRelations(table: QTableMetaData, record: QRecord, allTables?: Record<string, QTableMetaData>): boolean {
|
||||
return Boolean(table.associations?.length || table.exposedJoins?.length || references(table, record, allTables).length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields of the record whose values reference records of other visible tables.
|
||||
* @param table - Table metadata.
|
||||
* @param record - The record.
|
||||
* @param allTables - Tables the user can see.
|
||||
* @returns Field label, target table and key, for set values only.
|
||||
*/
|
||||
function references(table: QTableMetaData, record: QRecord, allTables?: Record<string, QTableMetaData>) {
|
||||
return Object.values(table.fields ?? {}).flatMap((field) => {
|
||||
const target = field.possibleValueSourceName && allTables?.[field.possibleValueSourceName]
|
||||
const value = record.values[field.name]
|
||||
if (!target || value === null || value === undefined || value === '') return []
|
||||
return [{ field, target, value: String(value), label: record.displayValues?.[field.name] ?? String(value) }]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Associations, exposed joins and references of the record.
|
||||
*
|
||||
* @param props - Table, record and visible tables.
|
||||
* @param props.table - Table metadata.
|
||||
* @param props.record - The record.
|
||||
* @param props.allTables - Tables the user can see (for labels and links).
|
||||
* @returns The relation lists.
|
||||
*/
|
||||
export function RecordRelations({ table, record, allTables }: { table: QTableMetaData; record: QRecord; allTables?: Record<string, QTableMetaData> }) {
|
||||
const refs = references(table, record, allTables)
|
||||
const tableLabel = (name: string) => allTables?.[name]?.label ?? name
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{refs.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">References</h3>
|
||||
<ul className="m-0 list-none space-y-1 p-0 text-sm">
|
||||
{refs.map(({ field, target, value, label }) => (
|
||||
<li key={field.name} className="flex flex-wrap gap-x-2">
|
||||
<span className="text-muted-foreground">{field.label}</span>
|
||||
<Link className="text-primary hover:underline" href={`/app/${encodeURIComponent(target.name)}/${encodeURIComponent(value)}`}>{label}</Link>
|
||||
<span className="text-muted-foreground">({target.label})</span>
|
||||
<Link className="text-xs text-primary hover:underline" href={`/app/${encodeURIComponent(target.name)}/${encodeURIComponent(value)}/dev`}>developer view</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(table.associations?.length) && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">Associations</h3>
|
||||
<PanelTable
|
||||
columns={['Name', 'Associated table', 'Join', 'Type', 'Join on']}
|
||||
rows={(table.associations ?? []).map((association) => [
|
||||
association.name,
|
||||
`${tableLabel(association.associatedTableName)} (${association.associatedTableName})`,
|
||||
association.join?.name ?? '',
|
||||
association.join?.type ?? '',
|
||||
(association.join?.joinOns ?? []).map((on) => `${on.leftField} = ${on.rightField}`).join(', '),
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(table.exposedJoins?.length) && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-foreground">Exposed joins</h3>
|
||||
<PanelTable
|
||||
columns={['Label', 'Table', 'Cardinality', 'Path']}
|
||||
rows={(table.exposedJoins ?? []).map((join) => [
|
||||
join.label,
|
||||
join.joinTable ? `${join.joinTable.label} (${join.joinTable.name})` : '',
|
||||
join.isMany ? 'many' : 'one',
|
||||
(join.joinPath ?? []).map((step) => step.name).join(' > '),
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** How many audits the summary lists. */
|
||||
const RECENT_AUDITS = 5
|
||||
|
||||
/**
|
||||
* The record's latest audits, with a button for the full history dialog.
|
||||
*
|
||||
* @param props - Audit source, table and record.
|
||||
* @param props.source - How the user reads audits.
|
||||
* @param props.table - Table metadata.
|
||||
* @param props.primaryKey - The record's key.
|
||||
* @param props.recordLabel - The record's label.
|
||||
* @returns The summary.
|
||||
*/
|
||||
export function RecentAuditSummary({ source, table, primaryKey, recordLabel }: { source: Exclude<AuditSource, null>; table: QTableMetaData; primaryKey: string | number; recordLabel: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: [...queryKeys.recordDeveloper(table.name, primaryKey), 'recentAudits'],
|
||||
queryFn: () => getAuditRecords(source, table.name, primaryKey),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
retry: false,
|
||||
})
|
||||
const audits = data?.records ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">Loading audits…</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">Audits could not be loaded: {getErrorMessage(error)}</p>}
|
||||
{data && audits.length === 0 && <p className="text-sm italic text-muted-foreground">This record has no audits.</p>}
|
||||
{audits.length > 0 && (
|
||||
<ol className="m-0 list-none space-y-2 p-0">
|
||||
{audits.slice(0, RECENT_AUDITS).map((audit) => (
|
||||
<li key={audit.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-l-2 border-border pl-3 text-sm">
|
||||
<span className="font-mono text-xs font-semibold text-foreground">{audit.action}</span>
|
||||
<DateTimeText value={audit.timestamp} type="DATE_TIME" className="text-xs text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{audit.user}</span>
|
||||
<span className="min-w-0 basis-full break-words text-sm text-foreground">
|
||||
{audit.message || `${audit.fieldChanges?.length ?? 0} field change${audit.fieldChanges?.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{data && <span className="text-xs text-muted-foreground">{data.total ?? audits.length} audit{(data.total ?? audits.length) === 1 ? '' : 's'} in all</span>}
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="dev-open-audit-history"
|
||||
>
|
||||
Full audit history
|
||||
</button>
|
||||
</div>
|
||||
<AuditHistoryDialog open={open} onOpenChange={setOpen} source={source} tableMetaData={table} primaryKey={primaryKey} recordLabel={recordLabel} returnFocusRef={trigger} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file TableMetadataExplorer — the table's metadata on the developer view as readable, searchable
|
||||
* groups (summary, fields with their possible-value sources and adornments, sections, menus and
|
||||
* supplemental metadata) plus the raw JSON, instead of one large JSON block.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
import type { QFieldMetaData, QTableMetaData } from '@/types'
|
||||
import { CodeBlock } from '@/components/scripts/HighlightedCode'
|
||||
import { CopyButton } from './DeveloperSection'
|
||||
import { PanelTable } from './DeveloperPanelView'
|
||||
|
||||
/**
|
||||
* Adornments of a field as short text, e.g. `CHIP {"color.ACTIVE":"success"}`.
|
||||
* @param field - The field.
|
||||
* @returns One line per adornment.
|
||||
*/
|
||||
export function adornmentText(field: QFieldMetaData): string {
|
||||
return (field.adornments ?? [])
|
||||
.map((adornment) => adornment.values && Object.keys(adornment.values).length ? `${adornment.type} ${JSON.stringify(adornment.values)}` : adornment.type)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the texts contains the needle (case-insensitive).
|
||||
* @param needle - Lower-cased search text ('' matches everything).
|
||||
* @param texts - Candidate texts.
|
||||
* @returns True on a match.
|
||||
*/
|
||||
function matches(needle: string, ...texts: Array<string | undefined | null>): boolean {
|
||||
return !needle || texts.some((text) => text?.toLowerCase().includes(needle))
|
||||
}
|
||||
|
||||
/**
|
||||
* The explorer.
|
||||
*
|
||||
* @param props - The table.
|
||||
* @param props.table - Full table metadata.
|
||||
* @returns Search box and collapsible groups.
|
||||
*/
|
||||
export function TableMetadataExplorer({ table }: { table: QTableMetaData }) {
|
||||
const [search, setSearch] = useState('')
|
||||
const needle = search.trim().toLowerCase()
|
||||
const json = useMemo(() => JSON.stringify(table, null, 2), [table])
|
||||
|
||||
const fields = Object.values(table.fields ?? {}).filter((field) =>
|
||||
matches(needle, field.name, field.label, field.type, field.possibleValueSourceName, adornmentText(field), field.displayFormat))
|
||||
const sections = (table.sections ?? []).filter((section) => matches(needle, section.name, section.label, section.tier, ...(section.fieldNames ?? [])))
|
||||
const supplemental = Object.entries({ ...(table.supplementalMetaData ?? {}), ...(table.supplementalTableMetaData ?? {}) })
|
||||
.filter(([key, value]) => matches(needle, key, JSON.stringify(value)))
|
||||
const capabilities = (table.capabilities ?? []).join(', ')
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="flex max-w-sm flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||
Search table metadata
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Field, section, adornment, PVS…"
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id="dev-metadata-search"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Group title="Summary" open={!needle}>
|
||||
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[minmax(8rem,max-content)_1fr]">
|
||||
{[
|
||||
['Name', table.name],
|
||||
['Label', table.label],
|
||||
['Primary key', table.primaryKeyField],
|
||||
['Capabilities', capabilities || 'all'],
|
||||
['Your permissions', ['read', 'insert', 'edit', 'delete'].filter((_, index) => [table.readPermission, table.insertPermission, table.editPermission, table.deletePermission][index]).join(', ') || 'none'],
|
||||
['Uses variants', table.usesVariants ? `yes (${table.variantTableLabel})` : 'no'],
|
||||
['Record search fields', (table.searchFields ?? []).join(', ') || '—'],
|
||||
].map(([label, value]) => (
|
||||
<React.Fragment key={label}>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="m-0 break-words font-mono text-xs text-foreground">{value}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</Group>
|
||||
|
||||
<Group title={`Fields (${fields.length})`} open={Boolean(needle)}>
|
||||
<PanelTable
|
||||
columns={['Name', 'Label', 'Type', 'Possible values', 'Adornments', 'Flags', 'Format']}
|
||||
rows={fields.map((field) => [
|
||||
field.name,
|
||||
field.label,
|
||||
field.type,
|
||||
field.possibleValueSourceName ?? (field.inlinePossibleValueSource?.enumValues?.map((option) => `${option.id}=${option.label}`).join(', ') ?? ''),
|
||||
adornmentText(field),
|
||||
[field.isRequired && 'required', !field.isEditable && 'read-only', field.isHidden && 'hidden', field.isHeavy && 'heavy', field.maxLength && `max ${field.maxLength}`].filter(Boolean).join(', '),
|
||||
field.displayFormat ?? '',
|
||||
])}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group title={`Sections (${sections.length})`} open={Boolean(needle) && sections.length > 0}>
|
||||
<PanelTable
|
||||
columns={['Name', 'Label', 'Tier', 'Fields']}
|
||||
rows={sections.map((section) => [section.name, section.label, section.tier ?? '', (section.fieldNames ?? []).join(', ')])}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{(table.menus?.length ?? 0) > 0 && !needle && (
|
||||
<Group title={`Menus (${table.menus?.length})`}>
|
||||
<PanelTable
|
||||
columns={['Slot', 'Label', 'Items']}
|
||||
rows={(table.menus ?? []).map((menu) => [menu.slot ?? '', menu.label ?? '', (menu.items ?? []).map((item) => item.label ?? item.itemType).join(', ')])}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{supplemental.length > 0 && (
|
||||
<Group title={`Supplemental metadata (${supplemental.length})`} open={Boolean(needle)}>
|
||||
{supplemental.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<h4 className="mb-1 font-mono text-xs font-semibold text-foreground">{key}</h4>
|
||||
<CodeBlock code={JSON.stringify(value, null, 2)} language="json" className="max-h-64" />
|
||||
</div>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group title="Raw JSON">
|
||||
<div className="mb-2 flex justify-end"><CopyButton text={json} label="Copy table metadata JSON" /></div>
|
||||
<CodeBlock code={json} language="json" className="max-h-[32rem]" dataQqqId="json-output" />
|
||||
</Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsible group (native details/summary: keyboard and screen-reader friendly).
|
||||
* @param props - Title, initial state and content.
|
||||
* @param props.title - Summary text.
|
||||
* @param props.open - Starts open.
|
||||
* @param props.children - Content.
|
||||
* @returns The group.
|
||||
*/
|
||||
function Group({ title, open = false, children }: { title: string; open?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
// key on open: a search that opens the group re-mounts it, so the user's own toggling still works afterwards
|
||||
<details key={String(open)} open={open} className="rounded-lg border border-border">
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm font-medium text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">{title}</summary>
|
||||
<div className="min-w-0 border-t border-border p-3">{children}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { EsbCounter } from '@/types'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
import { compactDateTime, relativeDateTime } from '@/lib/utils/datetime-utils'
|
||||
|
||||
/** Which side of a destination the counters describe. */
|
||||
export type EsbCountersKind = 'publish' | 'consume'
|
||||
@@ -59,7 +59,7 @@ export function EsbCounters({ counters, kind }: { counters: EsbCounter; kind: Es
|
||||
['Max time', `${Math.round(counters.maxMs)} ms`]
|
||||
)
|
||||
}
|
||||
items.push(['Last activity', formatDateTime(counters.lastActivity) ?? '—'])
|
||||
items.push(['Last activity', relativeDateTime(counters.lastActivity) ?? compactDateTime(counters.lastActivity)?.text ?? '—'])
|
||||
|
||||
return (
|
||||
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
|
||||
@@ -30,7 +30,7 @@ import { X } from 'lucide-react'
|
||||
import type { EsbMessage, EsbPermissions } from '@/types'
|
||||
import { ESB_PAGE_SIZE, getEsbDeadLetters, getEsbMessages } from '@/lib/api/esb'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import {
|
||||
EsbQueueActions,
|
||||
EsbSelectionActions,
|
||||
@@ -223,7 +223,7 @@ function MessageRow({
|
||||
)}
|
||||
<td className="px-2 py-2 font-mono text-xs text-foreground">{message.messageId}</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 text-foreground">
|
||||
{formatDateTime(message.timestamp) ?? message.timestamp}
|
||||
<DateTimeText type="DATE_TIME" value={message.timestamp} relative className="text-xs" />
|
||||
</td>
|
||||
<td className="px-2 py-2 tabular-nums text-foreground">{message.deliveryCount}</td>
|
||||
<td className="space-y-1 px-2 py-2">
|
||||
|
||||
@@ -18,17 +18,35 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render as rtlRender, screen } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const pushMock = vi.fn()
|
||||
let pathname = '/app'
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock }), usePathname: () => pathname }))
|
||||
|
||||
const searchRecordsMock = vi.fn()
|
||||
vi.mock('@/lib/api/record-search', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/record-search')>()),
|
||||
searchRecords: (...args: unknown[]) => searchRecordsMock(...args),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Renders inside a fresh query client (the palette runs record search through TanStack Query).
|
||||
*
|
||||
* @param ui - Element to render.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function render(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return rtlRender(ui, { wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> })
|
||||
}
|
||||
|
||||
import { CommandMenu } from './CommandMenu'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import type { QInstance, QTableMetaData } from '@/types'
|
||||
import { addRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { addRecentRecord, clearRecentRecords } from '@/lib/utils/recent-records'
|
||||
|
||||
const app = { label: 'People App', path: '/app/peopleApp' }
|
||||
const targets: NavTarget[] = [
|
||||
@@ -80,12 +98,13 @@ describe('CommandMenu', () => {
|
||||
]
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={more} />)
|
||||
await user.type(screen.getByRole('combobox'), 'pet')
|
||||
expect(screen.getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet', 'Pet Species', 'Carpet'])
|
||||
expect(Array.from(document.querySelectorAll('[data-qqq-id="command-group-navigation"] [role="option"]')).map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet', 'Pet Species', 'Carpet'])
|
||||
await user.type(screen.getByRole('combobox'), ' spe')
|
||||
expect(screen.getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet Species'])
|
||||
expect(Array.from(document.querySelectorAll('[data-qqq-id="command-group-navigation"] [role="option"]')).map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet Species'])
|
||||
await user.clear(screen.getByRole('combobox'))
|
||||
await user.type(screen.getByRole('combobox'), 'nothing here')
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
// only the search-everything entry remains
|
||||
expect(screen.queryAllByRole('option').map((option) => option.textContent)).toEqual(['Search everything for “nothing here”'])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('No results found.')
|
||||
})
|
||||
|
||||
@@ -142,4 +161,62 @@ describe('CommandMenu', () => {
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/person/7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('record search', () => {
|
||||
const searchMeta = {
|
||||
tables: {
|
||||
person: { name: 'person', label: 'Person', isHidden: false, readPermission: true, primaryKeyField: 'id', fields: {}, searchFields: ['firstName'], icon: { name: 'person' } },
|
||||
},
|
||||
processes: {},
|
||||
apps: {},
|
||||
} as unknown as QInstance
|
||||
|
||||
beforeEach(() => {
|
||||
pathname = '/app'
|
||||
pushMock.mockReset()
|
||||
searchRecordsMock.mockReset()
|
||||
clearRecentRecords()
|
||||
window.open = vi.fn() as unknown as typeof window.open
|
||||
})
|
||||
|
||||
it('lists found records under their table with a count, and a search-everything entry', async () => {
|
||||
searchRecordsMock.mockResolvedValue({
|
||||
results: [{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Avery Sample', updatedAt: '2026-10-01T10:00:00Z', highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'avery@example.com', matches: [{ start: 0, end: 5 }] }] }],
|
||||
tables: [{ tableName: 'person', resultCount: 1, hasMore: true, totalCount: 12, source: 'PROVIDER' }],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
|
||||
await user.type(screen.getByRole('combobox'), 'avery')
|
||||
const record = await screen.findByRole('option', { name: /Avery Sample/ })
|
||||
expect(record).toHaveTextContent('Email: avery@example.com')
|
||||
expect(Array.from(record.querySelectorAll('mark')).map((mark) => mark.textContent)).toEqual(['Avery', 'avery'])
|
||||
expect(document.querySelector('[data-qqq-id="command-group-records-person"]')).toHaveTextContent('Person · 12')
|
||||
expect(screen.getByRole('option', { name: /See all matches in Person/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /Search everything for/ })).toBeInTheDocument()
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('avery', expect.objectContaining({ tableNames: ['person'], limitPerTable: 5 }))
|
||||
})
|
||||
|
||||
it('opens the highlighted record in a new tab on Ctrl+Enter', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', recordId: '7', recordLabel: 'Avery Sample' }] })
|
||||
const user = userEvent.setup()
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
|
||||
await user.type(screen.getByRole('combobox'), 'avery')
|
||||
await screen.findByRole('option', { name: /Avery Sample/ })
|
||||
// the record is the first entry, so cmdk highlights it
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(window.open).toHaveBeenCalledWith('/app/person/7', '_blank', 'noopener,noreferrer')
|
||||
expect(pushMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the failure class when record search fails, keeping navigation results', async () => {
|
||||
const { AxiosError } = await import('axios')
|
||||
searchRecordsMock.mockRejectedValue(new AxiosError('Index unavailable', 'ERR_BAD_RESPONSE', undefined, null, { status: 503, data: {}, statusText: '', headers: {}, config: {} as never }))
|
||||
const user = userEvent.setup()
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
|
||||
await user.type(screen.getByRole('combobox'), 'pe')
|
||||
expect(await screen.findByText('Record search failed: server error (HTTP 503)')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Index unavailable/)).toBeInTheDocument()
|
||||
expect(document.querySelector('[data-qqq-id="command-item-peopleApp"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,16 +23,22 @@
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Search, Sparkles, X } from 'lucide-react'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { buildTableActions, commandMatches, compareCommandLabels, tableScreenFor, type TableAction } from '@/lib/utils/command-palette'
|
||||
import { getRecentRecords, type RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { searchableTables } from '@/lib/utils/record-search'
|
||||
import { openInNewTab, searchPagePath } from '@/lib/search/search-url'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
/** Record search results, loaded when the palette opens with searchable tables. */
|
||||
const CommandRecordResults = dynamic(() => import('@/components/search/CommandRecordResults').then((module) => module.CommandRecordResults), { ssr: false })
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
@@ -51,6 +57,12 @@ interface CommandMenuProps {
|
||||
navTargets: NavTarget[]
|
||||
/** Instance metadata, for the current table's actions (omit for navigation only). */
|
||||
metaData?: QInstance
|
||||
/**
|
||||
* When set (the assistant is available), typed text can be handed to the assistant.
|
||||
*
|
||||
* @param text - The typed text.
|
||||
*/
|
||||
onAskAssistant?: (text: string) => void
|
||||
}
|
||||
|
||||
/** Shared classes of a palette entry (44 px tall on touch screens). */
|
||||
@@ -84,12 +96,14 @@ function GroupHeading({ children }: { children: React.ReactNode }) {
|
||||
* @returns A fixed full-screen backdrop with the command palette dialog, or
|
||||
* `null` when `open` is `false`. Keyboard: ↑↓ navigate, ↵ open, Esc close.
|
||||
*/
|
||||
export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenuProps) {
|
||||
export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistant }: CommandMenuProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [search, setSearch] = useState('')
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
const [hash, setHash] = useState('')
|
||||
// value of the highlighted entry (cmdk), for Ctrl/Cmd+Enter
|
||||
const [selectedValue, setSelectedValue] = useState('')
|
||||
|
||||
// Recently viewed records and the hash (an open audit dialog hides the table actions) are read on open
|
||||
useEffect(() => {
|
||||
@@ -162,6 +176,24 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
const screen = useMemo(() => (open ? tableScreenFor(pathname ?? '', hash, metaData) : null), [open, pathname, hash, metaData])
|
||||
const tableActions = useMemo(() => (screen && metaData ? buildTableActions(screen, metaData) : []), [screen, metaData])
|
||||
|
||||
// Records found by record search (only when the metadata lists searchable tables)
|
||||
const tables = useMemo(() => searchableTables(metaData), [metaData])
|
||||
const [recordState, setRecordState] = useState({ count: 0, searching: false })
|
||||
const trimmedSearch = search.trim()
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd+Enter opens the highlighted entry in a new tab (cmdk handles plain Enter).
|
||||
*
|
||||
* @param event - Keydown inside the palette (runs before cmdk's own handler).
|
||||
*/
|
||||
const handlePaletteKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey)) return
|
||||
const prefix = selectedValue.slice(0, selectedValue.indexOf(':') + 1)
|
||||
if (!['page:', 'recent:', 'record:', 'search:'].includes(prefix)) return
|
||||
event.preventDefault()
|
||||
openInNewTab(selectedValue.slice(prefix.length))
|
||||
}
|
||||
|
||||
// Material's filter (per-word substring) and ranking (labels that start with the search first)
|
||||
const visibleActions = search ? tableActions.filter((action) => commandMatches(action.label, search)) : tableActions
|
||||
const visibleTargets = useMemo(() => {
|
||||
@@ -177,6 +209,12 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
.sort((a, b) => compareCommandLabels(a.recordLabel, b.recordLabel, search))
|
||||
}, [recentRecords, search])
|
||||
|
||||
// first entries around the records, for moving cmdk's highlight when records arrive
|
||||
const firstValueBefore = (screen && visibleActions[0] && `action:${visibleActions[0].key}`)
|
||||
|| (visibleTargets[0] && `page:${visibleTargets[0].path}`)
|
||||
|| undefined
|
||||
const firstValueAfter = visibleRecent[0] ? `recent:${visibleRecent[0].path}` : undefined
|
||||
|
||||
/**
|
||||
* Runs a table action: navigates to it, or opens the record's audit dialog (`#audit`).
|
||||
*
|
||||
@@ -218,14 +256,14 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
ref={dialogRef}
|
||||
onKeyDown={trapFocus}
|
||||
>
|
||||
<Command shouldFilter={false} label="Command palette">
|
||||
<Command shouldFilter={false} label="Command palette" value={selectedValue} onValueChange={setSelectedValue} onKeyDown={handlePaletteKeyDown}>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search for tables, actions, or recently viewed items..."
|
||||
placeholder={tables.length > 0 ? 'Search for tables, actions, records, or recently viewed items...' : 'Search for tables, actions, or recently viewed items...'}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autoFocus
|
||||
data-qqq-id="command-menu-search"
|
||||
@@ -248,7 +286,7 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
aria-live="polite"
|
||||
data-qqq-id="command-menu-results"
|
||||
>
|
||||
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && (
|
||||
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && recordState.count === 0 && !recordState.searching && !(onAskAssistant && search.trim()) && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground" role="status" data-qqq-id="command-menu-empty">
|
||||
No results found.
|
||||
</p>
|
||||
@@ -295,6 +333,22 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{open && tables.length > 0 && (
|
||||
<CommandRecordResults
|
||||
search={search}
|
||||
open={open}
|
||||
tables={tables}
|
||||
selectedValue={selectedValue}
|
||||
setSelectedValue={setSelectedValue}
|
||||
firstValueBefore={firstValueBefore}
|
||||
firstValueAfter={firstValueAfter}
|
||||
onSelect={handleSelect}
|
||||
onState={setRecordState}
|
||||
itemClassName={ITEM_CLASS}
|
||||
heading={(text) => <GroupHeading>{text}</GroupHeading>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleRecent.length > 0 && (
|
||||
<Command.Group heading={<GroupHeading>Recently Viewed Records</GroupHeading>} data-qqq-id="command-group-recent">
|
||||
{visibleRecent.map((record) => (
|
||||
@@ -312,6 +366,38 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{trimmedSearch && (
|
||||
<Command.Group heading={<GroupHeading>Search</GroupHeading>} data-qqq-id="command-group-search">
|
||||
<Command.Item
|
||||
value={`search:${searchPagePath({ q: trimmedSearch })}`}
|
||||
onSelect={() => handleSelect(searchPagePath({ q: trimmedSearch }))}
|
||||
className={ITEM_CLASS}
|
||||
data-qqq-id="command-search-all"
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="flex-1 truncate">Search everything for <span className="font-medium">“{trimmedSearch}”</span></span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* Last, so Enter still opens the best navigation match; alone when nothing else matches */}
|
||||
{onAskAssistant && search.trim() && (
|
||||
<Command.Group heading={<GroupHeading>Assistant</GroupHeading>} data-qqq-id="command-group-assistant">
|
||||
<Command.Item
|
||||
value="assistant:ask"
|
||||
onSelect={() => {
|
||||
onClose()
|
||||
onAskAssistant(search.trim())
|
||||
}}
|
||||
className={ITEM_CLASS}
|
||||
data-qqq-id="command-ask-assistant"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="flex-1 truncate font-medium">Ask Assistant: {search.trim()}</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
{/* Footer hint */}
|
||||
@@ -325,6 +411,10 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}
|
||||
open
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">Ctrl/⌘ ↵</kbd>{' '}
|
||||
new tab
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">esc</kbd>{' '}
|
||||
close
|
||||
|
||||
@@ -111,6 +111,8 @@ interface KeyboardShortcutsDialogProps {
|
||||
* opened (Safari and iPad do not focus a tapped or clicked button).
|
||||
*/
|
||||
returnFocusRef?: React.RefObject<HTMLElement | null>
|
||||
/** Lists the assistant shortcut (Ctrl/⌘+J) when the assistant is available. */
|
||||
assistantEnabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +128,12 @@ interface KeyboardShortcutsDialogProps {
|
||||
* sections (max 60 vh), and a footer reminder. Each section groups related
|
||||
* shortcuts under a heading with {@link Kbd} badges for each key combination.
|
||||
*/
|
||||
export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef }: KeyboardShortcutsDialogProps) {
|
||||
export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef, assistantEnabled = false }: KeyboardShortcutsDialogProps) {
|
||||
const sections = assistantEnabled
|
||||
? shortcutSections.map((section) => (section.title === 'Global'
|
||||
? { ...section, shortcuts: [...section.shortcuts, { keys: ['⌘', 'J'], description: 'Assistant (Ctrl+J on Windows and Linux)' }] }
|
||||
: section))
|
||||
: shortcutSections
|
||||
const restoreFocus = useRestoreFocus(open)
|
||||
/**
|
||||
* Returns focus to where it was, or else to the help button.
|
||||
@@ -177,7 +184,7 @@ export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef }: Keybo
|
||||
|
||||
{/* Shortcut sections */}
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
|
||||
{shortcutSections.map((section, sectionIndex) => (
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={section.title} className={cn(sectionIndex > 0 && 'mt-5')}>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
|
||||
@@ -38,16 +38,18 @@ function Harness() {
|
||||
describe('SearchDialog keyboard access', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('keeps both Tab directions in an empty dialog', async () => {
|
||||
it('keeps both Tab directions inside an empty dialog (input and the close button)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open search' }))
|
||||
const input = screen.getByRole('combobox')
|
||||
await waitFor(() => expect(input).toHaveFocus())
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(input).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(input).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('wraps between the first and last controls when the query adds buttons', async () => {
|
||||
@@ -57,13 +59,13 @@ describe('SearchDialog keyboard access', () => {
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'no matches')
|
||||
await user.tab({ shift: true })
|
||||
expect(screen.getByRole('button', { name: 'Show all matches' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(input).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Clear search' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Show all matches' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('dismisses with Escape from a button and returns focus to the opener', async () => {
|
||||
@@ -90,7 +92,7 @@ describe('SearchDialog keyboard access', () => {
|
||||
expect(input).toHaveValue('')
|
||||
expect(input).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(input).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
|
||||
@@ -15,30 +15,32 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file SearchDialog — "/" key search dialog over pages and recently viewed records, plus
|
||||
* records found by the backend record search when the metadata advertises searchable tables.
|
||||
* @file SearchDialog — "/" key (and mobile header) search dialog: search-as-you-type over
|
||||
* pages, recently viewed records and recent searches, plus records found by the backend
|
||||
* record search when the metadata advertises searchable tables.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import React, { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Search, ArrowRight, X } from 'lucide-react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
import type { NavTarget } 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 type { SearchableTable } from '@/lib/utils/record-search'
|
||||
import { NavigationSearchResults } from '@/components/layout/NavigationSearchResults'
|
||||
import { searchPagePath } from '@/lib/search/search-url'
|
||||
import type { QuickSearchPanelController } from '@/components/search/QuickSearchPanel'
|
||||
|
||||
/** Records shown per table in the dialog. */
|
||||
const DIALOG_RECORDS_PER_TABLE = 5
|
||||
/** The results panel, loaded when the dialog first opens. */
|
||||
const QuickSearchPanel = dynamic(() => import('@/components/search/QuickSearchPanel').then((module) => module.QuickSearchPanel), { ssr: false })
|
||||
|
||||
/** Stable empty default for `searchTables`. */
|
||||
const NO_SEARCH_TABLES: SearchableTable[] = []
|
||||
|
||||
/** DOM id of the results listbox. */
|
||||
const LISTBOX_ID = 'search-dialog-results'
|
||||
|
||||
/**
|
||||
* Props for the SearchDialog component.
|
||||
*/
|
||||
@@ -54,13 +56,11 @@ interface SearchDialogProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal search dialog triggered by the `/` keyboard shortcut.
|
||||
* Modal search dialog triggered by the `/` keyboard shortcut and the mobile header button.
|
||||
*
|
||||
* Lists recently viewed records until the user types, then matching pages
|
||||
* (apps, tables, processes, reports by label), records found by record search
|
||||
* (when available) and matching recent records.
|
||||
* ArrowUp/Down move the selection, Enter opens it (or the search page when
|
||||
* nothing is selected), Escape closes.
|
||||
* Lists recent searches and recently viewed records until the user types, then grouped
|
||||
* results (top hits, pages, records per table, recently viewed). ↑/↓ move the selection,
|
||||
* Enter opens it, Ctrl/⌘+Enter opens it in a new tab, Escape closes.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The dialog overlay, or `null` when closed.
|
||||
@@ -70,78 +70,48 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
onClose()
|
||||
router.push(path)
|
||||
}, [router, onClose])
|
||||
|
||||
// Load recent records when the dialog opens; reset state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const returnTo = document.activeElement
|
||||
setRecentRecords(getRecentRecords().slice(0, 10))
|
||||
setSearchTerm('')
|
||||
setSelectedIndex(-1)
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
|
||||
}
|
||||
const [term, setTerm] = useState('')
|
||||
const controllerRef = useRef<QuickSearchPanelController | null>(null)
|
||||
const [activeDescendant, setActiveDescendant] = useState<string>()
|
||||
const recordSearchAvailable = searchTables.length > 0
|
||||
|
||||
const handleController = useCallback((controller: QuickSearchPanelController | null) => {
|
||||
controllerRef.current = controller
|
||||
setActiveDescendant(controller?.activeDescendant)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Defers ↑↓, Enter and Ctrl/⌘+Enter to the results panel (Enter searches everything while it loads).
|
||||
*
|
||||
* @param event - The input's keydown event.
|
||||
*/
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (controllerRef.current) {
|
||||
controllerRef.current.onInputKeyDown(event)
|
||||
} else if (event.key === 'Enter' && term.trim()) {
|
||||
event.preventDefault()
|
||||
handleNavigate(searchPagePath({ q: term }))
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const recordSearch = useRecordSearch(open ? searchTerm : '', searchTables, DIALOG_RECORDS_PER_TABLE)
|
||||
const items = useMemo(
|
||||
() => buildNavigationSearchItems(navTargets, recentRecords, searchTerm, 8, recordSearch.results),
|
||||
[navTargets, recentRecords, searchTerm, recordSearch.results]
|
||||
)
|
||||
}
|
||||
|
||||
// Reset and focus on open; give focus back on close
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
}, [items])
|
||||
|
||||
/**
|
||||
* Closes the dialog and navigates to the given path.
|
||||
*
|
||||
* @param path - The URL path to navigate to.
|
||||
*/
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
onClose()
|
||||
router.push(path)
|
||||
},
|
||||
[router, onClose]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation within the result list.
|
||||
*
|
||||
* @param e - The synthetic keyboard event from the search input.
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
handleNavigate(items[selectedIndex].path)
|
||||
} else if (searchTerm.trim()) {
|
||||
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[selectedIndex, items, searchTerm, handleNavigate]
|
||||
)
|
||||
if (!open) return
|
||||
const returnTo = document.activeElement
|
||||
setTerm('')
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
|
||||
}
|
||||
}, [open, setTerm])
|
||||
|
||||
/**
|
||||
* Handles dismissal from every control and keeps keyboard focus inside the modal.
|
||||
@@ -168,104 +138,82 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const hasTerm = searchTerm.trim().length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
|
||||
data-qqq-id="search-dialog-backdrop"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="fixed inset-0 z-[2000] flex items-start justify-center px-2 pt-[6vh] sm:px-4 sm:pt-[12vh]" data-qqq-id="search-dialog-backdrop">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} aria-hidden="true" />
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg overflow-hidden"
|
||||
className="relative z-10 flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg sm:max-h-[76vh]"
|
||||
data-qqq-id="search-dialog"
|
||||
role="dialog"
|
||||
aria-label="Search"
|
||||
aria-modal="true"
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={recordSearch.available ? 'Search pages and records...' : 'Jump to a page or recent record...'}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={recordSearchAvailable ? 'Search pages and records…' : 'Jump to a page or recent record…'}
|
||||
className="min-w-0 flex-1 bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none sm:text-sm"
|
||||
aria-label={recordSearchAvailable ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
aria-expanded={true}
|
||||
aria-controls="search-dialog-results"
|
||||
aria-controls={LISTBOX_ID}
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
data-qqq-id="search-dialog-input"
|
||||
/>
|
||||
{searchTerm && (
|
||||
{term && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
inputRef.current?.focus()
|
||||
setSearchTerm('')
|
||||
setTerm('')
|
||||
}}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<kbd className="hidden sm:inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close search"
|
||||
>
|
||||
esc
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{hasTerm || items.length > 0 ? (
|
||||
<NavigationSearchResults
|
||||
id="search-dialog-results"
|
||||
items={items}
|
||||
term={searchTerm}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleNavigate}
|
||||
onHover={setSelectedIndex}
|
||||
recordSearch={recordSearch}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<QuickSearchPanel
|
||||
listboxId={LISTBOX_ID}
|
||||
open={open}
|
||||
term={term}
|
||||
setTerm={setTerm}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
onNavigate={handleNavigate}
|
||||
onController={handleController}
|
||||
listClassName="min-h-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<div id="search-dialog-results" className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{recordSearch.available ? 'Type to find an app, table, process or record...' : 'Type to find an app, table, process or recent record...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex gap-4">
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd>{' '}navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}open
|
||||
</span>
|
||||
</div>
|
||||
{hasTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
Show all matches
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden items-center gap-4 border-t border-border px-4 py-2 text-xs text-muted-foreground sm:flex" aria-hidden="true">
|
||||
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd> navigate</span>
|
||||
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd> open</span>
|
||||
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">Ctrl/⌘ ↵</kbd> new tab</span>
|
||||
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">⇧ ↵</kbd> all results</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -417,7 +417,7 @@ export function DynamicForm({
|
||||
/>
|
||||
)}
|
||||
{/* a 12-column grid: full width on phones, half from sm, the field's gridColumns from lg (Material) */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="@container grid grid-cols-12 gap-4">
|
||||
{gridFields.map((f) => (
|
||||
<div
|
||||
key={f.name}
|
||||
@@ -456,7 +456,7 @@ export function DynamicForm({
|
||||
{formLabel && (
|
||||
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="@container grid grid-cols-12 gap-4">
|
||||
{resolvedFields.map((f) => (
|
||||
<div
|
||||
key={f.name}
|
||||
|
||||
@@ -178,3 +178,14 @@ describe('Breadcrumbs', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Breadcrumbs home crumb', () => {
|
||||
it('links to the instance home app when there is one, and to the overview otherwise', () => {
|
||||
currentPathname = '/app/person'
|
||||
const { unmount } = render(<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} homePath="/app/home" />)
|
||||
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/app/home')
|
||||
unmount()
|
||||
render(<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} />)
|
||||
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/app')
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,8 @@ export interface BreadcrumbsProps {
|
||||
pathToLabelMap: Record<string, string>
|
||||
/** Maps node paths to their enclosing apps, outermost first. */
|
||||
ancestorAppMap?: Record<string, ParentAppInfo[]>
|
||||
/** Where the home crumb links: the instance's home app, or the `/app` overview (default). */
|
||||
homePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +160,7 @@ export function buildDocumentTitle(crumbs: Breadcrumb[], pageTitle: string | und
|
||||
* `<Link>` crumbs (ancestor pages) followed by a `<span aria-current="page">`
|
||||
* for the current page, or `null` on the dashboard root and before labels load.
|
||||
*/
|
||||
export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {} }: BreadcrumbsProps) {
|
||||
export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homePath = '/app' }: BreadcrumbsProps) {
|
||||
const pathname = usePathname()
|
||||
const breadcrumbs = buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap)
|
||||
const trailRef = useRef<HTMLElement>(null)
|
||||
@@ -189,9 +191,9 @@ export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {} }: Bre
|
||||
aria-label="Breadcrumb"
|
||||
data-qqq-id="breadcrumbs"
|
||||
>
|
||||
{/* Home crumb (Material's home icon), back to the dashboard */}
|
||||
{/* Home crumb (Material's home icon), back to the home page (the home app, or the overview) */}
|
||||
<Link
|
||||
href="/app"
|
||||
href={homePath}
|
||||
aria-label="Home"
|
||||
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:flex-shrink-0"
|
||||
data-qqq-id="breadcrumb-home"
|
||||
|
||||
@@ -14,14 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for GlobalSearch: local "jump to" search over navigation targets and recent records,
|
||||
// plus backend record search when (and only when) searchable tables are given
|
||||
// Tests for GlobalSearch: search-as-you-type over pages, recent records and recent searches,
|
||||
// plus backend record search when (and only when) searchable tables are given: grouping,
|
||||
// top hits, counts, highlights, keyboard (incl. Ctrl+Enter), failure classes and fallbacks
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AxiosError, type AxiosResponse } from 'axios'
|
||||
|
||||
const pushMock = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
@@ -32,11 +34,15 @@ vi.mock('next/navigation', () => ({
|
||||
}))
|
||||
|
||||
const searchRecordsMock = vi.fn()
|
||||
vi.mock('@/lib/api/tables', () => ({ searchRecords: (...args: unknown[]) => searchRecordsMock(...args) }))
|
||||
vi.mock('@/lib/api/record-search', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/record-search')>()),
|
||||
searchRecords: (...args: unknown[]) => searchRecordsMock(...args),
|
||||
}))
|
||||
|
||||
import { GlobalSearch } from './GlobalSearch'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { addRecentRecord, clearRecentRecords } from '@/lib/utils/recent-records'
|
||||
import { addRecentSearch, getRecentSearches } from '@/lib/search/recent-searches'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
|
||||
const peopleApp = { label: 'People App', path: '/app/peopleApp' }
|
||||
@@ -49,6 +55,12 @@ const navTargets: NavTarget[] = [
|
||||
{ key: 'greetInteractive', label: 'Greet Interactive', path: '/app/greetInteractive', nodeType: 'PROCESS', ancestors: [peopleApp, greetingsApp] },
|
||||
]
|
||||
|
||||
/**
|
||||
* Renders the header search in a fresh query client.
|
||||
*
|
||||
* @param searchTables - Searchable tables (omit: no record search).
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderSearch(searchTables?: SearchableTable[]) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
@@ -60,7 +72,19 @@ function renderSearch(searchTables?: SearchableTable[]) {
|
||||
|
||||
const input = () => screen.getByRole('combobox', { name: 'Search pages and recent records' })
|
||||
const recordInput = () => screen.getByRole('combobox', { name: 'Search pages and records' })
|
||||
const people: SearchableTable[] = [{ name: 'person', label: 'Person' }, { name: 'pet', label: 'Pet' }]
|
||||
const people: SearchableTable[] = [{ name: 'person', label: 'Person', icon: { name: 'person' } }, { name: 'pet', label: 'Pet' }]
|
||||
const optionLabels = (container: HTMLElement) => within(container).getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent ?? option.textContent)
|
||||
// jsdom's accessible name splits highlighted text ("Greet Inter active"): find options by text content
|
||||
const option = (pattern: RegExp, container: HTMLElement = document.body) => {
|
||||
const found = within(container).queryAllByRole('option').find((candidate) => pattern.test(candidate.textContent ?? ''))
|
||||
if (!found) throw new Error(`No option matches ${pattern}`)
|
||||
return found
|
||||
}
|
||||
const findOption = async (pattern: RegExp) => {
|
||||
await screen.findAllByRole('option')
|
||||
return vi.waitFor(() => option(pattern))
|
||||
}
|
||||
const selected = () => screen.getAllByRole('option').find((option) => option.getAttribute('aria-selected') === 'true')
|
||||
|
||||
describe('GlobalSearch', () => {
|
||||
const fetchSpy = vi.fn()
|
||||
@@ -68,9 +92,11 @@ describe('GlobalSearch', () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset()
|
||||
clearRecentRecords()
|
||||
localStorage.removeItem('qqq-recent-searches')
|
||||
fetchSpy.mockReset()
|
||||
searchRecordsMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
window.open = vi.fn() as unknown as typeof window.open
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -83,86 +109,152 @@ describe('GlobalSearch', () => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches navigation targets by label and shows their app context', async () => {
|
||||
it('lists matching pages, best label matches first, with their type and app context', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pe')
|
||||
const options = within(screen.getByRole('group', { name: 'Pages' })).getAllByRole('option')
|
||||
expect(options.map((option) => option.querySelector('span span')?.textContent)).toEqual(['People App', 'Person', 'Pet', 'Greetings App', 'Greet Interactive'])
|
||||
expect(options[1]).toHaveTextContent('People App / Greetings App')
|
||||
expect(options[1].querySelector('svg')).toHaveAttribute('data-qqq-icon', 'person')
|
||||
// "People App", "Person" and "Pet" start with the term: top hits (at most three, closest first)
|
||||
expect(optionLabels(await screen.findByRole('group', { name: 'Top hits' }))).toEqual(['Pet', 'Person', 'People App'])
|
||||
const pages = screen.getByRole('group', { name: 'Pages' })
|
||||
expect(optionLabels(pages)).toEqual(['Greetings App', 'Greet Interactive'])
|
||||
expect(within(pages).getAllByRole('option')[1]).toHaveTextContent('Process · People App / Greetings App')
|
||||
expect(within(screen.getByRole('group', { name: 'Top hits' })).getAllByRole('option')[1].querySelector('svg')).toHaveAttribute('data-qqq-icon', 'person')
|
||||
})
|
||||
|
||||
it('matches targets through their enclosing app label', async () => {
|
||||
it('highlights the matched text', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'greetings')
|
||||
const labels = screen.getAllByRole('option').map((option) => option.querySelector('span span')?.textContent)
|
||||
expect(labels).toEqual(['Greetings App', 'Person', 'Pet', 'Greet Interactive'])
|
||||
await user.type(input(), 'inter')
|
||||
expect((await findOption(/Greet Interactive/)).querySelector('mark')).toHaveTextContent('Inter')
|
||||
})
|
||||
|
||||
it('navigates to the clicked target and clears the input', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'Greet Inter')
|
||||
await user.click(screen.getByRole('option', { name: /^Greet/ }))
|
||||
await user.click(option(/^Greet Interactive/))
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/greetInteractive')
|
||||
expect(input()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('supports arrow keys and Enter', async () => {
|
||||
it('selects the first result while typing so Enter opens the top hit, and arrows move', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pet')
|
||||
expect(selected()).toHaveTextContent('Pet')
|
||||
expect(input()).toHaveAttribute('aria-activedescendant', selected()!.id)
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(selected()).toHaveTextContent('Search everything for “pet”')
|
||||
await user.keyboard('{ArrowUp}{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/pet')
|
||||
})
|
||||
|
||||
it('opens the search page on Enter with nothing selected', async () => {
|
||||
it('opens the selected result in a new tab with Ctrl+Enter and keeps the search open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'zzz{Enter}')
|
||||
await user.type(input(), 'pet')
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(window.open).toHaveBeenCalledWith('/app/pet', '_blank', 'noopener,noreferrer')
|
||||
expect(pushMock).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers "search everything" as the only result when nothing matches, and Enter opens the search page', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'zzz')
|
||||
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or recent records match “zzz”')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=zzz')
|
||||
expect(getRecentSearches()).toEqual(['zzz'])
|
||||
})
|
||||
|
||||
it('keeps the keyboard selection when results appear under a resting pointer', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pe')
|
||||
const options = screen.getAllByRole('option')
|
||||
// Results rendering under the pointer fire mouseenter without pointer movement
|
||||
fireEvent.mouseEnter(screen.getAllByRole('option')[0])
|
||||
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'false')
|
||||
fireEvent.mouseMove(screen.getAllByRole('option')[1])
|
||||
expect(screen.getAllByRole('option')[1]).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.mouseEnter(options[2])
|
||||
expect(options[0]).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.mouseMove(options[2])
|
||||
expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('shows an empty message when nothing matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'zzz')
|
||||
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or recent records match “zzz”')
|
||||
})
|
||||
|
||||
it('lists recently viewed records before typing and filters them by label', async () => {
|
||||
it('lists recent searches and recently viewed records before typing; a recent search fills the box', async () => {
|
||||
addRecentRecord({ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Sample', path: '/app/person/1' })
|
||||
addRecentSearch('greet')
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.click(input())
|
||||
expect(within(screen.getByRole('group', { name: 'Recently viewed' })).getByRole('option', { name: /Avery Sample/ })).toBeInTheDocument()
|
||||
await user.type(input(), 'avery')
|
||||
await user.click(screen.getByRole('option', { name: /Avery Sample/ }))
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/person/1')
|
||||
expect(selected()).toBeUndefined()
|
||||
await user.click(within(screen.getByRole('group', { name: 'Recent searches' })).getByRole('option', { name: 'greet' }))
|
||||
expect(input()).toHaveValue('greet')
|
||||
expect(option(/Greet Interactive/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
it('clears recent searches', async () => {
|
||||
addRecentSearch('greet')
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.click(input())
|
||||
await user.click(screen.getByRole('button', { name: 'Clear recent searches' }))
|
||||
expect(screen.queryByRole('group', { name: 'Recent searches' })).not.toBeInTheDocument()
|
||||
expect(getRecentSearches()).toEqual([])
|
||||
})
|
||||
|
||||
it('closes on Escape, and a second Escape clears the text', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pe')
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(input()).toHaveValue('pe')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(input()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('after Escape closes the dropdown, Enter searches everything instead of opening a hidden option', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pet')
|
||||
await screen.findByRole('listbox')
|
||||
await user.keyboard('{Escape}{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
|
||||
})
|
||||
|
||||
it('typing a new term drops a selection made by pointer for the previous one', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pe')
|
||||
await screen.findByRole('listbox')
|
||||
fireEvent.mouseMove(option(/Search everything/))
|
||||
expect(selected()).toHaveTextContent('Search everything')
|
||||
await user.type(input(), 't')
|
||||
expect(selected()).toHaveTextContent('Pet')
|
||||
})
|
||||
|
||||
it('suggests completions of the last word from matching labels; choosing one fills the box', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'greet in')
|
||||
const suggestions = await screen.findByRole('group', { name: 'Suggestions' })
|
||||
expect(optionLabels(suggestions)).toEqual(['greet interactive'])
|
||||
// a completion is never preselected: Enter still opens the top hit
|
||||
expect(selected()).toHaveTextContent('Greet Interactive')
|
||||
await user.click(option(/greet interactive/, suggestions))
|
||||
expect(input()).toHaveValue('greet interactive')
|
||||
})
|
||||
|
||||
it('Shift+Enter opens the results page even with a result selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pet')
|
||||
await screen.findByRole('listbox')
|
||||
await user.keyboard('{Shift>}{Enter}{/Shift}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
|
||||
})
|
||||
|
||||
it('never calls the backend without searchable tables (no record search capability)', async () => {
|
||||
@@ -176,59 +268,119 @@ describe('GlobalSearch', () => {
|
||||
})
|
||||
|
||||
describe('with record search', () => {
|
||||
it('labels the box for records and lists found records by label with their table, after pages', async () => {
|
||||
searchRecordsMock.mockResolvedValue([
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Petersen' },
|
||||
{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Pepper' },
|
||||
])
|
||||
it('groups found records by table with counts, highlights matched fields and links to more', async () => {
|
||||
searchRecordsMock.mockResolvedValue({
|
||||
results: [
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Petersen', highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'avery@pets.io', matches: [{ start: 6, end: 8 }] }] },
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '2', recordLabel: 'Sam Pepe', details: [{ fieldName: 'city', displayValue: 'Austin' }] },
|
||||
{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Biscuit' },
|
||||
],
|
||||
tables: [{ tableName: 'person', resultCount: 2, hasMore: true, totalCount: 31 }, { tableName: 'pet', resultCount: 1, hasMore: false }],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'pe')
|
||||
const records = within(await screen.findByRole('group', { name: 'Records' })).getAllByRole('option')
|
||||
expect(records.map((option) => option.querySelector('span span')?.textContent)).toEqual(['Avery Petersen', 'Pepper'])
|
||||
expect(records[0].querySelectorAll('span span')[1]).toHaveTextContent('Person')
|
||||
expect(records[1].querySelectorAll('span span')[1]).toHaveTextContent('Pet')
|
||||
const personGroup = await screen.findByRole('group', { name: /^Person/ })
|
||||
expect(personGroup).toHaveAccessibleName('Person 31 matches')
|
||||
expect(optionLabels(personGroup)).toEqual(['Avery Petersen', 'Sam Pepe', 'See all 31 matches in Person'])
|
||||
const avery = option(/Avery Petersen/, personGroup)
|
||||
expect(avery).toHaveTextContent('Email: avery@pets.io')
|
||||
expect(Array.from(avery.querySelectorAll('mark')).map((mark) => mark.textContent)).toEqual(['Pe', 'pe'])
|
||||
expect(option(/Sam Pepe/, personGroup)).toHaveTextContent('Austin')
|
||||
expect(within(personGroup).getByRole('option', { name: /See all 31/ })).toHaveAttribute('href', '/app/search?q=pe&tables=person')
|
||||
expect(screen.getByRole('group', { name: /^Pet/ })).toHaveAccessibleName('Pet 1 matches')
|
||||
expect(searchRecordsMock).toHaveBeenCalledTimes(1)
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('pe', { tableNames: ['person', 'pet'], limitPerTable: 5 })
|
||||
const groups = screen.getAllByRole('group').map((group) => group.getAttribute('aria-label'))
|
||||
expect(groups).toEqual(['Pages', 'Records'])
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('pe', expect.objectContaining({ tableNames: ['person', 'pet'], limitPerTable: 6, offset: 0, sort: 'relevance', signal: expect.any(AbortSignal) }))
|
||||
})
|
||||
|
||||
it('opens a found record by keyboard', async () => {
|
||||
searchRecordsMock.mockResolvedValue([{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }])
|
||||
it('keeps "nothing selected" after ArrowUp when records arrive later, so Enter opens the results page', async () => {
|
||||
// records answer well after the page matches are listed and the user pressed ArrowUp
|
||||
searchRecordsMock.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
return { results: [{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Petunia' }] }
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'zulu')
|
||||
await screen.findByRole('option', { name: /Zed Zulu/ })
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(screen.getByRole('option', { name: /Zed Zulu/ })).toHaveAttribute('aria-selected', 'true')
|
||||
await user.type(recordInput(), 'pet')
|
||||
await vi.waitFor(() => expect(selected()).toHaveTextContent('Pet'))
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(selected()).toBeUndefined()
|
||||
await vi.waitFor(() => option(/Petunia/), { timeout: 3000 })
|
||||
expect(selected()).toBeUndefined()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
|
||||
})
|
||||
|
||||
it('promotes an exact record match to top hits and opens it with Enter', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', recordId: '7', recordLabel: 'Zed Zulu' }] })
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'zed zulu')
|
||||
const top = await screen.findByRole('group', { name: 'Top hits' })
|
||||
expect(option(/Zed Zulu/, top)).toHaveTextContent('Person')
|
||||
expect(selected()).toHaveTextContent('Zed Zulu')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/person/7')
|
||||
expect(getRecentSearches()).toEqual(['zed zulu'])
|
||||
})
|
||||
|
||||
it('never preselects a record left over from the previous term while the new search runs', async () => {
|
||||
searchRecordsMock.mockImplementation(async (term: string) => (term === 'acme'
|
||||
? { results: [{ tableName: 'person', recordId: '1', recordLabel: 'Acme Buyer' }] }
|
||||
: new Promise(() => undefined)))
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'acme')
|
||||
await findOption(/Acme Buyer/)
|
||||
await user.clear(recordInput())
|
||||
await user.type(recordInput(), 'zeta')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=zeta')
|
||||
expect(pushMock).not.toHaveBeenCalledWith('/app/person/1')
|
||||
})
|
||||
|
||||
it('does not search a one-character term, and drops recent records that were found', async () => {
|
||||
addRecentRecord({ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu', path: '/app/person/7' })
|
||||
searchRecordsMock.mockResolvedValue([{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }])
|
||||
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }] })
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'z')
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
expect(searchRecordsMock).not.toHaveBeenCalled()
|
||||
await user.type(recordInput(), 'e')
|
||||
await screen.findByRole('group', { name: 'Records' })
|
||||
await user.type(recordInput(), 'u')
|
||||
await screen.findByRole('group', { name: /^Person/ })
|
||||
expect(screen.queryByRole('group', { name: 'Recently viewed' })).not.toBeInTheDocument()
|
||||
// (jsdom names the highlighted label "Ze d Zulu": match the unhighlighted part)
|
||||
expect(screen.getAllByRole('option', { name: /Zulu/ })).toHaveLength(1)
|
||||
expect(screen.getAllByRole('option').filter((candidate) => /Zulu/.test(candidate.textContent ?? ''))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('announces searching and failures, and says what nothing matched', async () => {
|
||||
searchRecordsMock.mockRejectedValue(new Error('boom'))
|
||||
it('names the failure class and the backend message, keeps page matches, and retries', async () => {
|
||||
searchRecordsMock.mockRejectedValueOnce(new AxiosError('Error in record search provider', 'ERR_BAD_RESPONSE', undefined, null, { status: 500, data: {}, statusText: '', headers: {}, config: {} } as unknown as AxiosResponse))
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'qqqq')
|
||||
await user.type(recordInput(), 'pet')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching records…')
|
||||
await screen.findByText('Record search failed. Try again.')
|
||||
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or records match “qqqq”')
|
||||
const failure = await screen.findByText('Record search failed: server error (HTTP 500)')
|
||||
expect(failure.closest('[data-qqq-id="search-failure"]')).toHaveAttribute('data-failure-kind', 'server')
|
||||
expect(screen.getByText('Error in record search provider')).toBeInTheDocument()
|
||||
expect(option(/^Pet/)).toBeInTheDocument()
|
||||
searchRecordsMock.mockResolvedValueOnce({ results: [{ tableName: 'pet', recordId: '4', recordLabel: 'Petunia' }] })
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await findOption(/Petunia/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Record search failed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows fallback results with the provider failure class when the index failed', async () => {
|
||||
searchRecordsMock.mockResolvedValue({
|
||||
results: [{ tableName: 'pet', recordId: '4', recordLabel: 'Petunia' }],
|
||||
tables: [{ tableName: 'pet', resultCount: 1, hasMore: false, source: 'FALLBACK' }],
|
||||
provider: { status: 'FAILED', failureClass: 'ConnectException', message: 'Connection refused' },
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'petu')
|
||||
expect(await screen.findByText('Search index unavailable — showing basic matches instead')).toBeInTheDocument()
|
||||
expect(screen.getByText('ConnectException: Connection refused')).toBeInTheDocument()
|
||||
expect(option(/Petunia/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,32 +15,45 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file GlobalSearch — header search over apps, tables, processes, reports and recently
|
||||
* viewed records (matched locally), plus records found by the backend record search when
|
||||
* the metadata advertises searchable tables. Without searchable tables no search request is made.
|
||||
* @file GlobalSearch — header search-as-you-type over apps, tables, processes, reports and
|
||||
* recently viewed records (matched locally), plus records found by the backend record
|
||||
* search when the metadata advertises searchable tables (without them no search request
|
||||
* is made). The input is part of every page; the results panel loads on first focus.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Search, ArrowRight } from 'lucide-react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { useRecordSearch } from '@/lib/hooks/use-record-search'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
import { NavigationSearchResults } from './NavigationSearchResults'
|
||||
import { searchPagePath } from '@/lib/search/search-url'
|
||||
import type { QuickSearchPanelController } from '@/components/search/QuickSearchPanel'
|
||||
|
||||
/** Records shown per table in the dropdown. */
|
||||
const DROPDOWN_RECORDS_PER_TABLE = 5
|
||||
/** The results panel, loaded when the search first opens. */
|
||||
const QuickSearchPanel = dynamic(() => import('@/components/search/QuickSearchPanel').then((module) => module.QuickSearchPanel), { ssr: false })
|
||||
|
||||
/** Stable empty default for `searchTables`. */
|
||||
const NO_SEARCH_TABLES: SearchableTable[] = []
|
||||
|
||||
/** DOM id of the results listbox. */
|
||||
const LISTBOX_ID = 'header-search-results'
|
||||
|
||||
/** Keyboard hints under the results. */
|
||||
const FOOTER = (
|
||||
<div className="hidden items-center gap-4 border-t border-border px-3 py-1.5 text-[0.7rem] text-muted-foreground sm:flex" aria-hidden="true">
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">↑↓</kbd> navigate</span>
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">↵</kbd> open</span>
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">Ctrl/⌘ ↵</kbd> new tab</span>
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">⇧ ↵</kbd> all results</span>
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">esc</kbd> close</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Props for the GlobalSearch component.
|
||||
*/
|
||||
@@ -54,41 +67,46 @@ export interface GlobalSearchProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline header search with a dropdown of matching pages (apps, tables,
|
||||
* processes, reports by label), records found by record search (when available)
|
||||
* and recently viewed records.
|
||||
* Inline header search with a dropdown of grouped results: top hits, pages, records per
|
||||
* table (with counts and "see all" links), recently viewed records and recent searches.
|
||||
*
|
||||
* - With no text, the dropdown lists recently viewed records.
|
||||
* - ArrowUp/ArrowDown move the selection; Enter opens the selected result, or
|
||||
* the search page (`/app/search?q=…`) when nothing is selected; Escape closes.
|
||||
* Keyboard: ↓/↑ move the selection (the first result is selected while typing), Enter
|
||||
* opens it, Ctrl/⌘+Enter opens it in a new tab, Escape closes (and then clears).
|
||||
* "Search everything" (or Enter before results load) opens the search page.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A `<div>` containing the search combobox and, when open, a listbox dropdown.
|
||||
* @returns A `<div>` containing the search combobox and, when open, its results.
|
||||
*/
|
||||
export function GlobalSearch({ navTargets, searchTables = NO_SEARCH_TABLES, className }: GlobalSearchProps) {
|
||||
const router = useRouter()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
// mounted once opened, so the panel keeps its state (recents, cache) between openings
|
||||
const [panelMounted, setPanelMounted] = useState(false)
|
||||
const [term, setTerm] = useState('')
|
||||
const controllerRef = useRef<QuickSearchPanelController | null>(null)
|
||||
const [activeDescendant, setActiveDescendant] = useState<string>()
|
||||
const [hasContent, setHasContent] = useState(false)
|
||||
const recordSearchAvailable = searchTables.length > 0
|
||||
|
||||
// Load recent records when the dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setRecentRecords(getRecentRecords().slice(0, 10))
|
||||
}
|
||||
if (isOpen) setPanelMounted(true)
|
||||
}, [isOpen])
|
||||
|
||||
const recordSearch = useRecordSearch(isOpen ? searchTerm : '', searchTables, DROPDOWN_RECORDS_PER_TABLE)
|
||||
const items = useMemo(
|
||||
() => buildNavigationSearchItems(navTargets, recentRecords, searchTerm, 8, recordSearch.results),
|
||||
[navTargets, recentRecords, searchTerm, recordSearch.results]
|
||||
)
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
router.push(path)
|
||||
}, [router])
|
||||
|
||||
// Close dropdown on outside click
|
||||
const handleController = useCallback((controller: QuickSearchPanelController | null) => {
|
||||
controllerRef.current = controller
|
||||
setActiveDescendant(controller?.activeDescendant)
|
||||
setHasContent(controller?.hasContent ?? false)
|
||||
}, [])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Closes the dropdown when the user clicks outside the search container.
|
||||
@@ -97,130 +115,105 @@ export function GlobalSearch({ navTargets, searchTables = NO_SEARCH_TABLES, clas
|
||||
*/
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!(event.target instanceof Node)) return
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) setIsOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Reset selection when the result list changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
}, [items])
|
||||
|
||||
/**
|
||||
* Navigates to the given path, closes the dropdown, and clears the search term.
|
||||
* Keyboard handling: opens the dropdown, closes it on Escape (a second Escape clears the
|
||||
* text), and otherwise defers to the results panel (↑↓, Enter, Ctrl/⌘+Enter).
|
||||
*
|
||||
* @param path - The URL path to navigate to.
|
||||
* @param event - The input's keydown event.
|
||||
*/
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
setIsOpen(false)
|
||||
setSearchTerm('')
|
||||
router.push(path)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (isOpen) setIsOpen(false)
|
||||
else if (term) setTerm('')
|
||||
else inputRef.current?.blur()
|
||||
return
|
||||
}
|
||||
if (!isOpen && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
||||
event.preventDefault()
|
||||
setIsOpen(true)
|
||||
return
|
||||
}
|
||||
if (controllerRef.current && isOpen) {
|
||||
controllerRef.current.onInputKeyDown(event)
|
||||
} else if (event.key === 'Enter' && term.trim()) {
|
||||
// the panel is closed or still loading: Enter searches everything (never a hidden option)
|
||||
event.preventDefault()
|
||||
setTerm('')
|
||||
handleNavigate(searchPagePath({ q: term }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation within the search dropdown.
|
||||
*
|
||||
* @param e - The synthetic keyboard event from the text input.
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
handleNavigate(items[selectedIndex].path)
|
||||
} else if (searchTerm.trim()) {
|
||||
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
break
|
||||
}
|
||||
},
|
||||
[isOpen, selectedIndex, items, searchTerm, handleNavigate]
|
||||
)
|
||||
|
||||
const hasTerm = searchTerm.trim().length > 0
|
||||
const showDropdown = isOpen && (hasTerm || items.length > 0)
|
||||
const showDropdown = isOpen && (term.trim().length > 0 || hasContent)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative', className)} data-qqq-id="header-search">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 rounded-full border border-input bg-background px-4 py-2 text-sm transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1">
|
||||
<Search className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
value={term}
|
||||
onChange={(event) => {
|
||||
setTerm(event.target.value)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={recordSearch.available ? 'Search...' : 'Jump to...'}
|
||||
className="w-36 bg-transparent text-foreground placeholder:text-muted-foreground outline-none"
|
||||
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
placeholder={recordSearchAvailable ? 'Search…' : 'Jump to…'}
|
||||
className="w-36 bg-transparent text-foreground outline-none placeholder:text-muted-foreground lg:w-48 2xl:w-64"
|
||||
aria-label={recordSearchAvailable ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
aria-expanded={showDropdown}
|
||||
aria-controls="header-search-results"
|
||||
aria-controls={LISTBOX_ID}
|
||||
aria-activedescendant={showDropdown ? activeDescendant : undefined}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
data-qqq-id="input-header-search"
|
||||
/>
|
||||
{term && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTerm('')
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Clear search"
|
||||
data-qqq-id="button-header-search-clear"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full right-0 mt-2 w-[22rem] max-w-[80vw] overflow-hidden rounded-xl border border-border bg-card shadow-lg z-50">
|
||||
<NavigationSearchResults
|
||||
id="header-search-results"
|
||||
items={items}
|
||||
term={searchTerm}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleNavigate}
|
||||
onHover={setSelectedIndex}
|
||||
recordSearch={recordSearch}
|
||||
{panelMounted && (
|
||||
<div
|
||||
hidden={!showDropdown}
|
||||
className="absolute right-0 top-full z-50 mt-2 w-[min(36rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-border bg-card shadow-lg"
|
||||
data-qqq-id="header-search-dropdown"
|
||||
>
|
||||
<QuickSearchPanel
|
||||
listboxId={LISTBOX_ID}
|
||||
open={isOpen}
|
||||
term={term}
|
||||
setTerm={setTerm}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
onNavigate={handleNavigate}
|
||||
onController={handleController}
|
||||
listClassName="max-h-[min(70vh,32rem)]"
|
||||
footer={FOOTER}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
{hasTerm && (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
data-qqq-id="button-header-search-all"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
Show all matches
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -53,10 +53,14 @@ export interface HeaderProps {
|
||||
pathToLabelMap?: Record<string, string>
|
||||
/** Maps node paths to their enclosing apps, passed through to Breadcrumbs. */
|
||||
ancestorAppMap?: Record<string, ParentAppInfo[]>
|
||||
/** Where the breadcrumb home crumb links (the instance's home app, or `/app`). */
|
||||
homePath?: string
|
||||
/** Navigable app-tree nodes for the header search. */
|
||||
navTargets?: NavTarget[]
|
||||
/** Tables the backend record search covers, for the header search (empty: local search only). */
|
||||
searchTables?: SearchableTable[]
|
||||
/** The assistant toggle, shown before the help button when the assistant is available. */
|
||||
assistantButton?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +79,7 @@ export interface HeaderProps {
|
||||
* {@link GlobalSearch} + notifications bell on the right. The hamburger and
|
||||
* GlobalSearch are each conditionally visible based on the `md` breakpoint.
|
||||
*/
|
||||
export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, onSearchOpen, onCommandOpen, onHelpOpen, helpButtonRef, pathToLabelMap = {}, ancestorAppMap = {}, navTargets = [], searchTables }: HeaderProps) {
|
||||
export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, onSearchOpen, onCommandOpen, onHelpOpen, helpButtonRef, pathToLabelMap = {}, ancestorAppMap = {}, homePath, navTargets = [], searchTables, assistantButton }: HeaderProps) {
|
||||
const [notificationCount] = useState(0)
|
||||
// toasts appear below the header, never over its controls
|
||||
const headerRef = useRef<HTMLElement>(null)
|
||||
@@ -103,7 +107,7 @@ export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, on
|
||||
>
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} />
|
||||
<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} homePath={homePath} />
|
||||
</div>
|
||||
|
||||
{/* Right section: search + help + notifications */}
|
||||
@@ -139,6 +143,8 @@ export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, on
|
||||
</button>
|
||||
)}
|
||||
|
||||
{assistantButton}
|
||||
|
||||
{/* Keyboard shortcuts hint */}
|
||||
<button
|
||||
ref={helpButtonRef}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* 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 NavigationSearchResults — listbox of "jump to" results shared by the header search and the search dialog.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Clock } from 'lucide-react'
|
||||
|
||||
import type { RecordSearchState } from '@/lib/hooks/use-record-search'
|
||||
import type { NavigationSearchItem } from '@/lib/utils/navigation-search'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon, type MetadataIconKind } from './MetadataIcon'
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/** Props for {@link NavigationSearchResults}. */
|
||||
export interface NavigationSearchResultsProps {
|
||||
/** DOM id of the listbox (referenced by the combobox's `aria-controls`). */
|
||||
id: string
|
||||
/** Ordered results: pages first, then recent records. */
|
||||
items: NavigationSearchItem[]
|
||||
/** Current search text (for the empty message). */
|
||||
term: string
|
||||
/** Index of the highlighted item, or -1. */
|
||||
selectedIndex: number
|
||||
/** Called with a result path when an item is chosen. */
|
||||
onSelect: (path: string) => void
|
||||
/** Called with an index when the pointer moves over an item. */
|
||||
onHover: (index: number) => void
|
||||
/** Record search status; omit when the backend has no record search. */
|
||||
recordSearch?: Pick<RecordSearchState, 'available' | 'isSearching' | 'isError'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the matched part of a label.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.text - Label text.
|
||||
* @param props.term - Search text.
|
||||
* @returns The label with the first match wrapped in `<mark>`.
|
||||
*/
|
||||
function Highlighted({ text, term }: { text: string; term: string }) {
|
||||
const needle = term.trim().toLowerCase()
|
||||
const at = needle ? text.toLowerCase().indexOf(needle) : -1
|
||||
if (at < 0) return <>{text}</>
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, at)}
|
||||
<mark className="rounded-sm bg-primary/20 px-0.5 text-foreground">{text.slice(at, at + needle.length)}</mark>
|
||||
{text.slice(at + needle.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders grouped results ("Pages", "Records" found by record search, "Recently
|
||||
* viewed") as a listbox of options, with a polite status line for record search.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A `role="listbox"` element, with an empty message when nothing matches.
|
||||
*/
|
||||
export function NavigationSearchResults({ id, items, term, selectedIndex, onSelect, onHover, recordSearch }: NavigationSearchResultsProps) {
|
||||
const groups: Array<{ kind: NavigationSearchItem['kind']; heading: string }> = [
|
||||
{ kind: 'page', heading: 'Pages' },
|
||||
{ kind: 'result', heading: 'Records' },
|
||||
{ kind: 'record', heading: 'Recently viewed' },
|
||||
]
|
||||
const searching = recordSearch?.available === true && recordSearch.isSearching
|
||||
const status = searching
|
||||
? 'Searching records…'
|
||||
: recordSearch?.available && recordSearch.isError ? 'Record search failed. Try again.' : ''
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Record search progress and errors, announced politely (outside the listbox: not an option) */}
|
||||
<p role="status" aria-live="polite" className={cn('px-3 text-xs text-muted-foreground', status ? 'pt-2' : 'sr-only')} data-qqq-id="search-results-status">
|
||||
{status}
|
||||
</p>
|
||||
<div id={id} role="listbox" aria-label="Search results" aria-busy={searching} className="max-h-[400px] overflow-y-auto py-1" data-qqq-id="search-results">
|
||||
{items.length === 0 && !searching && (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground" data-qqq-id="search-results-empty">
|
||||
{recordSearch?.available ? 'No pages or records match' : 'No pages or recent records match'} “{term.trim()}”
|
||||
</p>
|
||||
)}
|
||||
{groups.map(({ kind, heading }) => {
|
||||
const group = items.map((item, index) => ({ item, index })).filter(({ item }) => item.kind === kind)
|
||||
if (group.length === 0) return null
|
||||
return (
|
||||
<div key={kind} role="group" aria-label={heading}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground" aria-hidden="true">
|
||||
{kind === 'record' && <Clock className="h-3 w-3" aria-hidden="true" />}
|
||||
{heading}
|
||||
</div>
|
||||
{group.map(({ item, index }) => (
|
||||
<button
|
||||
key={`${item.kind}-${item.path}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
onClick={() => onSelect(item.path)}
|
||||
// mousemove, not mouseenter: results rendering under a resting pointer must not steal the keyboard selection
|
||||
onMouseMove={() => { if (selectedIndex !== index) onHover(index) }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-3 py-2 text-left transition-colors',
|
||||
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
data-qqq-id={`search-result-${item.kind}-${item.path.replace(/\//g, '-')}`}
|
||||
>
|
||||
{item.kind === 'page' ? (
|
||||
<MetadataIcon icon={item.icon} kind={item.nodeType ? ICON_KIND[item.nodeType] : 'app'} className="text-muted-foreground" />
|
||||
) : item.kind === 'record' ? (
|
||||
// the record's table icon, as in Material's recently viewed menu
|
||||
<MetadataIcon icon={item.icon} kind="table" className="text-muted-foreground" />
|
||||
) : (
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[0.6rem] font-medium text-primary" aria-hidden="true">
|
||||
{item.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
<Highlighted text={item.label} term={term} />
|
||||
</span>
|
||||
{item.context && <span className="block truncate text-xs text-muted-foreground">{item.context}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -500,3 +500,13 @@ describe('Sidebar', () => {
|
||||
expect(document.querySelector('[data-qqq-id="sidebar-user-avatar"]')).toHaveTextContent('A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sidebar logo link', () => {
|
||||
it('links to the instance home app when there is one, and to the overview otherwise', () => {
|
||||
const { container, unmount } = render(<Sidebar routes={leafRoutes} homePath="/app/home" />)
|
||||
expect(container.querySelector('[data-qqq-id="sidebar-logo-link"]')).toHaveAttribute('href', '/app/home')
|
||||
unmount()
|
||||
const plain = render(<Sidebar routes={leafRoutes} />)
|
||||
expect(plain.container.querySelector('[data-qqq-id="sidebar-logo-link"]')).toHaveAttribute('href', '/app')
|
||||
})
|
||||
})
|
||||
@@ -106,6 +106,8 @@ export interface SidebarProps {
|
||||
routes: SidebarRoute[]
|
||||
/** Application branding metadata (logo, icon, app name, banners). */
|
||||
branding?: QBrandingMetaData
|
||||
/** Where the logo links: the instance's home app, or the `/app` overview (default). */
|
||||
homePath?: string
|
||||
/** Called when the mouse enters the sidebar (desktop hover expansion). */
|
||||
onMouseEnter?: () => void
|
||||
/** Called when the mouse leaves the sidebar (desktop hover collapse). */
|
||||
@@ -132,21 +134,22 @@ export interface SidebarProps {
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.branding - Branding metadata.
|
||||
* @param props.href - The home page the logo links to.
|
||||
* @returns The branding link element.
|
||||
*/
|
||||
function SidebarBranding({ branding }: { branding?: QBrandingMetaData }) {
|
||||
function SidebarBranding({ branding, href }: { branding?: QBrandingMetaData; href: string }) {
|
||||
const appName = branding?.appName || 'QQQ Admin'
|
||||
const linkClass = 'flex min-w-0 flex-1 items-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
if (branding?.logo) {
|
||||
return (
|
||||
<Link href="/app" className={linkClass} data-qqq-id="sidebar-logo-link">
|
||||
<Link href={href} className={linkClass} data-qqq-id="sidebar-logo-link">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- branding logos are arbitrary backend assets */}
|
||||
<img src={branding.logo} alt={appName} title={appName} className="max-h-12 w-full object-contain object-left" data-qqq-id="sidebar-logo" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link href="/app" className={linkClass} data-qqq-id="sidebar-logo-link">
|
||||
<Link href={href} className={linkClass} data-qqq-id="sidebar-logo-link">
|
||||
{branding?.icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- branding icons are arbitrary backend assets
|
||||
<img src={branding.icon} alt="" className="h-12 w-12 flex-shrink-0 object-contain" data-qqq-id="sidebar-icon" />
|
||||
@@ -182,6 +185,7 @@ function SidebarBranding({ branding }: { branding?: QBrandingMetaData }) {
|
||||
export default function Sidebar({
|
||||
routes,
|
||||
branding,
|
||||
homePath = '/app',
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
logout,
|
||||
@@ -238,7 +242,7 @@ export default function Sidebar({
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-qqq-id="sidenav-root">
|
||||
{/* Logo / App Branding — height matches header so border lines up */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4" style={{ height: 'var(--qqq-header-height)' }} data-qqq-id="sidenav-logo-area">
|
||||
<SidebarBranding branding={branding} />
|
||||
<SidebarBranding branding={branding} href={homePath} />
|
||||
{/* Close button for mobile drawer */}
|
||||
{isMobileDrawer && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file FileKindIcon — the file-type icon shown for a FILE_PREVIEW value that has no image thumbnail.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { File, FileAudio, FileImage, FileText, FileVideo } from 'lucide-react'
|
||||
|
||||
import type { FilePreviewKind } from '@/lib/utils/file-preview'
|
||||
|
||||
const ICONS: Record<FilePreviewKind, typeof File> = {
|
||||
image: FileImage,
|
||||
pdf: FileText,
|
||||
video: FileVideo,
|
||||
audio: FileAudio,
|
||||
other: File,
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorative icon for a kind of file.
|
||||
*
|
||||
* @param props - The kind and classes.
|
||||
* @returns The icon (hidden from assistive technology).
|
||||
*/
|
||||
export function FileKindIcon({ kind, className }: { kind: FilePreviewKind; className?: string }) {
|
||||
const Icon = ICONS[kind]
|
||||
return <Icon className={className} aria-hidden="true" data-file-kind={kind} />
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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_PREVIEW stories: thumbnails as the query grid and the record view show them, and the
|
||||
* preview lightbox they open (arrow keys step through the gallery).
|
||||
*/
|
||||
import React from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import { DataCell } from '@/components/query/DataCell'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
import { FilePreviewGallery } from './FilePreviewGallery'
|
||||
|
||||
/** An inline SVG "photo", so the stories need no network. */
|
||||
function swatch(from: string, to: string, label: string): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="1200" height="800" fill="url(#g)"/><text x="600" y="430" font-family="sans-serif" font-size="96" fill="white" text-anchor="middle">${label}</text></svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
const field: QFieldMetaData = {
|
||||
name: 'originalUrl', label: 'Image', type: 'STRING', isEditable: false, isRequired: false, isHeavy: false, isHidden: false,
|
||||
adornments: [{
|
||||
type: 'FILE_PREVIEW',
|
||||
values: { fileNameField: 'fileName', mimeTypeField: 'mimeType', widthField: 'width', heightField: 'height', sizeBytesField: 'fileSize', captionField: 'caption' },
|
||||
}],
|
||||
}
|
||||
|
||||
const records: QRecord[] = [
|
||||
{ tableName: 'media', values: { originalUrl: swatch('#0ea5e9', '#6366f1', 'Harbor'), fileName: 'harbor.svg', mimeType: 'image/svg+xml', width: 1200, height: 800, fileSize: 48213, caption: 'Morning at the harbor' } },
|
||||
{ tableName: 'media', values: { originalUrl: swatch('#f97316', '#db2777', 'Sunset'), fileName: 'sunset.svg', mimeType: 'image/svg+xml', width: 1200, height: 800, fileSize: 51022 } },
|
||||
{ tableName: 'media', values: { originalUrl: '/files/annual-report.pdf', fileName: 'annual-report.pdf', mimeType: 'application/pdf', fileSize: 1832000 } },
|
||||
]
|
||||
|
||||
/** Thumbnails in a grid-like list; click one (or focus it and press Enter) to preview. */
|
||||
function GridThumbnails() {
|
||||
return (
|
||||
<FilePreviewGallery records={records} fields={[field]}>
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{records.map((record) => (
|
||||
<tr key={String(record.values.fileName)} className="border-b border-border">
|
||||
<td className="p-2"><DataCell field={field} value={record.values.originalUrl} displayValue={undefined} record={record} /></td>
|
||||
<td className="p-2">{String(record.values.fileName)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Records/FilePreview',
|
||||
component: GridThumbnails,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'FILE_PREVIEW adornment: fixed-size lazy thumbnails (file-type icons for non-images) that open an accessible preview lightbox with zoom, details, open/download and arrow-key navigation.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof GridThumbnails>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/** Query-grid thumbnails (40 px, the default `thumbnailMaxSize`). */
|
||||
export const QueryGrid: Story = {}
|
||||
|
||||
/** A record view value: a larger thumbnail with the file name. */
|
||||
export const RecordView: Story = {
|
||||
render: () => (
|
||||
<FilePreviewGallery records={[records[0]]} fields={[field]}>
|
||||
<FieldValue field={field} record={records[0]} thumbnailSize={160} />
|
||||
</FilePreviewGallery>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* 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 { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
import type { FieldAdornment, QFieldMetaData, QRecord } from '@/types'
|
||||
import { filePreview } from '@/lib/utils/file-preview'
|
||||
import { DataCell } from '@/components/query/DataCell'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
import { FilePreviewGallery } from './FilePreviewGallery'
|
||||
import { FileThumbnail } from './FileThumbnail'
|
||||
import { FilePreviewLightbox } from './FilePreviewLightbox'
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
function field(name: string, values: Record<string, unknown> = {}, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
|
||||
const adornments: FieldAdornment[] = [{ type: 'FILE_PREVIEW', values }]
|
||||
return { name, label: name, type: 'STRING', isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments, ...extra }
|
||||
}
|
||||
|
||||
function record(values: Record<string, unknown>, displayValues: Record<string, string> = {}): QRecord {
|
||||
return { tableName: 'media', values, displayValues }
|
||||
}
|
||||
|
||||
function item(overrides: Partial<FilePreviewItem> = {}): FilePreviewItem {
|
||||
return { url: '/files/a.png', thumbnailUrl: '/files/a-t.png', fileName: 'a.png', mimeType: 'image/png', kind: 'image', alt: 'a.png', previewable: true, thumbnailSize: 40, ...overrides }
|
||||
}
|
||||
|
||||
const media = field('url', { thumbnailUrlField: 'thumb', fileNameField: 'name', mimeTypeField: 'type', widthField: 'w', heightField: 'h', sizeBytesField: 'size', captionField: 'caption' })
|
||||
const rows = [
|
||||
record({ url: '/files/one.jpg', thumb: '/files/one-t.jpg', name: 'one.jpg', type: 'image/jpeg', w: 1200, h: 800, size: 204800, caption: 'First' }),
|
||||
record({ url: '/qqq/v1/table/media/2/blob/two.pdf', name: 'two.pdf', type: 'application/pdf' }),
|
||||
record({ url: '/files/three.png', name: 'three.png', type: 'image/png' }),
|
||||
]
|
||||
|
||||
/**
|
||||
* Renders the thumbnails of rows inside a gallery, as a grid would.
|
||||
*/
|
||||
function Gallery({ onRowClick }: { onRowClick?: () => void }) {
|
||||
return (
|
||||
<FilePreviewGallery records={rows} fields={[media]}>
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} onClick={onRowClick} data-testid={`row-${index}`}>
|
||||
<DataCell field={media} value={row.values.url} displayValue={undefined} record={row} />
|
||||
</div>
|
||||
))}
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileThumbnail (QRun-IO/qqq#1056)', () => {
|
||||
it('renders a fixed-size lazy thumbnail with a loading pulse until the image loads', () => {
|
||||
render(<FileThumbnail field={media} record={rows[0]} item={filePreview(media, rows[0])!} dataQqqId="grid-cell-url" />)
|
||||
const button = screen.getByRole('button', { name: 'Preview one.jpg' })
|
||||
expect(button).toHaveAttribute('aria-haspopup', 'dialog')
|
||||
const box = document.querySelector('[data-qqq-id="grid-cell-url-thumbnail"]') as HTMLElement
|
||||
expect(box.style.width).toBe('40px')
|
||||
expect(box.style.height).toBe('40px')
|
||||
const image = box.querySelector('img') as HTMLImageElement
|
||||
expect(image).toHaveAttribute('src', '/files/one-t.jpg')
|
||||
expect(image).toHaveAttribute('loading', 'lazy')
|
||||
expect(image).toHaveAttribute('alt', '')
|
||||
expect(document.querySelector('[data-qqq-id="grid-cell-url-thumbnail-loading"]')).not.toBeNull()
|
||||
fireEvent.load(image)
|
||||
expect(document.querySelector('[data-qqq-id="grid-cell-url-thumbnail-loading"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the file-type icon when the image fails or the file is not an image', () => {
|
||||
render(<FileThumbnail field={media} record={rows[0]} item={filePreview(media, rows[0])!} dataQqqId="t1" />)
|
||||
fireEvent.error(document.querySelector('[data-qqq-id="t1-thumbnail"] img')!)
|
||||
expect(document.querySelector('[data-qqq-id="t1-thumbnail"] img')).toBeNull()
|
||||
expect(document.querySelector('[data-qqq-id="t1-thumbnail"] [data-file-kind="image"]')).not.toBeNull()
|
||||
render(<FileThumbnail field={media} record={rows[1]} item={filePreview(media, rows[1])!} dataQqqId="t2" />)
|
||||
expect(document.querySelector('[data-qqq-id="t2-thumbnail"] [data-file-kind="pdf"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('is not a button when previewable is false, and keeps the alt text', () => {
|
||||
const still = field('url', { previewable: false })
|
||||
const row = record({ url: '/files/x.png' })
|
||||
render(<FileThumbnail field={still} record={row} item={filePreview(still, row)!} size={160} showName dataQqqId="t3" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByRole('img', { name: 'x.png' })).toBeInTheDocument()
|
||||
expect(screen.getByText('x.png')).toBeInTheDocument()
|
||||
expect((document.querySelector('[data-qqq-id="t3-thumbnail"]') as HTMLElement).style.width).toBe('160px')
|
||||
})
|
||||
|
||||
it('opens a preview of just its file outside a gallery', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<FileThumbnail field={media} record={rows[2]} item={filePreview(media, rows[2])!} dataQqqId="solo" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Preview three.png' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText('three.png')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('Preview')).toBeInTheDocument()
|
||||
expect(within(dialog).queryByRole('button', { name: 'Next file' })).toBeNull()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FilePreviewGallery', () => {
|
||||
it('opens at the clicked value without triggering the row, steps with the arrow keys and returns focus on Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRowClick = vi.fn()
|
||||
render(<Gallery onRowClick={onRowClick} />)
|
||||
const first = screen.getByRole('button', { name: 'Preview one.jpg' })
|
||||
await user.click(first)
|
||||
expect(onRowClick).not.toHaveBeenCalled()
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByRole('heading', { name: 'one.jpg' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
|
||||
// the caption is the alt text when there is no alt text field
|
||||
expect(within(dialog).getByRole('img', { name: 'First' })).toHaveAttribute('src', '/files/one.jpg')
|
||||
// details: type, size, dimensions and caption
|
||||
const details = document.querySelector('[data-qqq-id="file-preview-details"]') as HTMLElement
|
||||
expect(details).toHaveTextContent('First')
|
||||
expect(details).toHaveTextContent('image/jpeg')
|
||||
expect(details).toHaveTextContent('200 KB')
|
||||
expect(details).toHaveTextContent('1200 × 800 px')
|
||||
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(within(dialog).getByRole('heading', { name: 'two.pdf' })).toBeInTheDocument()
|
||||
expect(dialog).toHaveAttribute('data-preview-kind', 'pdf')
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toHaveAttribute('src', '/qqq/v1/table/media/2/blob/two.pdf')
|
||||
|
||||
await user.keyboard('{End}')
|
||||
expect(within(dialog).getByText('3 of 3')).toBeInTheDocument()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
expect(within(dialog).getByText('3 of 3')).toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Previous file' }))
|
||||
expect(within(dialog).getByText('2 of 3')).toBeInTheDocument()
|
||||
await user.keyboard('{Home}')
|
||||
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(first).toHaveFocus()
|
||||
expect(onRowClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens from the keyboard without the key reaching the row', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onKeyDown = vi.fn()
|
||||
render(
|
||||
<div onKeyDown={onKeyDown}><Gallery /></div>,
|
||||
)
|
||||
screen.getByRole('button', { name: 'Preview three.png' }).focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('dialog')).getByText('3 of 3')).toBeInTheDocument()
|
||||
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: 'Enter' }))
|
||||
})
|
||||
|
||||
it('has no accessibility violations with the preview open', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { baseElement } = render(<Gallery />)
|
||||
expect(await axe(baseElement)).toHaveNoViolations()
|
||||
await user.click(screen.getByRole('button', { name: 'Preview one.jpg' }))
|
||||
await screen.findByRole('dialog')
|
||||
expect(await axe(baseElement)).toHaveNoViolations()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FilePreviewLightbox', () => {
|
||||
function show(items: FilePreviewItem[], index = 0) {
|
||||
const onIndexChange = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<FilePreviewLightbox items={items} index={index} onIndexChange={onIndexChange} onClose={onClose} />)
|
||||
return { onIndexChange, onClose, dialog: screen.getByRole('dialog') }
|
||||
}
|
||||
|
||||
it('zooms images with the buttons, keys and a double click, and resets to fit', async () => {
|
||||
const user = userEvent.setup()
|
||||
show([item()])
|
||||
const image = screen.getByRole('img', { name: 'a.png' })
|
||||
expect(image).toHaveAttribute('data-zoom', '1')
|
||||
expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
expect(image).toHaveAttribute('data-zoom', '1.5')
|
||||
await user.keyboard('+')
|
||||
expect(image).toHaveAttribute('data-zoom', '2')
|
||||
await user.keyboard('-')
|
||||
expect(image).toHaveAttribute('data-zoom', '1.5')
|
||||
await user.keyboard('0')
|
||||
expect(image).toHaveAttribute('data-zoom', '1')
|
||||
fireEvent.doubleClick(image)
|
||||
expect(image).toHaveAttribute('data-zoom', '2')
|
||||
await user.click(screen.getByRole('button', { name: 'Zoom 200%, fit to window' }))
|
||||
expect(image).toHaveAttribute('data-zoom', '1')
|
||||
for (let i = 0; i < 6; i++) await user.click(screen.getByRole('button', { name: 'Zoom in' }).closest('button')!)
|
||||
expect(image).toHaveAttribute('data-zoom', '4')
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('applies the zoom as a width once the image has its fitted size', () => {
|
||||
show([item()])
|
||||
const image = screen.getByRole('img', { name: 'a.png' }) as HTMLImageElement
|
||||
Object.defineProperty(image, 'naturalWidth', { value: 2000 })
|
||||
Object.defineProperty(image, 'naturalHeight', { value: 1000 })
|
||||
image.getBoundingClientRect = () => ({ width: 500, height: 250 } as DOMRect)
|
||||
fireEvent.load(image)
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-details"]')).toHaveTextContent('2000 × 1000 px')
|
||||
fireEvent.doubleClick(image)
|
||||
expect(image.style.width).toBe('1000px')
|
||||
})
|
||||
|
||||
it('offers open-original and download links (attachment for backend files)', () => {
|
||||
show([item({ url: '/qqq/v1/table/media/1/blob/a.png', fileName: 'a.png' })])
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toHaveAttribute('href', '/qqq/v1/table/media/1/blob/a.png')
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', '/qqq/v1/table/media/1/blob/a.png?download=1')
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('download', 'a.png')
|
||||
})
|
||||
|
||||
it('falls back to the file card for an image that fails to load', () => {
|
||||
show([item()])
|
||||
fireEvent.error(screen.getByRole('img', { name: 'a.png' }))
|
||||
expect(screen.getByText('This file could not be loaded.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeNull()
|
||||
})
|
||||
|
||||
it('explains types it cannot show', () => {
|
||||
show([item({ kind: 'other', mimeType: 'application/zip', fileName: 'b.zip', url: '/files/b.zip' })])
|
||||
expect(screen.getByText('No preview is available for this type of file.')).toBeInTheDocument()
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', '/files/b.zip')
|
||||
})
|
||||
|
||||
it('does not frame a PDF from another origin', () => {
|
||||
show([item({ kind: 'pdf', mimeType: 'application/pdf', fileName: 'c.pdf', url: 'https://elsewhere.example/c.pdf' })])
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toBeNull()
|
||||
expect(screen.getByText(/opens in a new tab instead/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('frames only backend file paths, not other pages of this origin', () => {
|
||||
show([item({ kind: 'pdf', mimeType: 'application/pdf', fileName: 'd.pdf', url: '/app/media/1' })])
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toBeNull()
|
||||
expect(screen.getByText(/not a file served by the application/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('plays video and audio, falling back when the player cannot load them', () => {
|
||||
show([item({ kind: 'video', mimeType: 'video/mp4', fileName: 'v.mp4', url: '/files/v.mp4' }), item({ kind: 'audio', mimeType: 'audio/mpeg', fileName: 's.mp3', url: '/files/s.mp3' })])
|
||||
const video = document.querySelector('[data-qqq-id="file-preview-video"]') as HTMLVideoElement
|
||||
expect(video).toHaveAttribute('src', '/files/v.mp4')
|
||||
expect(video).toHaveAttribute('controls')
|
||||
// arrow keys used inside the player are its own
|
||||
fireEvent.keyDown(video, { key: 'ArrowRight' })
|
||||
act(() => { fireEvent.error(video) })
|
||||
expect(screen.getByText('This file could not be loaded.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an audio player', () => {
|
||||
show([item({ kind: 'audio', mimeType: 'audio/mpeg', fileName: 's.mp3', url: '/files/s.mp3' })])
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-audio"]')).toHaveAttribute('src', '/files/s.mp3')
|
||||
})
|
||||
|
||||
it('reports navigation and closing to its owner, ignoring modified keys', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onIndexChange, onClose } = show([item(), item({ fileName: 'b.png', url: '/b.png' })], 1)
|
||||
await user.keyboard('{Control>}{ArrowRight}{/Control}')
|
||||
expect(onIndexChange).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Next file' }))
|
||||
expect(onIndexChange).toHaveBeenLastCalledWith(0)
|
||||
await user.click(screen.getByRole('button', { name: 'Close preview' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FILE_PREVIEW in the shared value renderers', () => {
|
||||
function renderValue(element: React.ReactElement) {
|
||||
return render(<QueryClientProvider client={new QueryClient()}>{element}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
it('DataCell shows a thumbnail in place of other adornments, and the usual value without a URL', () => {
|
||||
const both = { ...media, adornments: [{ type: 'LINK' as const }, ...media.adornments] }
|
||||
renderValue(<DataCell field={both} value={rows[0].values.url} displayValue={undefined} record={rows[0]} />)
|
||||
expect(document.querySelector('[data-qqq-id="grid-cell-url"]')).toHaveAttribute('data-preview-kind', 'image')
|
||||
const empty = record({ url: 'javascript:alert(1)' })
|
||||
renderValue(<DataCell field={field('bad')} value={empty.values.bad} displayValue={undefined} record={empty} />)
|
||||
expect(document.querySelector('[data-qqq-id="grid-cell-bad"]')).toHaveTextContent('—')
|
||||
})
|
||||
|
||||
it('FieldValue shows a record-view thumbnail with the file name, and an em dash without a file', () => {
|
||||
renderValue(<FieldValue field={media} record={rows[0]} thumbnailSize={160} />)
|
||||
const value = document.querySelector('[data-qqq-id="field-value-url"]') as HTMLElement
|
||||
expect(value).toHaveTextContent('one.jpg')
|
||||
expect((document.querySelector('[data-qqq-id="field-value-url-thumbnail"]') as HTMLElement).style.width).toBe('160px')
|
||||
renderValue(<FieldValue field={field('none')} record={record({ none: null })} />)
|
||||
expect(document.querySelector('[data-qqq-id="field-value-none"]')).toHaveTextContent('—')
|
||||
})
|
||||
})
|
||||
@@ -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 FilePreviewGallery — groups the FILE_PREVIEW values of a set of records (a grid page, a
|
||||
* child list, a record) so the preview lightbox steps through them with the arrow keys. The
|
||||
* lightbox code loads only when a preview first opens.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { createContext, lazy, Suspense, useCallback, useContext, useMemo, useState } from 'react'
|
||||
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
import { filePreviewItems } from '@/lib/utils/file-preview'
|
||||
|
||||
const FilePreviewLightbox = lazy(() => import('./FilePreviewLightbox'))
|
||||
|
||||
/** What a thumbnail asks of the gallery around it. */
|
||||
interface FilePreviewGalleryContextValue {
|
||||
/**
|
||||
* Opens the preview at a record's field.
|
||||
*
|
||||
* @returns `false` when the value is not part of this gallery.
|
||||
*/
|
||||
open: (record: QRecord, field: QFieldMetaData) => boolean
|
||||
}
|
||||
|
||||
const FilePreviewGalleryContext = createContext<FilePreviewGalleryContextValue | null>(null)
|
||||
|
||||
/** Props for {@link FilePreviewGallery}. */
|
||||
interface FilePreviewGalleryProps {
|
||||
/** Records on screen, in display order. */
|
||||
records: QRecord[]
|
||||
/** Fields shown for them, in display order (fields without FILE_PREVIEW are ignored). */
|
||||
fields: QFieldMetaData[]
|
||||
/** Selected table variant as v1 JSON, for backend file URLs. */
|
||||
tableVariant?: string
|
||||
/** The content holding the thumbnails. */
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the gallery to the thumbnails rendered inside it.
|
||||
*
|
||||
* @param props - See {@link FilePreviewGalleryProps}.
|
||||
* @returns The children, plus the lightbox while one is open.
|
||||
*/
|
||||
export function FilePreviewGallery({ records, fields, tableVariant, children }: FilePreviewGalleryProps) {
|
||||
const entries = useMemo(() => filePreviewItems(records, fields, tableVariant), [records, fields, tableVariant])
|
||||
const [index, setIndex] = useState<number | null>(null)
|
||||
|
||||
const open = useCallback((record: QRecord, field: QFieldMetaData) => {
|
||||
const position = entries.findIndex((entry) => entry.record === record && entry.field.name === field.name)
|
||||
if (position < 0) return false
|
||||
setIndex(position)
|
||||
return true
|
||||
}, [entries])
|
||||
|
||||
const value = useMemo(() => ({ open }), [open])
|
||||
const items = useMemo(() => entries.map((entry) => entry.item), [entries])
|
||||
|
||||
return (
|
||||
<FilePreviewGalleryContext.Provider value={value}>
|
||||
{children}
|
||||
{index !== null && items.length > 0 && (
|
||||
<LightboxHost items={items} index={Math.min(index, items.length - 1)} onIndexChange={setIndex} onClose={() => setIndex(null)} />
|
||||
)}
|
||||
</FilePreviewGalleryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery around a thumbnail, if any.
|
||||
*
|
||||
* @returns The gallery, or `null` outside one.
|
||||
*/
|
||||
export function useFilePreviewGallery(): FilePreviewGalleryContextValue | null {
|
||||
return useContext(FilePreviewGalleryContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and shows the lightbox.
|
||||
*
|
||||
* @param props - The lightbox props.
|
||||
* @returns The lazily loaded lightbox.
|
||||
*/
|
||||
export function LightboxHost(props: { items: FilePreviewItem[]; index: number; onIndexChange: (index: number) => void; onClose: () => void }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<FilePreviewLightbox {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -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 FilePreviewLightbox — the accessible preview dialog for FILE_PREVIEW values: the full-size
|
||||
* image with zoom, a same-origin PDF, a video or audio player, or a file card with open and
|
||||
* download links; with the file's details and arrow-key navigation across the gallery. Loaded
|
||||
* on demand by {@link FilePreviewGallery}.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { ChevronLeft, ChevronRight, Download, ExternalLink, Maximize2, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
import { apiUrl } from '@/lib/api/client'
|
||||
import { attachmentUrl } from '@/lib/utils/adornment-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { formatBytes, isBackendFileUrl, isSameOrigin } from '@/lib/utils/file-preview'
|
||||
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
|
||||
import { FileKindIcon } from './FileKindIcon'
|
||||
|
||||
/** Zoom steps of an image preview (1 = fit to the window). */
|
||||
export const ZOOM_STEPS = [1, 1.5, 2, 3, 4]
|
||||
|
||||
/** Props for {@link FilePreviewLightbox}. */
|
||||
export interface FilePreviewLightboxProps {
|
||||
/** The files the preview steps through. */
|
||||
items: FilePreviewItem[]
|
||||
/** Index of the file shown. */
|
||||
index: number
|
||||
/** Called with the index to show next. */
|
||||
onIndexChange: (index: number) => void
|
||||
/** Called when the preview closes. */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview dialog. Escape closes it, focus is trapped while it is open and returns to the
|
||||
* thumbnail that opened it; Left/Right (and Home/End) step through the items; `+`, `-` and `0`
|
||||
* zoom an image, as do the toolbar buttons and a double click.
|
||||
*
|
||||
* @param props - See {@link FilePreviewLightboxProps}.
|
||||
* @returns The dialog.
|
||||
*/
|
||||
export function FilePreviewLightbox({ items, index, onIndexChange, onClose }: FilePreviewLightboxProps) {
|
||||
const restoreFocus = useRestoreFocus(true)
|
||||
const item = items[Math.min(Math.max(index, 0), items.length - 1)]
|
||||
const count = items.length
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setZoom(1)
|
||||
setFailed(false)
|
||||
setNatural(null)
|
||||
}, [index])
|
||||
|
||||
// warm the browser cache with the neighbouring images, so stepping through is instant
|
||||
useEffect(() => {
|
||||
if (count < 2 || typeof Image === 'undefined') return
|
||||
for (const neighbour of [items[(index + 1) % count], items[(index - 1 + count) % count]]) {
|
||||
if (neighbour?.kind === 'image' && neighbour.url) new Image().src = neighbour.url
|
||||
}
|
||||
}, [items, index, count])
|
||||
|
||||
const go = useCallback((delta: number) => {
|
||||
if (count > 1) onIndexChange((index + delta + count) % count)
|
||||
}, [count, index, onIndexChange])
|
||||
|
||||
const zoomBy = useCallback((direction: 1 | -1) => {
|
||||
setZoom((current) => {
|
||||
const position = ZOOM_STEPS.indexOf(current)
|
||||
const next = ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, Math.max(0, position + direction))]
|
||||
return next ?? 1
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!item) return null
|
||||
const isImage = item.kind === 'image' && !failed
|
||||
const width = item.width ?? natural?.width
|
||||
const height = item.height ?? natural?.height
|
||||
|
||||
/**
|
||||
* Keyboard navigation and zoom; keys typed into a media player's controls are left to it.
|
||||
*
|
||||
* @param event - The key event.
|
||||
*/
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('video, audio, iframe, input, textarea, select')) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
let handled = true
|
||||
if (event.key === 'ArrowRight') go(1)
|
||||
else if (event.key === 'ArrowLeft') go(-1)
|
||||
else if (event.key === 'Home' && count > 1) onIndexChange(0)
|
||||
else if (event.key === 'End' && count > 1) onIndexChange(count - 1)
|
||||
else if (isImage && (event.key === '+' || event.key === '=')) zoomBy(1)
|
||||
else if (isImage && event.key === '-') zoomBy(-1)
|
||||
else if (isImage && event.key === '0') setZoom(1)
|
||||
else handled = false
|
||||
if (handled) event.preventDefault()
|
||||
}
|
||||
|
||||
const details = [
|
||||
item.mimeType && { label: 'Type', value: item.mimeType },
|
||||
item.sizeBytes !== undefined && { label: 'Size', value: formatBytes(item.sizeBytes) },
|
||||
width && height && { label: 'Dimensions', value: `${width} × ${height} px` },
|
||||
].filter(Boolean) as Array<{ label: string; value: string }>
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose() }}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-[2000] bg-black/80" />
|
||||
<Dialog.Content
|
||||
onCloseAutoFocus={restoreFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed inset-2 z-[2001] flex flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-xl focus:outline-none sm:inset-6"
|
||||
data-qqq-id="file-preview-dialog"
|
||||
data-preview-kind={item.kind}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Dialog.Title className="truncate text-sm font-semibold" title={item.fileName} data-qqq-id="file-preview-title">
|
||||
{item.fileName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-xs text-muted-foreground" aria-live="polite" data-qqq-id="file-preview-position">
|
||||
{count > 1 ? `${index + 1} of ${count}` : 'Preview'}
|
||||
{count > 1 && <span className="sr-only">. Use the left and right arrow keys to see the others.</span>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" role="toolbar" aria-label="Preview actions">
|
||||
{isImage && (
|
||||
<>
|
||||
<ToolbarButton label="Zoom out" onClick={() => zoomBy(-1)} disabled={zoom <= ZOOM_STEPS[0]} id="zoom-out"><ZoomOut className="h-4 w-4" aria-hidden="true" /></ToolbarButton>
|
||||
<ToolbarButton label={zoom === 1 ? 'Fit to window' : `Zoom ${Math.round(zoom * 100)}%, fit to window`} onClick={() => setZoom(1)} id="zoom-fit">
|
||||
<Maximize2 className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="ml-1 min-w-9 text-xs tabular-nums" aria-hidden="true">{Math.round(zoom * 100)}%</span>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label="Zoom in" onClick={() => zoomBy(1)} disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]} id="zoom-in"><ZoomIn className="h-4 w-4" aria-hidden="true" /></ToolbarButton>
|
||||
</>
|
||||
)}
|
||||
<FileActions item={item} />
|
||||
<Dialog.Close asChild>
|
||||
<button type="button" aria-label="Close preview" data-qqq-id="file-preview-close"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1 bg-muted/40">
|
||||
<PreviewStage item={item} zoom={zoom} failed={failed} onFailed={() => setFailed(true)}
|
||||
onNaturalSize={setNatural} onToggleZoom={() => setZoom((current) => (current === 1 ? 2 : 1))} />
|
||||
{count > 1 && (
|
||||
<>
|
||||
<NavButton side="left" label="Previous file" onClick={() => go(-1)}><ChevronLeft className="h-5 w-5" aria-hidden="true" /></NavButton>
|
||||
<NavButton side="right" label="Next file" onClick={() => go(1)}><ChevronRight className="h-5 w-5" aria-hidden="true" /></NavButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(details.length > 0 || item.caption) && (
|
||||
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 border-t border-border px-3 py-2 text-xs sm:px-4" data-qqq-id="file-preview-details">
|
||||
{item.caption && <p className="w-full text-sm text-foreground">{item.caption}</p>}
|
||||
{details.length > 0 && (
|
||||
<dl className="flex flex-wrap gap-x-6 gap-y-1">
|
||||
{details.map((detail) => (
|
||||
<div key={detail.label} className="flex gap-1.5">
|
||||
<dt className="text-muted-foreground">{detail.label}</dt>
|
||||
<dd className="font-medium tabular-nums">{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The previewed file itself, by kind.
|
||||
*
|
||||
* @param props - The item, zoom and callbacks.
|
||||
* @returns The stage content.
|
||||
*/
|
||||
function PreviewStage({ item, zoom, failed, onFailed, onNaturalSize, onToggleZoom }: {
|
||||
item: FilePreviewItem
|
||||
zoom: number
|
||||
failed: boolean
|
||||
onFailed: () => void
|
||||
onNaturalSize: (size: { width: number; height: number }) => void
|
||||
onToggleZoom: () => void
|
||||
}) {
|
||||
const [fitWidth, setFitWidth] = useState<number | null>(null)
|
||||
|
||||
if (failed || item.kind === 'other' || !item.url) {
|
||||
return <Unavailable item={item} reason={failed ? 'This file could not be loaded.' : 'No preview is available for this type of file.'} />
|
||||
}
|
||||
|
||||
if (item.kind === 'image') {
|
||||
const zoomed = zoom > 1 && fitWidth
|
||||
return (
|
||||
<div className={cn('absolute inset-0 overflow-auto p-4', !zoomed && 'flex items-center justify-center')} data-qqq-id="file-preview-stage">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer */}
|
||||
<img
|
||||
key={item.url}
|
||||
src={item.url}
|
||||
alt={item.alt}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onDoubleClick={onToggleZoom}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
onNaturalSize({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
setFitWidth(image.getBoundingClientRect().width || null)
|
||||
}}
|
||||
onError={onFailed}
|
||||
className={cn('select-none', zoomed ? 'mx-auto max-w-none cursor-zoom-out' : 'max-h-full max-w-full cursor-zoom-in object-contain')}
|
||||
style={zoomed ? { width: `${Math.round(fitWidth * zoom)}px` } : undefined}
|
||||
data-qqq-id="file-preview-image"
|
||||
data-zoom={zoom}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.kind === 'pdf') {
|
||||
// frames are limited to the dashboard's own origin by its Content-Security-Policy, and within
|
||||
// it to backend file paths, so a stored URL cannot frame another page of the dashboard
|
||||
if (!isSameOrigin(item.url)) {
|
||||
return <Unavailable item={item} reason="This PDF is on another site, so it opens in a new tab instead of here." />
|
||||
}
|
||||
if (!isBackendFileUrl(item.url)) {
|
||||
return <Unavailable item={item} reason="This PDF is not a file served by the application, so it opens in a new tab instead of here." />
|
||||
}
|
||||
return <iframe src={item.url} title={item.fileName} className="absolute inset-0 h-full w-full border-0 bg-white" data-qqq-id="file-preview-pdf" />
|
||||
}
|
||||
|
||||
if (item.kind === 'video') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<video key={item.url} src={item.url} controls preload="metadata" className="max-h-full max-w-full" onError={onFailed} data-qqq-id="file-preview-video">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 p-4">
|
||||
<FileKindIcon kind="audio" className="h-16 w-16 text-muted-foreground" />
|
||||
<audio key={item.url} src={item.url} controls preload="metadata" className="w-full max-w-md" onError={onFailed} data-qqq-id="file-preview-audio" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback for a file that cannot be shown here: its icon, name and open/download links.
|
||||
*
|
||||
* @param props - The item and why it is not shown.
|
||||
* @returns The fallback panel.
|
||||
*/
|
||||
function Unavailable({ item, reason }: { item: FilePreviewItem; reason: string }) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center" data-qqq-id="file-preview-unavailable">
|
||||
<FileKindIcon kind={item.kind} className="h-16 w-16 text-muted-foreground" />
|
||||
<p className="max-w-md break-all text-sm font-medium">{item.fileName}</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{reason}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-in-new-tab and download links for the full-size file.
|
||||
*
|
||||
* @param props - The item.
|
||||
* @returns The links.
|
||||
*/
|
||||
function FileActions({ item }: { item: FilePreviewItem }) {
|
||||
if (!item.url) return null
|
||||
// backend field downloads honour ?download=1; other URLs rely on the download attribute (same origin)
|
||||
const backendFile = item.url.startsWith(apiUrl('/table/'))
|
||||
const downloadHref = backendFile ? attachmentUrl(item.url) : item.url
|
||||
const linkClass = 'inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
return (
|
||||
<>
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer" className={linkClass} title="Open original" data-qqq-id="file-preview-open">
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Open original (opens in a new tab)</span>
|
||||
</a>
|
||||
<a href={downloadHref} download={item.fileName} className={linkClass} title="Download" data-qqq-id="file-preview-download">
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Download {item.fileName}</span>
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon button of the toolbar.
|
||||
*
|
||||
* @param props - Label, handler, state and icon.
|
||||
* @returns The button.
|
||||
*/
|
||||
function ToolbarButton({ label, onClick, disabled, id, children }: { label: string; onClick: () => void; disabled?: boolean; id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled} aria-label={label} title={label} data-qqq-id={`file-preview-${id}`}
|
||||
className="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-2 text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40">
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A previous/next button over the stage.
|
||||
*
|
||||
* @param props - Side, label, handler and icon.
|
||||
* @returns The button.
|
||||
*/
|
||||
function NavButton({ side, label, onClick, children }: { side: 'left' | 'right'; label: string; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} aria-label={label} data-qqq-id={`file-preview-${side === 'left' ? 'previous' : 'next'}`}
|
||||
className={cn('absolute top-1/2 -translate-y-1/2 inline-flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card/90 text-foreground shadow hover:bg-card focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
side === 'left' ? 'left-2' : 'right-2')}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreviewLightbox
|
||||
@@ -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 FileThumbnail — a FILE_PREVIEW value as a fixed-size, lazily loaded thumbnail (or a
|
||||
* file-type icon) that opens the preview lightbox: through the surrounding
|
||||
* {@link FilePreviewGallery} when there is one, else for this file alone.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FileKindIcon } from './FileKindIcon'
|
||||
import { LightboxHost, useFilePreviewGallery } from './FilePreviewGallery'
|
||||
|
||||
/** Props for {@link FileThumbnail}. */
|
||||
interface FileThumbnailProps {
|
||||
/** The adorned field. */
|
||||
field: QFieldMetaData
|
||||
/** The record holding the value. */
|
||||
record: QRecord
|
||||
/** The resolved preview (see `filePreview`). */
|
||||
item: FilePreviewItem
|
||||
/** Edge of the thumbnail box in CSS pixels; defaults to the adornment's `thumbnailMaxSize`. */
|
||||
size?: number
|
||||
/** Show the file name beside the thumbnail (record views). */
|
||||
showName?: boolean
|
||||
/** The `data-qqq-id` of the value. */
|
||||
dataQqqId: string
|
||||
/** Additional classes for the root. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the thumbnail. The box has a fixed size, so loading never shifts the layout; a pulse
|
||||
* shows until the image loads, and the file-type icon replaces an image that fails to load.
|
||||
* Clicks and keys do not reach an enclosing row, so a grid row does not also navigate.
|
||||
*
|
||||
* @param props - See {@link FileThumbnailProps}.
|
||||
* @returns The thumbnail, as a button when the value is previewable.
|
||||
*/
|
||||
export function FileThumbnail({ field, record, item, size, showName = false, dataQqqId, className }: FileThumbnailProps) {
|
||||
const gallery = useFilePreviewGallery()
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [standalone, setStandalone] = useState(false)
|
||||
const edge = size ?? item.thumbnailSize
|
||||
const image = item.kind === 'image' && item.thumbnailUrl && !failed
|
||||
|
||||
const box = (
|
||||
<span
|
||||
className="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-muted"
|
||||
style={{ width: edge, height: edge }}
|
||||
data-qqq-id={`${dataQqqId}-thumbnail`}
|
||||
>
|
||||
{image ? (
|
||||
<>
|
||||
{!loaded && <span className="absolute inset-0 animate-pulse bg-muted-foreground/15" aria-hidden="true" data-qqq-id={`${dataQqqId}-thumbnail-loading`} />}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer */}
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.previewable ? '' : item.alt}
|
||||
width={edge}
|
||||
height={edge}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
className={cn('h-full w-full', edge > 64 ? 'object-contain' : 'object-cover')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<FileKindIcon kind={item.kind} className="h-1/2 w-1/2 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
// the name sits under the thumbnail, no wider than it, so narrow value columns do not break it up
|
||||
const name = showName ? <span className="block truncate text-sm text-foreground" style={{ maxWidth: edge }}>{item.fileName}</span> : null
|
||||
const layout = showName ? 'inline-flex flex-col items-start gap-1' : 'inline-flex items-center gap-2'
|
||||
|
||||
if (!item.previewable) {
|
||||
return (
|
||||
<span className={cn(layout, className)} data-qqq-id={dataQqqId} title={item.fileName}>
|
||||
{box}
|
||||
{name}
|
||||
{!image && !showName && <span className="sr-only">{item.fileName}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the preview within the gallery, or alone when the value is not part of one.
|
||||
*
|
||||
* @param event - The click.
|
||||
*/
|
||||
const open = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
// Safari does not focus a clicked button; focus it so closing the preview returns here
|
||||
event.currentTarget.focus()
|
||||
if (!gallery?.open(record, field)) setStandalone(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() }}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`Preview ${item.fileName}`}
|
||||
title={item.fileName}
|
||||
className={cn('group max-w-full cursor-zoom-in rounded-md text-left', layout, 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1', className)}
|
||||
data-qqq-id={dataQqqId}
|
||||
data-preview-kind={item.kind}
|
||||
>
|
||||
<span className="contents [&>span:first-child]:transition-shadow group-hover:[&>span:first-child]:shadow-md">{box}</span>
|
||||
{name}
|
||||
</button>
|
||||
{standalone && <LightboxHost items={[item]} index={0} onIndexChange={() => undefined} onClose={() => setStandalone(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file DataCell — dispatches to the correct cell renderer based on QQQ field type and adornments. Handles LINK, CHIP, ERROR, RENDER_HTML, REVEAL, FILE_DOWNLOAD, TOOLTIP adornments (backend value keys) and type-based fallbacks.
|
||||
* @file DataCell — dispatches to the correct cell renderer based on QQQ field type and adornments. Handles FILE_PREVIEW, LINK, CHIP, ERROR, RENDER_HTML, REVEAL, FILE_DOWNLOAD, TOOLTIP adornments (backend value keys) and type-based fallbacks.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -27,6 +27,9 @@ import Link from 'next/link'
|
||||
import { sanitizeHtml } from '@/lib/utils/sanitize-html'
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import { chipStyle, CHIP_COLOR_CLASSES, fileDownload, linkTarget, tooltipText } from '@/lib/utils/adornment-utils'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { filePreview } from '@/lib/utils/file-preview'
|
||||
import { FileThumbnail } from '@/components/preview/FileThumbnail'
|
||||
|
||||
/**
|
||||
* Props for the DataCell component.
|
||||
@@ -56,6 +59,10 @@ interface DataCellProps {
|
||||
export function DataCell({ field, value, displayValue, record }: DataCellProps) {
|
||||
const display = displayValue ?? (value != null ? String(value) : '')
|
||||
|
||||
// FILE_PREVIEW wins over the other adornments: a thumbnail that opens the preview
|
||||
const preview = filePreview(field, record)
|
||||
if (preview) return <FileThumbnail field={field} record={record} item={preview} dataQqqId={`grid-cell-${field.name}`} />
|
||||
|
||||
// Check adornments for special rendering
|
||||
for (const adornment of field.adornments ?? []) {
|
||||
switch (adornment.type) {
|
||||
@@ -186,22 +193,10 @@ export function DataCell({ field, value, displayValue, record }: DataCellProps)
|
||||
)
|
||||
}
|
||||
|
||||
case 'DATE': {
|
||||
if (!value) return <EmptyCell fieldName={field.name} />
|
||||
return (
|
||||
<span className="text-sm text-foreground" data-qqq-id={`grid-cell-${field.name}`}>
|
||||
{display || formatDate(String(value))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
case 'DATE':
|
||||
case 'DATE_TIME': {
|
||||
if (!value) return <EmptyCell fieldName={field.name} />
|
||||
return (
|
||||
<span className="text-sm text-foreground" data-qqq-id={`grid-cell-${field.name}`}>
|
||||
{displayValue && displayValue !== String(value) ? displayValue : formatDateTime(String(value))}
|
||||
</span>
|
||||
)
|
||||
return <DateTimeText type={field.type} value={value} displayValue={displayValue} dataQqqId={`grid-cell-${field.name}`} />
|
||||
}
|
||||
|
||||
case 'PASSWORD': {
|
||||
@@ -304,51 +299,3 @@ function RevealCell({ value, fieldName }: { value: string; fieldName: string })
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Intl formatters are costly to construct and a 250-row page can hold thousands of date
|
||||
// cells, so each is built once, on first use (QRun-IO/qqq#710).
|
||||
let dateFormat: Intl.DateTimeFormat | undefined
|
||||
let zoneFormat: Intl.DateTimeFormat | undefined
|
||||
|
||||
/**
|
||||
* Formats an ISO date string using the browser's locale date formatting.
|
||||
*
|
||||
* Returns the original string unchanged if parsing fails.
|
||||
*
|
||||
* @param value - An ISO 8601 date string (e.g., `"2024-06-15"`).
|
||||
* @returns A locale-formatted date string (e.g., `"6/15/2024"`).
|
||||
*/
|
||||
function formatDate(value: string): string {
|
||||
try {
|
||||
const d = new Date(value)
|
||||
if (isNaN(d.getTime())) return value
|
||||
// the same output as d.toLocaleDateString(), without a new formatter per call
|
||||
dateFormat ??= new Intl.DateTimeFormat()
|
||||
return dateFormat.format(d)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an ISO datetime string using the browser's locale date-time formatting.
|
||||
*
|
||||
* Returns the original string unchanged if parsing fails.
|
||||
*
|
||||
* @param value - An ISO 8601 datetime string (e.g., `"2024-06-15T14:30:00Z"`).
|
||||
* @returns A locale-formatted datetime string (e.g., `"6/15/2024, 2:30:00 PM"`).
|
||||
*/
|
||||
function formatDateTime(value: string): string {
|
||||
const d = new Date(value)
|
||||
if (isNaN(d.getTime())) return value
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const hours = d.getHours() % 12 === 0 ? 12 : d.getHours() % 12
|
||||
zoneFormat ??= new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' })
|
||||
const zone = zoneFormat.formatToParts(d).find((p) => p.type === 'timeZoneName')?.value ?? ''
|
||||
// Material's "yyyy-MM-dd hh:mm:ss AM TZ", in the browser's time zone
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(hours)}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ${d.getHours() < 12 ? 'AM' : 'PM'} ${zone}`.trim()
|
||||
}
|
||||
+127
-117
@@ -43,10 +43,13 @@ import { selectHelpContent, QUERY_SCREEN_HELP_ROLES } from '@/lib/utils/help-uti
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { arrangePinnedColumns, effectivePins, getQueryColumns, orderColumns, type ColumnPins, type QueryColumn } from '@/lib/utils/query-columns'
|
||||
import { isColumnVisible } from '@/lib/utils/saved-view-utils'
|
||||
import { joinedPreviewField } from '@/lib/utils/file-preview'
|
||||
import { HelpContent } from '@/components/records/HelpContent'
|
||||
import { ColumnHeaderMenu, type ColumnMenuActions } from './ColumnHeaderMenu'
|
||||
import { tableHeaderQqqId } from '@/lib/utils/qqq-id'
|
||||
import { DataCell } from './DataCell'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
|
||||
/**
|
||||
* Props for the DataGrid component.
|
||||
@@ -232,6 +235,9 @@ export function DataGrid({
|
||||
}
|
||||
}, [tableMetaData, columnVisibility, columnOrder, columnPins])
|
||||
|
||||
// The fields the preview lightbox steps through (FILE_PREVIEW columns, as their cells read them)
|
||||
const previewFields = useMemo(() => visibleFields.map((column) => column.isJoin ? joinedPreviewField(column.field, column.tableName) : column.field), [visibleFields])
|
||||
|
||||
// A user-resized width wins, then explicit metadata, then the readable Next UI default.
|
||||
const widthOf = useCallback((column: QueryColumn) => Math.max(MIN_COLUMN_WIDTH, columnWidths[column.name] ?? column.defaultWidth), [columnWidths])
|
||||
|
||||
@@ -351,6 +357,8 @@ export function DataGrid({
|
||||
|
||||
const fieldColumns: ColumnDef<QRecord>[] = visibleFields.map((column) => {
|
||||
const field = column.field
|
||||
// join values are keyed `joinTable.field`; a FILE_PREVIEW field reads them so
|
||||
const cellField = column.isJoin ? joinedPreviewField(field, column.tableName) : field
|
||||
const sortInfo = sortMap.get(column.name)
|
||||
const help = selectHelpContent(field.helpContents, QUERY_SCREEN_HELP_ROLES)
|
||||
const filtered = column.isQueryCriteria && filteredColumns?.has(column.name)
|
||||
@@ -424,7 +432,7 @@ export function DataGrid({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataCell
|
||||
field={field}
|
||||
field={cellField}
|
||||
value={trimValue(row.original.values[column.name])}
|
||||
displayValue={trimValue(row.original.displayValues?.[column.name])}
|
||||
record={row.original}
|
||||
@@ -752,129 +760,131 @@ export function DataGrid({
|
||||
// Main grid
|
||||
// ------------------------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`relative w-full ${height ? 'overflow-auto' : 'overflow-x-auto'}`}
|
||||
style={height ? { height } : undefined}
|
||||
data-qqq-id={`grid-${tableName}`}
|
||||
data-grid-scroll={height ? 'viewport' : 'page'}
|
||||
>
|
||||
{/* D-Q-6: SR announcement for background refetches */}
|
||||
<FilePreviewGallery records={records} fields={previewFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
ref={scrollRef}
|
||||
className={`relative w-full ${height ? 'overflow-auto' : 'overflow-x-auto'}`}
|
||||
style={height ? { height } : undefined}
|
||||
data-qqq-id={`grid-${tableName}`}
|
||||
data-grid-scroll={height ? 'viewport' : 'page'}
|
||||
>
|
||||
{isFetching ? 'Loading results' : ''}
|
||||
</div>
|
||||
|
||||
{isFetching && (
|
||||
<div className="sticky left-0 top-0 z-10 h-0.5 w-full overflow-hidden bg-primary/20">
|
||||
<div className="h-full bg-primary animate-[slideRight_1s_ease-in-out_infinite]" />
|
||||
{/* D-Q-6: SR announcement for background refetches */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{isFetching ? 'Loading results' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table
|
||||
ref={tableRef}
|
||||
className="w-full border-collapse table-fixed min-w-[600px]"
|
||||
style={{ minWidth: `${table.getTotalSize()}px` }}
|
||||
role="grid"
|
||||
aria-label={`${tableMetaData.label} records`}
|
||||
>
|
||||
<thead className={height ? 'sticky top-0 z-[3]' : undefined}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className="border-b border-border bg-muted"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
// Compute aria-sort for sortable columns
|
||||
const isSelectCol = header.id === '_select'
|
||||
const sortInfo = !isSelectCol
|
||||
? sortOrder.find((s) => s.fieldName === header.id)
|
||||
: undefined
|
||||
const isSortable = !isSelectCol && header.column.columnDef.enableSorting !== false
|
||||
const ariaSortValue: 'ascending' | 'descending' | 'none' | undefined = !isSortable
|
||||
? undefined
|
||||
: sortInfo
|
||||
? sortInfo.isAscending
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
const pinned = pinStyles[header.id]
|
||||
// the selection column always stays in view, as the pinned columns do
|
||||
const stickyClass = isSelectCol ? 'sticky left-0 z-[2] bg-muted' : pinned ? `${pinned.className} z-[2] bg-muted` : ''
|
||||
{isFetching && (
|
||||
<div className="sticky left-0 top-0 z-10 h-0.5 w-full overflow-hidden bg-primary/20">
|
||||
<div className="h-full bg-primary animate-[slideRight_1s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
data-qqq-id={isSelectCol ? undefined : tableHeaderQqqId(undefined, header.id)}
|
||||
scope="col"
|
||||
data-col={header.id}
|
||||
data-pinned={isSelectCol ? undefined : pins[header.id]}
|
||||
className={cn('group relative text-left font-semibold text-foreground select-none', cellClass, stickyClass, isSelectCol && 'pointer-coarse:px-0', !isSelectCol && columnMenu && 'pr-1')}
|
||||
style={{ width: `${header.getSize()}px`, ...(pinned?.style ?? {}) }}
|
||||
aria-sort={ariaSortValue}
|
||||
>
|
||||
{/* These locally defined headers are pure render callbacks. Calling them
|
||||
preserves their child identity when current props rebuild the columns. */}
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: typeof header.column.columnDef.header === 'function'
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
<table
|
||||
ref={tableRef}
|
||||
className="w-full border-collapse table-fixed min-w-[600px]"
|
||||
style={{ minWidth: `${table.getTotalSize()}px` }}
|
||||
role="grid"
|
||||
aria-label={`${tableMetaData.label} records`}
|
||||
>
|
||||
<thead className={height ? 'sticky top-0 z-[3]' : undefined}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className="border-b border-border bg-muted"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
// Compute aria-sort for sortable columns
|
||||
const isSelectCol = header.id === '_select'
|
||||
const sortInfo = !isSelectCol
|
||||
? sortOrder.find((s) => s.fieldName === header.id)
|
||||
: undefined
|
||||
const isSortable = !isSelectCol && header.column.columnDef.enableSorting !== false
|
||||
const ariaSortValue: 'ascending' | 'descending' | 'none' | undefined = !isSortable
|
||||
? undefined
|
||||
: sortInfo
|
||||
? sortInfo.isAscending
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
const pinned = pinStyles[header.id]
|
||||
// the selection column always stays in view, as the pinned columns do
|
||||
const stickyClass = isSelectCol ? 'sticky left-0 z-[2] bg-muted' : pinned ? `${pinned.className} z-[2] bg-muted` : ''
|
||||
|
||||
{/* Column resize handle */}
|
||||
{!isSelectCol && (() => {
|
||||
const fieldLabel = visibleFields.find(f => f.name === header.id)?.label ?? header.id
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/40 active:bg-primary focus:outline-none focus-visible:bg-primary/60"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${fieldLabel} column`}
|
||||
aria-valuenow={Math.round(header.getSize())}
|
||||
aria-valuemin={MIN_COLUMN_WIDTH}
|
||||
aria-valuemax={2000}
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) =>
|
||||
handleResizeMouseDown(e, header.id, header.getSize())
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
handleResizeKeyDown(e, header.id, header.getSize())
|
||||
}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
data-qqq-id={isSelectCol ? undefined : tableHeaderQqqId(undefined, header.id)}
|
||||
scope="col"
|
||||
data-col={header.id}
|
||||
data-pinned={isSelectCol ? undefined : pins[header.id]}
|
||||
className={cn('group relative text-left font-semibold text-foreground select-none', cellClass, stickyClass, isSelectCol && 'pointer-coarse:px-0', !isSelectCol && columnMenu && 'pr-1')}
|
||||
style={{ width: `${header.getSize()}px`, ...(pinned?.style ?? {}) }}
|
||||
aria-sort={ariaSortValue}
|
||||
>
|
||||
{/* These locally defined headers are pure render callbacks. Calling them
|
||||
preserves their child identity when current props rebuild the columns. */}
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: typeof header.column.columnDef.header === 'function'
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<GridRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
columns={columns}
|
||||
isSelected={row.getIsSelected()}
|
||||
isChecked={isRowSelectedByQuery ? isRowSelectedByQuery(row.index) : row.getIsSelected()}
|
||||
rowClass={rowClass}
|
||||
cellClass={cellClass}
|
||||
pinStyles={pinStyles}
|
||||
onRowMouseDown={disableRowClick ? undefined : handleRowMouseDown}
|
||||
onRowClick={disableRowClick ? undefined : handleRowClick}
|
||||
onRowDoubleClick={disableRowClick ? undefined : handleRowDoubleClick}
|
||||
onOpenRecord={disableRowClick ? undefined : openRecord}
|
||||
onCellKeyDown={handleCellKeyDown}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Column resize handle */}
|
||||
{!isSelectCol && (() => {
|
||||
const fieldLabel = visibleFields.find(f => f.name === header.id)?.label ?? header.id
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/40 active:bg-primary focus:outline-none focus-visible:bg-primary/60"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${fieldLabel} column`}
|
||||
aria-valuenow={Math.round(header.getSize())}
|
||||
aria-valuemin={MIN_COLUMN_WIDTH}
|
||||
aria-valuemax={2000}
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) =>
|
||||
handleResizeMouseDown(e, header.id, header.getSize())
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
handleResizeKeyDown(e, header.id, header.getSize())
|
||||
}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<GridRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
columns={columns}
|
||||
isSelected={row.getIsSelected()}
|
||||
isChecked={isRowSelectedByQuery ? isRowSelectedByQuery(row.index) : row.getIsSelected()}
|
||||
rowClass={rowClass}
|
||||
cellClass={cellClass}
|
||||
pinStyles={pinStyles}
|
||||
onRowMouseDown={disableRowClick ? undefined : handleRowMouseDown}
|
||||
onRowClick={disableRowClick ? undefined : handleRowClick}
|
||||
onRowDoubleClick={disableRowClick ? undefined : handleRowDoubleClick}
|
||||
onOpenRecord={disableRowClick ? undefined : openRecord}
|
||||
onCellKeyDown={handleCellKeyDown}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ import type { QTableMetaData, QRecord, QFieldMetaData } from '@/types'
|
||||
import { getQueryColumns, orderColumns } from '@/lib/utils/query-columns'
|
||||
import { isColumnVisible } from '@/lib/utils/saved-view-utils'
|
||||
|
||||
import { joinedPreviewField } from '@/lib/utils/file-preview'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { DataCell } from './DataCell'
|
||||
|
||||
interface RecordCardViewProps {
|
||||
@@ -83,7 +86,7 @@ export function RecordCardView({
|
||||
() => orderColumns(getQueryColumns(tableMetaData), columnOrder)
|
||||
.filter((column) => isColumnVisible(column.name, columnVisibility))
|
||||
.slice(0, maxFieldsPerCard)
|
||||
.map((column) => ({ ...column.field, name: column.name, label: column.label })),
|
||||
.map((column) => ({ ...(column.isJoin ? joinedPreviewField(column.field, column.tableName) : column.field), name: column.name, label: column.label })),
|
||||
[tableMetaData, columnVisibility, columnOrder, maxFieldsPerCard]
|
||||
)
|
||||
|
||||
@@ -164,91 +167,93 @@ export function RecordCardView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
role="list"
|
||||
aria-label={`${tableMetaData.label} records`}
|
||||
data-qqq-id={`record-card-view-${tableName}`}
|
||||
>
|
||||
{records.map((record, index) => {
|
||||
const recordId = getRecordId(record, index)
|
||||
const isSelected = isRowSelectedByQuery ? isRowSelectedByQuery(index) : Boolean(rowSelection[recordId])
|
||||
const recordLabel = record.recordLabel || recordId
|
||||
<FilePreviewGallery records={records} fields={visibleFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
role="list"
|
||||
aria-label={`${tableMetaData.label} records`}
|
||||
data-qqq-id={`record-card-view-${tableName}`}
|
||||
>
|
||||
{records.map((record, index) => {
|
||||
const recordId = getRecordId(record, index)
|
||||
const isSelected = isRowSelectedByQuery ? isRowSelectedByQuery(index) : Boolean(rowSelection[recordId])
|
||||
const recordLabel = record.recordLabel || recordId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={recordId}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onClick={() => handleCardClick(record)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleCardClick(record)
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer rounded-xl border p-4 transition-colors hover:border-primary/30 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-card'
|
||||
}`}
|
||||
data-qqq-id={`record-card-${recordId}`}
|
||||
>
|
||||
{/* Card header: checkbox + record label */}
|
||||
<div className="flex items-start gap-3 pointer-coarse:items-center">
|
||||
{/* The label is the checkbox's touch target (44 px on coarse pointers, globals.css) */}
|
||||
<label
|
||||
className="inline-flex shrink-0 cursor-pointer items-center justify-center pointer-coarse:-my-2.5 pointer-coarse:-ml-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSelectionToggle(recordId, index)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${recordLabel}`}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input text-primary focus:ring-ring cursor-pointer pointer-coarse:mt-0 pointer-coarse:h-5 pointer-coarse:w-5"
|
||||
data-qqq-id={`card-select-${recordId}`}
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="truncate text-sm font-semibold text-card-foreground"
|
||||
data-qqq-id={`card-title-${recordId}`}
|
||||
>
|
||||
{recordLabel}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card body: field key-value pairs */}
|
||||
<dl className="mt-3 grid grid-cols-1 gap-1.5">
|
||||
{visibleFields.map((field) => {
|
||||
// Skip the primary key if it is the same as recordLabel
|
||||
if (field.name === tableMetaData.primaryKeyField && recordLabel === String(record.values[field.name])) {
|
||||
return null
|
||||
return (
|
||||
<div
|
||||
key={recordId}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onClick={() => handleCardClick(record)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleCardClick(record)
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer rounded-xl border p-4 transition-colors hover:border-primary/30 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-card'
|
||||
}`}
|
||||
data-qqq-id={`record-card-${recordId}`}
|
||||
>
|
||||
{/* Card header: checkbox + record label */}
|
||||
<div className="flex items-start gap-3 pointer-coarse:items-center">
|
||||
{/* The label is the checkbox's touch target (44 px on coarse pointers, globals.css) */}
|
||||
<label
|
||||
className="inline-flex shrink-0 cursor-pointer items-center justify-center pointer-coarse:-my-2.5 pointer-coarse:-ml-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSelectionToggle(recordId, index)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${recordLabel}`}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input text-primary focus:ring-ring cursor-pointer pointer-coarse:mt-0 pointer-coarse:h-5 pointer-coarse:w-5"
|
||||
data-qqq-id={`card-select-${recordId}`}
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="truncate text-sm font-semibold text-card-foreground"
|
||||
data-qqq-id={`card-title-${recordId}`}
|
||||
>
|
||||
{recordLabel}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={field.name} className="flex items-baseline gap-2 text-sm" data-qqq-id={`card-field-${field.name}`}>
|
||||
<dt className="shrink-0 text-muted-foreground">
|
||||
{field.label}:
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-card-foreground">
|
||||
{/* Formatted exactly like the grid cell (dates, money, possible-value links) */}
|
||||
<DataCell field={field} value={record.values[field.name]} displayValue={record.displayValues?.[field.name]} record={record} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Card body: field key-value pairs */}
|
||||
<dl className="mt-3 grid grid-cols-1 gap-1.5">
|
||||
{visibleFields.map((field) => {
|
||||
// Skip the primary key if it is the same as recordLabel
|
||||
if (field.name === tableMetaData.primaryKeyField && recordLabel === String(record.values[field.name])) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.name} className="flex items-baseline gap-2 text-sm" data-qqq-id={`card-field-${field.name}`}>
|
||||
<dt className="shrink-0 text-muted-foreground">
|
||||
{field.label}:
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-card-foreground">
|
||||
{/* Formatted exactly like the grid cell (dates, money, possible-value links) */}
|
||||
<DataCell field={field} value={record.values[field.name]} displayValue={record.displayValues?.[field.name]} record={record} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useId, useState } from 'react'
|
||||
import React, { useId, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowUpRight, ExternalLink, X } from 'lucide-react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
@@ -29,6 +29,9 @@ import type { QTableMetaData, QRecord, QAssociation } from '@/types'
|
||||
import { resolveAssociationValues, type AssociationTableState } from '@/lib/utils/association-utils'
|
||||
import { serializeFilter } from '@/lib/utils/filter-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { hasFilePreview } from '@/lib/utils/file-preview'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { FieldValue } from './FieldValue'
|
||||
import { RecordHoverCard } from './RecordHoverCard'
|
||||
import { EntityForm } from '@/components/forms/EntityForm'
|
||||
@@ -78,6 +81,14 @@ export function AssociatedRecords({
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
|
||||
const headingId = useId()
|
||||
const associationId = encodeURIComponent(association.name)
|
||||
const reverse = joinTableMetaData ? association.join.leftTable !== parentTableMetaData.name : false
|
||||
const availableRecords = joinTableMetaData && !reverse && joinTableMetaData.readPermission ? loadedRecords : undefined
|
||||
// stable arrays per page, so the preview gallery's items are not rebuilt on every render
|
||||
const shownRecords = useMemo(() => (availableRecords ?? []).slice(0, visibleCount), [availableRecords, visibleCount])
|
||||
const joinFields = joinTableMetaData?.fields
|
||||
const visibleFields = useMemo(() => Object.values(joinFields ?? {})
|
||||
.filter((f) => !f.isHidden && !f.isHeavy)
|
||||
.slice(0, 6), [joinFields])
|
||||
|
||||
if (metadata?.isDenied) return null
|
||||
|
||||
@@ -89,13 +100,8 @@ export function AssociatedRecords({
|
||||
}
|
||||
|
||||
const relationship = resolveAssociationValues(association, parentTableMetaData, parentRecord, joinTableMetaData)
|
||||
const reverse = association.join.leftTable !== parentTableMetaData.name
|
||||
const availableRecords = !reverse && joinTableMetaData.readPermission ? loadedRecords : undefined
|
||||
const records = availableRecords ?? []
|
||||
const fixedValues = 'values' in relationship ? relationship.values : undefined
|
||||
const visibleFields = Object.values(joinTableMetaData.fields)
|
||||
.filter((f) => !f.isHidden && !f.isHeavy)
|
||||
.slice(0, 6)
|
||||
|
||||
// Build from params for outgoing links
|
||||
const fromParams = navigateFrom
|
||||
@@ -200,63 +206,65 @@ export function AssociatedRecords({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{records.slice(0, visibleCount).map((childRecord, rowIdx) => {
|
||||
const childPk =
|
||||
childRecord.values[joinTableMetaData.primaryKeyField] as string | number
|
||||
const recordHref = joinTableMetaData.readPermission && childPk !== undefined
|
||||
? `/app/${encodeURIComponent(joinTableMetaData.name)}/${encodeURIComponent(String(childPk))}${fromParamsFirst}`
|
||||
: undefined
|
||||
return (
|
||||
<tr
|
||||
key={childPk ?? rowIdx}
|
||||
className="hover:bg-accent transition-colors"
|
||||
data-qqq-id={`assoc-row-${joinTableMetaData.name}-${childPk}`}
|
||||
>
|
||||
{visibleFields.map((field, fieldIdx) => (
|
||||
<td
|
||||
key={field.name}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm"
|
||||
data-qqq-id={`grid-cell-${field.name}`}
|
||||
>
|
||||
{fieldIdx <= 1 && recordHref ? (
|
||||
<RecordHoverCard
|
||||
tableName={joinTableMetaData.name}
|
||||
primaryKey={childPk}
|
||||
tableMetaData={joinTableMetaData}
|
||||
navigateFrom={navigateFrom}
|
||||
>
|
||||
<Link
|
||||
href={recordHref}
|
||||
className="text-primary hover:text-primary/80 hover:underline"
|
||||
aria-label={`View ${joinTableMetaData.label} record ${childPk}`}
|
||||
>
|
||||
{childRecord.displayValues?.[field.name] ?? String(childRecord.values[field.name] ?? '')}
|
||||
</Link>
|
||||
</RecordHoverCard>
|
||||
) : (
|
||||
<FieldValue field={field} record={childRecord} allTables={allTables} navigateFrom={navigateFrom} />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{recordHref && (
|
||||
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
|
||||
<Link
|
||||
href={recordHref}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80',
|
||||
'pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center',
|
||||
'focus:outline-none focus:underline'
|
||||
)}
|
||||
data-qqq-id={`link-view-${joinTableMetaData.name}-${childPk}`}
|
||||
<FilePreviewGallery records={shownRecords} fields={visibleFields} tableVariant={storedRecordVariantJson(joinTableMetaData)}>
|
||||
{shownRecords.map((childRecord, rowIdx) => {
|
||||
const childPk =
|
||||
childRecord.values[joinTableMetaData.primaryKeyField] as string | number
|
||||
const recordHref = joinTableMetaData.readPermission && childPk !== undefined
|
||||
? `/app/${encodeURIComponent(joinTableMetaData.name)}/${encodeURIComponent(String(childPk))}${fromParamsFirst}`
|
||||
: undefined
|
||||
return (
|
||||
<tr
|
||||
key={childPk ?? rowIdx}
|
||||
className="hover:bg-accent transition-colors"
|
||||
data-qqq-id={`assoc-row-${joinTableMetaData.name}-${childPk}`}
|
||||
>
|
||||
{visibleFields.map((field, fieldIdx) => (
|
||||
<td
|
||||
key={field.name}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm"
|
||||
data-qqq-id={`grid-cell-${field.name}`}
|
||||
>
|
||||
View
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{fieldIdx <= 1 && recordHref && !hasFilePreview(field) ? (
|
||||
<RecordHoverCard
|
||||
tableName={joinTableMetaData.name}
|
||||
primaryKey={childPk}
|
||||
tableMetaData={joinTableMetaData}
|
||||
navigateFrom={navigateFrom}
|
||||
>
|
||||
<Link
|
||||
href={recordHref}
|
||||
className="text-primary hover:text-primary/80 hover:underline"
|
||||
aria-label={`View ${joinTableMetaData.label} record ${childPk}`}
|
||||
>
|
||||
{childRecord.displayValues?.[field.name] ?? String(childRecord.values[field.name] ?? '')}
|
||||
</Link>
|
||||
</RecordHoverCard>
|
||||
) : (
|
||||
<FieldValue field={field} record={childRecord} allTables={allTables} navigateFrom={navigateFrom} />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{recordHref && (
|
||||
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
|
||||
<Link
|
||||
href={recordHref}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80',
|
||||
'pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center',
|
||||
'focus:outline-none focus:underline'
|
||||
)}
|
||||
data-qqq-id={`link-view-${joinTableMetaData.name}-${childPk}`}
|
||||
>
|
||||
View
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</FilePreviewGallery>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { cn } from '@/lib/utils/cn'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import { useAuditRecords } from '@/lib/hooks/use-audit-records'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
|
||||
/**
|
||||
* Props for {@link AuditHistoryDialog}.
|
||||
@@ -215,7 +216,7 @@ function AuditEntry({ entry, tableMetaData }: { entry: QAuditRecord; tableMetaDa
|
||||
return (
|
||||
<li className="rounded-lg border border-border p-3" data-qqq-id={`audit-entry-${entry.id}`}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 text-xs text-muted-foreground">
|
||||
<time dateTime={entry.timestamp} data-qqq-id="audit-entry-timestamp">{formatDateTime(entry.timestamp) ?? entry.timestamp}</time>
|
||||
<DateTimeText type="DATE_TIME" value={entry.timestamp} className="text-xs text-muted-foreground" dataQqqId="audit-entry-timestamp" />
|
||||
{entry.user && <span data-qqq-id="audit-entry-user">{entry.user}</span>}
|
||||
</div>
|
||||
{entry.message && <p className="mt-1 text-sm font-medium text-foreground" data-qqq-id="audit-entry-message">{entry.message}</p>}
|
||||
|
||||
@@ -109,12 +109,22 @@ describe('FieldValue adornments use the backend value keys', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Error: Could not load widget [nope]')
|
||||
})
|
||||
|
||||
it('shows a zoned DATE_TIME display value as-is and formats a raw instant in the viewer zone', () => {
|
||||
expect(show(field('fixed', [], { type: 'DATE_TIME' }), { fixed: '2024-03-10T08:30:00Z' }, { fixed: '2024-03-10 03:30:00 AM CDT' }))
|
||||
.toHaveTextContent('2024-03-10 03:30:00 AM CDT')
|
||||
it('shows DATE_TIME compactly on one line with the full zoned timestamp as its tooltip', () => {
|
||||
const fixed = show(field('fixed', [], { type: 'DATE_TIME' }), { fixed: '2024-03-10T08:30:00Z' }, { fixed: '2024-03-10 03:30:00 AM CDT' })
|
||||
// in a CDT viewer the zone matches (date only); elsewhere the declared zone keeps its time
|
||||
expect(fixed.textContent).toMatch(/^Mar 10, 2024(, 3:30 AM CDT)?$/)
|
||||
expect(fixed.getAttribute('title')).toContain('2024-03-10 03:30:00 AM CDT')
|
||||
const raw = show(field('plain', [], { type: 'DATE_TIME' }), { plain: '2024-03-10T08:30:00Z' }, { plain: '2024-03-10T08:30:00Z' })
|
||||
expect(raw.textContent).toMatch(/^2024-03-10 \d{2}:30:00 [AP]M \S+$/)
|
||||
expect(raw).toHaveAttribute('dateTime', '2024-03-10T08:30:00Z')
|
||||
expect(raw.textContent).toMatch(/^Mar (9|10), 2024$/)
|
||||
expect(raw.getAttribute('title')).toMatch(/^2024-03-(09|10) \d{2}:30:00 [AP]M \S+$/)
|
||||
expect(raw).toHaveAttribute('dateTime', '2024-03-10T08:30:00.000Z')
|
||||
expect(raw).toHaveClass('whitespace-nowrap')
|
||||
})
|
||||
|
||||
it('shows a DATE as its calendar day, never shifted by the viewer zone and never with a time', () => {
|
||||
const date = show(field('day', [], { type: 'DATE' }), { day: '2024-06-15' })
|
||||
expect(date).toHaveTextContent('Jun 15, 2024')
|
||||
expect(date).toHaveAttribute('dateTime', '2024-06-15')
|
||||
})
|
||||
|
||||
it('renders RENDER_HTML sanitized and an empty value as a dash', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
/**
|
||||
* @file FieldValue — renders a single field value from metadata: adornments first
|
||||
* (LINK, REVEAL, RENDER_HTML, CHIP, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET,
|
||||
* (FILE_PREVIEW, LINK, REVEAL, RENDER_HTML, CHIP, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET,
|
||||
* TOOLTIP), then type-specific formatting.
|
||||
*/
|
||||
|
||||
@@ -33,15 +33,17 @@ import { useFocusSafeTooltip } from '@/lib/hooks/use-focus-safe-tooltip'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { isHttpUrl, isEmail } from '@/lib/utils/string-utils'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
import { formatJson, formatSql, languageFor } from '@/lib/utils/code-highlight'
|
||||
import {
|
||||
attachmentUrl, chipStyle, CHIP_COLOR_CLASSES, fileDownload, findAdornment, linkTarget, tooltipText,
|
||||
} from '@/lib/utils/adornment-utils'
|
||||
import { filePreview } from '@/lib/utils/file-preview'
|
||||
import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { HighlightedCode } from '@/components/scripts/HighlightedCode'
|
||||
import { SeededWidget } from '@/components/widgets/ConnectedWidget'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { FileThumbnail } from '@/components/preview/FileThumbnail'
|
||||
import { RecordHoverCard } from './RecordHoverCard'
|
||||
|
||||
/**
|
||||
@@ -68,6 +70,11 @@ interface FieldValueProps {
|
||||
tableMetaData?: QTableMetaData
|
||||
/** Additional CSS classes applied to the root element. */
|
||||
className?: string
|
||||
/**
|
||||
* Edge, in CSS pixels, of a FILE_PREVIEW thumbnail, which then also shows the file name
|
||||
* (record views). Lists leave it unset and use the adornment's `thumbnailMaxSize`.
|
||||
*/
|
||||
thumbnailSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,17 +82,17 @@ interface FieldValueProps {
|
||||
*
|
||||
* Adornments are applied in the order the QQQ dashboards use (one per value), and
|
||||
* a TOOLTIP adornment wraps whatever is rendered. Without an adornment, the value
|
||||
* is formatted by type: DATE_TIME in the viewer's time zone (unless the backend
|
||||
* supplied a zoned display value), BOOLEAN as Yes/No, TEXT with line breaks, HTML
|
||||
* is formatted by type: DATE and DATE_TIME compactly in the viewer's zone and locale
|
||||
* (see DateTimeText), BOOLEAN as Yes/No, TEXT with line breaks, HTML
|
||||
* sanitized, and everything else as the backend display value. Empty values render
|
||||
* an em dash.
|
||||
*
|
||||
* @param props - See {@link FieldValueProps}.
|
||||
* @returns The rendered value.
|
||||
*/
|
||||
export function FieldValue({ field, record, allTables, navigateFrom, widgetMetaDataMap, tableMetaData, className }: FieldValueProps) {
|
||||
export function FieldValue({ field, record, allTables, navigateFrom, widgetMetaDataMap, tableMetaData, className, thumbnailSize }: FieldValueProps) {
|
||||
const content = <FieldValueContent field={field} record={record} allTables={allTables}
|
||||
navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} className={className} />
|
||||
navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} className={className} thumbnailSize={thumbnailSize} />
|
||||
const tooltip = tooltipText(field, record)
|
||||
const tooltipState = useFocusSafeTooltip()
|
||||
if (!tooltip) return content
|
||||
@@ -117,7 +124,7 @@ export function FieldValue({ field, record, allTables, navigateFrom, widgetMetaD
|
||||
* @param props - See {@link FieldValueProps}.
|
||||
* @returns The rendered value.
|
||||
*/
|
||||
function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaDataMap, tableMetaData, className }: FieldValueProps) {
|
||||
function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaDataMap, tableMetaData, className, thumbnailSize }: FieldValueProps) {
|
||||
const rawValue = record.values[field.name]
|
||||
const displayValue = record.displayValues?.[field.name]
|
||||
const value = displayValue ?? rawValue
|
||||
@@ -148,6 +155,12 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
|
||||
)
|
||||
}
|
||||
|
||||
const preview = filePreview(field, record, storedRecordVariantJson(tableMetaData ?? allTables?.[record.tableName]))
|
||||
if (preview) {
|
||||
return <FileThumbnail field={field} record={record} item={preview} size={thumbnailSize} showName={thumbnailSize !== undefined}
|
||||
dataQqqId={dataQqqId} className={className} />
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <EmptyValue fieldName={field.name} className={className} />
|
||||
}
|
||||
@@ -251,12 +264,10 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
|
||||
)
|
||||
}
|
||||
|
||||
case 'DATE_TIME': {
|
||||
// A zoned display value from the backend (e.g. a fixed or per-record zone) wins;
|
||||
// otherwise the instant is shown in the viewer's time zone.
|
||||
const text = displayValue && displayValue !== rawValue ? displayValue : (formatDateTime(rawValue) ?? String(value))
|
||||
return <time dateTime={typeof rawValue === 'string' ? rawValue : undefined} className={cn('text-sm text-foreground', className)}
|
||||
data-qqq-id={dataQqqId}>{text}</time>
|
||||
case 'DATE_TIME':
|
||||
case 'DATE': {
|
||||
// compact and on one line, full value on hover; a backend zone other than the viewer's is kept
|
||||
return <DateTimeText type={field.type} value={rawValue} displayValue={displayValue} className={className} dataQqqId={dataQqqId} />
|
||||
}
|
||||
|
||||
case 'BLOB': {
|
||||
|
||||
@@ -37,6 +37,9 @@ import { recordHashAction } from '@/lib/utils/material-links'
|
||||
import { recordSectionField, recordViewActionsPlacement, showRecordSidebar, visibleRecordViewSections } from '@/lib/utils/record-layout-utils'
|
||||
|
||||
import { RecordViewSection } from './RecordViewSection'
|
||||
import { RECORD_VIEW_THUMBNAIL_SIZE } from '@/lib/utils/file-preview'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { FieldValue } from './FieldValue'
|
||||
import { FieldLabel } from './FieldLabel'
|
||||
import { RecordViewHeader } from './RecordViewHeader'
|
||||
@@ -556,182 +559,187 @@ function RecordViewContent({
|
||||
[tableMetaData, allTables, navigateFrom]
|
||||
)
|
||||
const viewId = `record-view-${tableMetaData.name}`
|
||||
// the record's FILE_PREVIEW values, in section order, form the preview gallery of the page
|
||||
const previewFields = [...new Set((tableMetaData.sections ?? []).flatMap((section) => section.fieldNames ?? []))]
|
||||
.map((name) => tableMetaData.fields[name]).filter((field): field is QFieldMetaData => Boolean(field))
|
||||
const materialViewId = `record-view-${sanitizeQqqId(tableMetaData.name)}`
|
||||
|
||||
return (
|
||||
<RecordViewContext.Provider value={contextValue}>
|
||||
<div className="recordView" data-qqq-id={materialViewId !== viewId ? materialViewId : undefined}>
|
||||
<div
|
||||
className={cn('space-y-5', className)}
|
||||
data-qqq-id={viewId}
|
||||
>
|
||||
{/* Back link — returns to source page if navigated from another record, otherwise table list */}
|
||||
<Link
|
||||
href={safeFromPath || `/app/${tableMetaData.name}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors pointer-coarse:min-h-11"
|
||||
data-qqq-id="link-back-to-table"
|
||||
<FilePreviewGallery records={[record]} fields={previewFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
|
||||
<div className="recordView" data-qqq-id={materialViewId !== viewId ? materialViewId : undefined}>
|
||||
<div
|
||||
className={cn('space-y-5', className)}
|
||||
data-qqq-id={viewId}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to {fromLabel || tableMetaData.label}
|
||||
</Link>
|
||||
{/* Back link — returns to source page if navigated from another record, otherwise table list */}
|
||||
<Link
|
||||
href={safeFromPath || `/app/${tableMetaData.name}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors pointer-coarse:min-h-11"
|
||||
data-qqq-id="link-back-to-table"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to {fromLabel || tableMetaData.label}
|
||||
</Link>
|
||||
|
||||
<div className={cn(hasSidebar && 'lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-6')}>
|
||||
{hasSidebar && <RecordViewSidebar sections={orderedSections} onNavigate={navigateToSection} />}
|
||||
<div className="min-w-0 space-y-5">
|
||||
<div className={cn(hasSidebar && 'lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-6')}>
|
||||
{hasSidebar && <RecordViewSidebar sections={orderedSections} onNavigate={navigateToSection} />}
|
||||
<div className="min-w-0 space-y-5">
|
||||
|
||||
{/* Record header — avatar + name + T1 fields + view-mode toggle + actions */}
|
||||
<RecordViewHeader
|
||||
actionsPlacement={recordViewActionsPlacement(instance, tableMetaData)}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
t1Fields={t1Fields}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
hideActions={hideActions}
|
||||
processes={processes}
|
||||
allProcesses={allProcesses}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
auditSource={auditSource}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
onRecordChanged={onRefetch}
|
||||
/>
|
||||
|
||||
{viewMode === 'tabs' && t1Sections.filter((section) => section.widgetName).map((section) => (
|
||||
<RecordViewSection key={section.name} section={section} tableMetaData={tableMetaData} record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap} allTables={allTables} navigateFrom={navigateFrom}
|
||||
renderAssociation={renderAssociation} />
|
||||
))}
|
||||
|
||||
{/* Record errors/warnings */}
|
||||
{(record.errors?.length ?? 0) > 0 && (
|
||||
<div role="alert" className="rounded-md border border-red-200 bg-red-50 px-4 py-3">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{record.errors!.map((err, i) => (
|
||||
<li key={i} className="text-sm text-red-700">{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{/* The warning of the save that led here (Material: create/update navigation state) */}
|
||||
<SaveWarningBanner tableName={tableMetaData.name} primaryKey={parentPk as string | number} />
|
||||
{(record.warnings?.length ?? 0) > 0 && (
|
||||
<div role="status" className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{record.warnings!.map((warn, i) => (
|
||||
<li key={i} className="text-sm text-yellow-700">{warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === View mode: Tabs (default) === */}
|
||||
{viewMode === 'tabs' && tabs.length > 0 && (
|
||||
<RecordViewTabs
|
||||
{/* Record header — avatar + name + T1 fields + view-mode toggle + actions */}
|
||||
<RecordViewHeader
|
||||
actionsPlacement={recordViewActionsPlacement(instance, tableMetaData)}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
secondarySections={secondarySections}
|
||||
tertiarySections={tertiarySections}
|
||||
associations={associations}
|
||||
renderAssociation={renderAssociation}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
t1Fields={t1Fields}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
hideActions={hideActions}
|
||||
processes={processes}
|
||||
allProcesses={allProcesses}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
auditSource={auditSource}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
onRecordChanged={onRefetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === View mode: List (compact top-to-bottom data view) === */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-4" data-qqq-id="record-view-list-mode">
|
||||
{/* T1 sections as compact cards */}
|
||||
{t1Sections.length > 0 && t1Sections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{viewMode === 'tabs' && t1Sections.filter((section) => section.widgetName).map((section) => (
|
||||
<RecordViewSection key={section.name} section={section} tableMetaData={tableMetaData} record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap} allTables={allTables} navigateFrom={navigateFrom}
|
||||
renderAssociation={renderAssociation} />
|
||||
))}
|
||||
|
||||
{/* T2 sections as compact cards */}
|
||||
{secondarySections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* T3 sections as compact cards */}
|
||||
{tertiarySections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Related records */}
|
||||
<RecordViewAssociated associations={associations} renderAssociation={renderAssociation} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No tabs/sections — just show all fields if nothing to tab */}
|
||||
{tabs.length === 0 && secondarySections.length === 0 && t1Fields.length === 0 && (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<dl className="grid gap-x-8 gap-y-4 grid-cols-1 sm:grid-cols-2">
|
||||
{Object.values(tableMetaData.fields)
|
||||
.filter((f) => !f.isHidden)
|
||||
.map((field) => (
|
||||
<div key={field.name} className="flex flex-col gap-0.5" data-qqq-id={`record-field-${field.name}`}>
|
||||
<dt className="text-sm font-semibold text-foreground">
|
||||
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
|
||||
</dt>
|
||||
<dd>
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
|
||||
</dd>
|
||||
</div>
|
||||
{/* Record errors/warnings */}
|
||||
{(record.errors?.length ?? 0) > 0 && (
|
||||
<div role="alert" className="rounded-md border border-red-200 bg-red-50 px-4 py-3">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{record.errors!.map((err, i) => (
|
||||
<li key={i} className="text-sm text-red-700">{err}</li>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{/* The warning of the save that led here (Material: create/update navigation state) */}
|
||||
<SaveWarningBanner tableName={tableMetaData.name} primaryKey={parentPk as string | number} />
|
||||
{(record.warnings?.length ?? 0) > 0 && (
|
||||
<div role="status" className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{record.warnings!.map((warn, i) => (
|
||||
<li key={i} className="text-sm text-yellow-700">{warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === View mode: Tabs (default) === */}
|
||||
{viewMode === 'tabs' && tabs.length > 0 && (
|
||||
<RecordViewTabs
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
secondarySections={secondarySections}
|
||||
tertiarySections={tertiarySections}
|
||||
associations={associations}
|
||||
renderAssociation={renderAssociation}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === View mode: List (compact top-to-bottom data view) === */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-4" data-qqq-id="record-view-list-mode">
|
||||
{/* T1 sections as compact cards */}
|
||||
{t1Sections.length > 0 && t1Sections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* T2 sections as compact cards */}
|
||||
{secondarySections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* T3 sections as compact cards */}
|
||||
{tertiarySections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Related records */}
|
||||
<RecordViewAssociated associations={associations} renderAssociation={renderAssociation} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No tabs/sections — just show all fields if nothing to tab */}
|
||||
{tabs.length === 0 && secondarySections.length === 0 && t1Fields.length === 0 && (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<dl className="grid gap-x-8 gap-y-4 grid-cols-1 sm:grid-cols-2">
|
||||
{Object.values(tableMetaData.fields)
|
||||
.filter((f) => !f.isHidden)
|
||||
.map((field) => (
|
||||
<div key={field.name} className="flex flex-col gap-0.5" data-qqq-id={`record-field-${field.name}`}>
|
||||
<dt className="text-sm font-semibold text-foreground">
|
||||
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
|
||||
</dt>
|
||||
<dd>
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FilePreviewGallery>
|
||||
</RecordViewContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -16,12 +16,13 @@
|
||||
// Phone action sheet: focus management and routes (QRun-IO/qqq#694, #649)
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/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 { QProcessMetaData, QTableMetaData } from '@/types'
|
||||
import type { AuditSource } from '@/lib/api/audits'
|
||||
import type { RecordViewActionsPlacement } from '@/lib/utils/record-layout-utils'
|
||||
import { RecordViewHeader } from './RecordViewHeader'
|
||||
|
||||
@@ -144,20 +145,92 @@ describe('RecordViewHeader #/launchProcess= links', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const readOnly = { ...table, insertPermission: false, editPermission: false, deletePermission: false,
|
||||
capabilities: ['TABLE_QUERY', 'TABLE_GET'] } as unknown as QTableMetaData
|
||||
|
||||
function renderTable(tableMetaData: QTableMetaData, auditSource: AuditSource = null) {
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<RecordViewHeader tableMetaData={tableMetaData} record={{ tableName: 'person', values: { id: 5 }, recordLabel: 'Morgan Sample' }}
|
||||
t1Fields={[]} viewMode="tabs" setViewMode={vi.fn()} hideActions={false} navigateFrom={{ path: '/app/person', label: 'Person' }}
|
||||
auditSource={auditSource} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Labels of the desktop Actions menu's items, after opening it. */
|
||||
async function openDesktopActions(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Record actions menu' }))
|
||||
return screen.getAllByRole('menuitem').map((item) => item.textContent)
|
||||
}
|
||||
|
||||
describe('RecordViewHeader for a read-only user', () => {
|
||||
it('offers no phone Actions trigger when there is nothing to do', () => {
|
||||
const readOnly = { ...table, insertPermission: false, editPermission: false, deletePermission: false,
|
||||
capabilities: ['TABLE_QUERY', 'TABLE_GET'] } as unknown as QTableMetaData
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<RecordViewHeader tableMetaData={readOnly} record={{ tableName: 'person', values: { id: 5 }, recordLabel: 'Morgan Sample' }}
|
||||
t1Fields={[]} viewMode="tabs" setViewMode={vi.fn()} hideActions={false} navigateFrom={{ path: '/app/person', label: 'Person' }} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('still offers Developer Mode from the desktop Actions menu, with no write actions (#45)', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(readOnly)
|
||||
for (const action of ['Edit', 'Copy', 'Delete']) {
|
||||
expect(screen.queryByRole('button', { name: `${action} Person record` })).not.toBeInTheDocument()
|
||||
}
|
||||
const items = await openDesktopActions(user)
|
||||
expect(items).toEqual(['Developer Mode'])
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Developer Mode' }))
|
||||
expect(push).toHaveBeenCalledWith('/app/person/5/dev')
|
||||
})
|
||||
|
||||
it('offers Developer Mode and Audit from the phone sheet', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(readOnly, 'table')
|
||||
await user.click(screen.getByRole('button', { name: 'Record actions' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Record actions' })
|
||||
expect(within(sheet).getByRole('button', { name: 'Developer Mode' })).toBeInTheDocument()
|
||||
expect(within(sheet).getByRole('button', { name: 'Audit' })).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('button', { name: 'Edit Person' })).not.toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('button', { name: 'Delete Person' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no Actions controls when a custom menu resolves to nothing', () => {
|
||||
const empty = { ...readOnly, menus: [{ slot: 'VIEW_SCREEN_ACTIONS', label: 'Actions', items: [
|
||||
{ itemType: 'BUILT_IN', values: { option: 'EDIT' } },
|
||||
] }] } as unknown as QTableMetaData
|
||||
renderTable(empty)
|
||||
expect(screen.queryByRole('button', { name: 'Record actions menu' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Record actions' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RecordViewHeader Actions menu for a table without processes or a custom menu (#45)', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders the default menu with New, Copy, Delete and Developer Mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(table)
|
||||
expect(await openDesktopActions(user)).toEqual(['New', 'Copy', 'Edit', 'Delete', 'Developer Mode'])
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Developer Mode' }))
|
||||
expect(push).toHaveBeenCalledWith('/app/person/5/dev')
|
||||
})
|
||||
|
||||
it('adds Audit when the user can read audits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(table, 'table')
|
||||
const items = await openDesktopActions(user)
|
||||
expect(items).toContain('Developer Mode')
|
||||
expect(items).toContain('Audit')
|
||||
})
|
||||
|
||||
it('does not duplicate Copy or Delete as standalone buttons beside the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(table)
|
||||
expect(screen.getByRole('button', { name: 'Edit Person record' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Copy Person record' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete Person record' })).not.toBeInTheDocument()
|
||||
const items = await openDesktopActions(user)
|
||||
expect(items.filter((label) => label === 'Copy')).toHaveLength(1)
|
||||
expect(items.filter((label) => label === 'Delete')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RecordViewHeader layout', () => {
|
||||
it('keeps secondary actions in the process menu without duplicate desktop buttons', async () => {
|
||||
const user = userEvent.setup()
|
||||
@@ -170,10 +243,10 @@ describe('RecordViewHeader layout', () => {
|
||||
expect(push).toHaveBeenCalledWith('/app/person/5/copy')
|
||||
})
|
||||
|
||||
it('keeps standalone secondary actions when there is no process menu', () => {
|
||||
renderHeader()
|
||||
expect(screen.getByRole('button', { name: 'Copy Person record' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete Person record' })).toBeInTheDocument()
|
||||
it('keeps the table processes in the Actions menu alongside Developer Mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader([{ name: 'tagRecords', label: 'Tag Records', hasPermission: true } as QProcessMetaData])
|
||||
expect(await openDesktopActions(user)).toEqual(['New', 'Copy', 'Edit', 'Delete', 'Tag Records', 'Developer Mode'])
|
||||
})
|
||||
|
||||
it('lets the controls wrap onto their own row instead of squeezing the title', () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { recordFieldFile, deliverRecordFieldFile } from '@/lib/utils/record-down
|
||||
import { RecordActions } from './RecordActions'
|
||||
import { RecordMenuIcon } from './RecordMenuIcon'
|
||||
import { FieldLabel } from './FieldLabel'
|
||||
import { HEADER_THUMBNAIL_SIZE } from '@/lib/utils/file-preview'
|
||||
import { FieldValue } from './FieldValue'
|
||||
import { DeleteConfirmDialog } from './DeleteConfirmDialog'
|
||||
import { AuditHistoryDialog } from './AuditHistoryDialog'
|
||||
@@ -134,6 +135,8 @@ interface RecordViewHeaderProps {
|
||||
widgetMetaDataMap?: Record<string, QWidgetMetaData>
|
||||
/** Reloads the record and its children (after a child is created from a link). */
|
||||
onRecordChanged?: () => void
|
||||
/** Shows the card/list view-mode toggle (default true; screens without sections hide it). */
|
||||
showViewModeToggle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +168,7 @@ export function RecordViewHeader({
|
||||
auditSource = null,
|
||||
widgetMetaDataMap,
|
||||
onRecordChanged,
|
||||
showViewModeToggle = true,
|
||||
}: RecordViewHeaderProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -242,9 +246,11 @@ export function RecordViewHeader({
|
||||
const phoneActionEntries = hasCustomActionsMenu ? actionEntries : defaultPhoneMenuExtras(actionEntries)
|
||||
const hasMetadataActions = (tableMetaData.menus ?? []).some((menu) =>
|
||||
(menu.slot === 'VIEW_SCREEN_ACTIONS' || menu.slot === 'VIEW_SCREEN_ADDITIONAL') && hasMenuItems(resolveMenu(menu)))
|
||||
// Keep the existing read-only security gate: a default Developer Mode entry alone does not
|
||||
// expose an otherwise empty Actions control, while a table's explicit menu still can.
|
||||
const hasMobileActions = canEdit || canInsert || canDelete || availableProcesses.length > 0 || hasMetadataActions
|
||||
// Show the Actions controls whenever the resolved menu has something to offer. The default menu
|
||||
// always resolves Developer Mode (and Audit when the user can read audits), so a table without
|
||||
// processes or a custom menu, or a read-only table, still reaches them, as Material did (#45).
|
||||
const hasRecordActions = hasMenuItems(actionEntries) || canEdit || canInsert || canDelete
|
||||
|| availableProcesses.length > 0 || hasMetadataActions
|
||||
|
||||
/**
|
||||
* Runs a resolved menu action from either the desktop dropdown or the phone sheet.
|
||||
@@ -296,9 +302,8 @@ export function RecordViewHeader({
|
||||
const desktopActions = !hideActions && (
|
||||
<div className="hidden md:flex md:flex-wrap md:items-center md:gap-2" data-qqq-id="record-view-desktop-actions">
|
||||
{tableMetaData.shareableTableMetaData && <ShareButton tableMetaData={tableMetaData} record={record} />}
|
||||
{hasMobileActions && <RecordActions className="flex-wrap" tableMetaData={tableMetaData} record={record}
|
||||
actionEntries={hasCustomActionsMenu || availableProcesses.length > 0 ? actionEntries : []}
|
||||
resolveMenu={resolveMenu} onAction={onAction} />}
|
||||
{hasRecordActions && <RecordActions className="flex-wrap" tableMetaData={tableMetaData} record={record}
|
||||
actionEntries={actionEntries} resolveMenu={resolveMenu} onAction={onAction} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -340,19 +345,19 @@ export function RecordViewHeader({
|
||||
</button>
|
||||
{actionsPlacement === 'INLINE_WITH_PAGE_TITLE' && desktopActions}
|
||||
</div>
|
||||
{/* T1 fields as a compact grid under the name */}
|
||||
{/* T1 fields in a row under the name, each as wide as its value (up to 32rem), wrapping as the header narrows */}
|
||||
{t1Fields.length > 0 && (
|
||||
<dl
|
||||
className="mt-2 grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3 lg:grid-cols-4"
|
||||
className="mt-2 flex flex-wrap gap-x-8 gap-y-2"
|
||||
data-qqq-id="record-primary-sections"
|
||||
>
|
||||
{t1Fields.map((field) => (
|
||||
<div key={field.name} className="flex min-w-0 flex-col" data-qqq-id={`record-field-${field.name}`}>
|
||||
<div key={field.name} className="flex min-w-[8rem] max-w-[32rem] flex-col" data-qqq-id={`record-field-${field.name}`}>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
|
||||
</dt>
|
||||
<dd className="min-w-0 text-sm [overflow-wrap:anywhere]">
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={HEADER_THUMBNAIL_SIZE} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
@@ -363,7 +368,7 @@ export function RecordViewHeader({
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 md:w-auto" data-qqq-id="record-view-controls">
|
||||
{/* View mode toggle */}
|
||||
<div
|
||||
{showViewModeToggle && <div
|
||||
className="flex rounded-lg border border-border bg-muted/50 p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label="View mode"
|
||||
@@ -397,7 +402,7 @@ export function RecordViewHeader({
|
||||
>
|
||||
<List className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Go To another record of this table by its key (tables with Material gotoFieldNames) */}
|
||||
<GotoRecordButton
|
||||
@@ -437,7 +442,7 @@ export function RecordViewHeader({
|
||||
{/* Mobile: bottom-sheet trigger button — MED-17 */}
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
{tableMetaData.shareableTableMetaData && <ShareButton tableMetaData={tableMetaData} record={record} />}
|
||||
{hasMobileActions && <button
|
||||
{hasRecordActions && <button
|
||||
type="button"
|
||||
ref={mobileActionsTrigger}
|
||||
onClick={() => setMobileActionsOpen(true)}
|
||||
@@ -461,7 +466,7 @@ export function RecordViewHeader({
|
||||
</div>
|
||||
|
||||
{/* Mobile actions bottom-sheet — MED-17 */}
|
||||
{mobileActionsOpen && hasMobileActions && (
|
||||
{mobileActionsOpen && hasRecordActions && (
|
||||
<div className="md:hidden" data-qqq-id="mobile-actions-sheet">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
|
||||
@@ -27,9 +27,11 @@ import type { QTableMetaData, QTableSection, QRecord, QWidgetMetaData } from '@/
|
||||
import { associationWidgetBinding } from '@/lib/utils/association-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { gridSpanClasses, initialSectionOpen, isCollapsibleSection, recordSectionField, storeSectionOpen, twelfths } from '@/lib/utils/record-layout-utils'
|
||||
import { DEFAULT_FIELD_SPAN_CLASSES } from '@/lib/utils/responsive-grid'
|
||||
import { selectSlotHelpContent, VIEW_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
|
||||
import { useHelpHelpActive } from '@/lib/context/q-context'
|
||||
|
||||
import { RECORD_VIEW_THUMBNAIL_SIZE } from '@/lib/utils/file-preview'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
import { FieldLabel } from '@/components/records/FieldLabel'
|
||||
import { HelpContent } from '@/components/records/HelpContent'
|
||||
@@ -242,7 +244,7 @@ export function RecordViewSection({
|
||||
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
|
||||
</dt>
|
||||
<dd className="min-w-0 flex-1 text-sm text-foreground [overflow-wrap:anywhere]">
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
@@ -263,7 +265,7 @@ export function RecordViewSection({
|
||||
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
|
||||
</dt>
|
||||
<dd className="min-w-0 text-sm text-foreground [overflow-wrap:anywhere]">
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
@@ -272,7 +274,7 @@ export function RecordViewSection({
|
||||
) : (
|
||||
/* Explicit field widths use twelfths; the surrounding view supplies the default layout. */
|
||||
<dl
|
||||
className={cn('grid grid-cols-12 gap-x-8', defaultFieldColumns === 2 ? 'gap-y-6' : 'gap-y-4')}
|
||||
className={cn('@container grid grid-cols-12 gap-x-8', defaultFieldColumns === 2 ? 'gap-y-6' : 'gap-y-4')}
|
||||
>
|
||||
{visibleFields.map((field) => {
|
||||
if (!field) return null
|
||||
@@ -283,7 +285,7 @@ export function RecordViewSection({
|
||||
'flex min-w-0 flex-col gap-0.5',
|
||||
twelfths(field.gridColumns) !== undefined
|
||||
? gridSpanClasses(field.gridColumns!)
|
||||
: defaultFieldColumns === 2 ? 'col-span-12 sm:col-span-6' : 'col-span-12'
|
||||
: defaultFieldColumns === 2 ? DEFAULT_FIELD_SPAN_CLASSES : 'col-span-12'
|
||||
)}
|
||||
data-qqq-id={`record-field-${field.name}`}
|
||||
>
|
||||
@@ -291,7 +293,7 @@ export function RecordViewSection({
|
||||
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
|
||||
</dt>
|
||||
<dd className={cn('min-w-0 [overflow-wrap:anywhere]', defaultFieldColumns === 1 && 'text-sm text-foreground')}>
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -232,10 +232,12 @@ export function RecordViewTabs({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content: Overview — Material's 12-column card widths */}
|
||||
{/* Tab content: Overview — Material's 12-column card widths, applied by the panel's own
|
||||
width (container queries) so cards sit side by side only when each is readable; cards
|
||||
keep their own height instead of stretching to the tallest in the row */}
|
||||
{activeTab === 'overview' && (
|
||||
<div
|
||||
className={cn('grid grid-cols-12 gap-6', panelFocus)}
|
||||
className={cn('@container grid grid-cols-12 items-start gap-6', panelFocus)}
|
||||
{...panelAttributes}
|
||||
data-qqq-id="record-tab-panel-overview"
|
||||
>
|
||||
@@ -270,7 +272,7 @@ export function RecordViewTabs({
|
||||
className={panelFocus}
|
||||
data-qqq-id={`record-tab-panel-${section.name}`}
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="@container grid grid-cols-12 gap-6">
|
||||
<div className={cn('rounded-xl border border-border bg-card p-6 shadow-sm', recordSectionGridSpan(section, widgetMetaDataMap, 12))}>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
@@ -297,7 +299,7 @@ export function RecordViewTabs({
|
||||
className={panelFocus}
|
||||
data-qqq-id={`record-tab-panel-${section.name}`}
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="@container grid grid-cols-12 gap-6">
|
||||
<div className={cn('rounded-xl border border-border bg-card p-6 shadow-sm', recordSectionGridSpan(section, widgetMetaDataMap, 12))}>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
|
||||
@@ -28,25 +28,14 @@ import { Loader2 } from 'lucide-react'
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import { testScript } from '@/lib/api/developer'
|
||||
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { CodeBlock } from './HighlightedCode'
|
||||
import { dateTimeText, domId, text } from './script-utils'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS } from '@/components/ui/controls'
|
||||
|
||||
export const BUTTON_CLASS = cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-foreground',
|
||||
'hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)
|
||||
export const PRIMARY_BUTTON_CLASS = cn(
|
||||
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)
|
||||
export const INPUT_CLASS =
|
||||
'w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring'
|
||||
export const ALERT_CLASS =
|
||||
'whitespace-pre-wrap rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive'
|
||||
// The shared control styles moved to the design-system module; re-exported for existing imports.
|
||||
export { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS }
|
||||
|
||||
/** Tester input and output fields of a script type. */
|
||||
export interface ScriptTestFields {
|
||||
@@ -202,7 +191,7 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
|
||||
<tbody>
|
||||
{result.logLines.map((line, index) => (
|
||||
<tr key={index} className="border-t border-border align-top">
|
||||
<td className="whitespace-nowrap px-3 py-2 text-foreground">{dateTimeText(line.timestamp)}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-foreground"><DateTimeText type="DATE_TIME" value={line.timestamp} /></td>
|
||||
<td className="whitespace-pre-wrap px-3 py-2 text-foreground">{line.text}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { languageFor } from '@/lib/utils/code-highlight'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { CodeBlock } from './HighlightedCode'
|
||||
import { ScriptEditorDialog } from './ScriptEditorDialog'
|
||||
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, ScriptDocs, ScriptTest, type ScriptTestFields } from './ScriptPanels'
|
||||
@@ -509,7 +510,7 @@ function ScriptLogs({ idKey, source, scriptRevisionId }: {
|
||||
const hadError = log.values.hadError
|
||||
return (
|
||||
<tr key={text(log.values.id)} className="border-t border-border align-top" data-qqq-id={`script-log-${text(log.values.id)}`}>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-foreground">{dateTimeText(log.values.startTimestamp)}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-foreground"><DateTimeText type="DATE_TIME" value={log.values.startTimestamp} /></td>
|
||||
<td className="px-3 py-2 text-right text-foreground">{text(log.values.runTimeMillis)}</td>
|
||||
<td className={cn('px-3 py-2', hadError === true ? 'text-destructive' : 'text-foreground')}>
|
||||
{hadError === true ? 'Yes' : hadError === false ? 'No' : ''}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 CommandRecordResults — record search inside the command palette: status (searching,
|
||||
* failure class, provider fallback) and one cmdk group per table with its count, records
|
||||
* with highlighted snippets, and a "see all" entry. Loaded on demand by the palette, only
|
||||
* when the metadata lists searchable tables.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { AlertTriangle, ArrowRight, Loader2 } from 'lucide-react'
|
||||
|
||||
import { useRecordSearch } from '@/lib/hooks/use-record-search'
|
||||
import { recordSearchResultPath, type SearchableTable } from '@/lib/utils/record-search'
|
||||
import { groupResultsByTable, tableCountLabel, tableHasMore } from '@/lib/search/search-model'
|
||||
import { searchPagePath } from '@/lib/search/search-url'
|
||||
import { SearchOptionContent } from './SearchOptionContent'
|
||||
|
||||
/** Records listed per table in the palette. */
|
||||
export const PALETTE_RECORDS_PER_TABLE = 5
|
||||
|
||||
/** Props for {@link CommandRecordResults}. */
|
||||
export interface CommandRecordResultsProps {
|
||||
/** Palette search text. */
|
||||
search: string
|
||||
/** Whether the palette is open. */
|
||||
open: boolean
|
||||
/** Searchable tables (non-empty). */
|
||||
tables: SearchableTable[]
|
||||
/** cmdk's highlighted value. */
|
||||
selectedValue: string
|
||||
/** Sets cmdk's highlighted value. */
|
||||
setSelectedValue: (value: string) => void
|
||||
/** Value of the first entry listed before the records (actions, pages), if any. */
|
||||
firstValueBefore?: string
|
||||
/** Value of the first entry listed after the records (recently viewed), if any. */
|
||||
firstValueAfter?: string
|
||||
/** Opens a path. */
|
||||
onSelect: (path: string) => void
|
||||
/** Reports how many records are listed and whether a search is pending. */
|
||||
onState: (state: { count: number; searching: boolean }) => void
|
||||
/** Class of a palette entry. */
|
||||
itemClassName: string
|
||||
/** Renders a group heading. */
|
||||
heading: (text: string) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders record search status and results as cmdk groups.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns Status and record groups.
|
||||
*/
|
||||
export function CommandRecordResults(props: CommandRecordResultsProps) {
|
||||
const { search, open, tables, selectedValue, setSelectedValue, firstValueBefore, firstValueAfter, onSelect, onState, itemClassName, heading } = props
|
||||
const trimmed = search.trim()
|
||||
const recordSearch = useRecordSearch(open ? search : '', tables, PALETTE_RECORDS_PER_TABLE)
|
||||
const groups = useMemo(
|
||||
() => groupResultsByTable(recordSearch.results, recordSearch.summaries, tables, trimmed),
|
||||
[recordSearch.results, recordSearch.summaries, tables, trimmed]
|
||||
)
|
||||
const count = groups.reduce((sum, group) => sum + group.results.length, 0)
|
||||
|
||||
useEffect(() => onState({ count, searching: recordSearch.isSearching }), [count, recordSearch.isSearching, onState])
|
||||
|
||||
// Records arrive after cmdk highlighted the first entry on typing; when that was the
|
||||
// search-everything entry (nothing else matched yet), highlight the new first entry instead.
|
||||
useEffect(() => {
|
||||
const first = firstValueBefore
|
||||
?? (groups[0] ? `record:${recordSearchResultPath(groups[0].results[0])}` : undefined)
|
||||
?? firstValueAfter
|
||||
if (first && selectedValue === `search:${searchPagePath({ q: trimmed })}`) setSelectedValue(first)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only new record results move the highlight
|
||||
}, [groups])
|
||||
|
||||
const notice = recordSearch.failure ?? recordSearch.fallback
|
||||
return (
|
||||
<>
|
||||
{recordSearch.isSearching && !recordSearch.failure && (
|
||||
<p className="flex items-center gap-2 px-4 py-1 text-xs text-muted-foreground" data-qqq-id="command-menu-searching">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
Searching records…
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="mx-3 my-1 flex items-start gap-2 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground" data-qqq-id="command-menu-search-status">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
<span className="min-w-0 break-words">
|
||||
<span className="font-medium text-foreground">{notice.title}</span>
|
||||
{notice.detail && <> — {notice.detail}</>}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<Command.Group
|
||||
key={group.tableName}
|
||||
heading={heading(`${group.tableLabel} · ${tableCountLabel(group.results.length, group.summary)}`)}
|
||||
data-qqq-id={`command-group-records-${group.tableName}`}
|
||||
>
|
||||
{group.results.map((result) => {
|
||||
const path = recordSearchResultPath(result)
|
||||
return (
|
||||
<Command.Item
|
||||
key={path}
|
||||
value={`record:${path}`}
|
||||
onSelect={() => onSelect(path)}
|
||||
className={itemClassName}
|
||||
data-qqq-id={`command-record-${result.tableName}-${result.recordId}`}
|
||||
>
|
||||
<SearchOptionContent
|
||||
option={{ id: path, kind: 'record', path, label: result.recordLabel, tableLabel: group.tableLabel, icon: group.icon, result }}
|
||||
term={trimmed}
|
||||
/>
|
||||
</Command.Item>
|
||||
)
|
||||
})}
|
||||
{tableHasMore(group.results.length, group.summary, PALETTE_RECORDS_PER_TABLE) && (
|
||||
<Command.Item
|
||||
value={`search:${searchPagePath({ q: trimmed, tables: [group.tableName] })}`}
|
||||
onSelect={() => onSelect(searchPagePath({ q: trimmed, tables: [group.tableName] }))}
|
||||
className={itemClassName}
|
||||
data-qqq-id={`command-more-${group.tableName}`}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-muted-foreground">See all matches in {group.tableLabel}</span>
|
||||
</Command.Item>
|
||||
)}
|
||||
</Command.Group>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file HighlightedText — text with its matches wrapped in `<mark>`. Matches come from the
|
||||
* backend (ranges) or are found locally from the search term. Rendered as text nodes only.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { RecordSearchMatch } from '@/lib/api/record-search'
|
||||
import { findMatches, segmentText } from '@/lib/search/highlight'
|
||||
|
||||
/** Props for {@link HighlightedText}. */
|
||||
export interface HighlightedTextProps {
|
||||
/** Text to show. */
|
||||
text: string
|
||||
/** Match ranges from the backend; when omitted, the term's words are found locally. */
|
||||
matches?: RecordSearchMatch[]
|
||||
/** Search text (used when `matches` is omitted). */
|
||||
term?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text with matched runs marked.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns Text and `<mark>` elements.
|
||||
*/
|
||||
export function HighlightedText({ text, matches, term = '' }: HighlightedTextProps) {
|
||||
const segments = segmentText(text, matches ?? findMatches(text, term))
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => (segment.match
|
||||
? <mark key={index} className="rounded-sm bg-primary/20 px-px font-semibold text-foreground">{segment.text}</mark>
|
||||
: <React.Fragment key={index}>{segment.text}</React.Fragment>))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* 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 QuickSearchListbox — the results of a search-as-you-type combobox: record search
|
||||
* status (searching, the failure class with a retry, or a provider fallback notice), then
|
||||
* grouped options (Top hits, Pages, one group per table with its count, Recently viewed,
|
||||
* "search everything"), as an ARIA listbox driven by `aria-activedescendant`.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle, Clock, History, Loader2, RotateCw } from 'lucide-react'
|
||||
|
||||
import type { QuickSearchController } from '@/lib/hooks/use-quick-search'
|
||||
import type { QuickSearchOption } from '@/lib/search/search-model'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { SearchOptionContent } from './SearchOptionContent'
|
||||
|
||||
/**
|
||||
* DOM id of an option (referenced by the combobox's `aria-activedescendant`).
|
||||
*
|
||||
* @param listboxId - The listbox id.
|
||||
* @param optionId - The option id.
|
||||
* @returns A DOM-safe id.
|
||||
*/
|
||||
export function quickSearchOptionDomId(listboxId: string, optionId: string): string {
|
||||
return `${listboxId}-${optionId.replace(/[^A-Za-z0-9_-]/g, (char) => `_${char.charCodeAt(0).toString(16)}`)}`
|
||||
}
|
||||
|
||||
/** Props for {@link QuickSearchListbox}. */
|
||||
export interface QuickSearchListboxProps {
|
||||
/** DOM id of the listbox (the combobox's `aria-controls`). */
|
||||
id: string
|
||||
/** The search controller. */
|
||||
search: QuickSearchController
|
||||
/** Classes for the scrolling list (e.g. its max height). */
|
||||
listClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a link click should be left to the browser (new tab or window, download).
|
||||
*
|
||||
* @param event - The click.
|
||||
* @returns `true` for modified or non-primary clicks.
|
||||
*/
|
||||
function isBrowserClick(event: React.MouseEvent): boolean {
|
||||
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the status line, the grouped options and the empty state.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The status region and the listbox.
|
||||
*/
|
||||
export function QuickSearchListbox({ id, search, listClassName }: QuickSearchListboxProps) {
|
||||
const { term, groups, activeId, setActiveId, choose, recordSearch, clearRecent } = search
|
||||
const trimmed = term.trim()
|
||||
const activeDomId = activeId ? quickSearchOptionDomId(id, activeId) : undefined
|
||||
|
||||
// keep the active option visible while moving with the keyboard
|
||||
useEffect(() => {
|
||||
if (activeDomId) document.getElementById(activeDomId)?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [activeDomId])
|
||||
|
||||
const searching = recordSearch.isSearching
|
||||
const { failure, fallback } = recordSearch
|
||||
const matches = groups.some((group) => group.kind !== 'actions' && group.options.length > 0)
|
||||
const recentSearchGroup = !trimmed && groups.some((group) => group.kind === 'recent-searches')
|
||||
|
||||
/**
|
||||
* Renders one option.
|
||||
*
|
||||
* @param option - The option.
|
||||
* @param showTable - Show the record's table (mixed-table groups).
|
||||
* @returns The option element.
|
||||
*/
|
||||
const renderOption = (option: QuickSearchOption, showTable: boolean) => {
|
||||
const active = option.id === activeId
|
||||
const className = cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left outline-none transition-colors pointer-coarse:min-h-11',
|
||||
active ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)
|
||||
const common = {
|
||||
id: quickSearchOptionDomId(id, option.id),
|
||||
role: 'option' as const,
|
||||
'aria-selected': active,
|
||||
// mousemove, not mouseenter: results rendering under a resting pointer must not steal the keyboard selection
|
||||
onMouseMove: () => { if (!active) setActiveId(option.id) },
|
||||
className,
|
||||
'data-qqq-id': `search-option-${option.kind}`,
|
||||
'data-option-id': option.id,
|
||||
}
|
||||
if (option.kind === 'query') {
|
||||
return (
|
||||
<div key={option.id} {...common} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(option)}>
|
||||
<SearchOptionContent option={option} term={trimmed} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={option.id}
|
||||
{...common}
|
||||
href={option.path}
|
||||
prefetch={false}
|
||||
tabIndex={-1}
|
||||
// keep focus in the input
|
||||
onMouseDown={(event) => { if (event.button === 0) event.preventDefault() }}
|
||||
onClick={(event) => {
|
||||
if (isBrowserClick(event)) return
|
||||
event.preventDefault()
|
||||
choose(option)
|
||||
}}
|
||||
>
|
||||
<SearchOptionContent option={option} term={trimmed} showTable={showTable} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Record search progress, failure and fallback, announced politely (outside the listbox: not options) */}
|
||||
<div role="status" aria-live="polite" data-qqq-id="search-results-status">
|
||||
{searching && !failure && (
|
||||
<p className="flex items-center gap-2 px-3 pt-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
Searching records…
|
||||
</p>
|
||||
)}
|
||||
{failure && (
|
||||
<div className="mx-2 mt-2 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" data-qqq-id="search-failure" data-failure-kind={failure.kind}>
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground">{failure.title}</p>
|
||||
{failure.detail && <p className="break-words text-muted-foreground">{failure.detail}</p>}
|
||||
<p className="text-muted-foreground">Showing pages and recently viewed records only.</p>
|
||||
</div>
|
||||
{failure.retryable && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={recordSearch.retry}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="button-search-retry"
|
||||
>
|
||||
<RotateCw className="h-3 w-3" aria-hidden="true" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fallback && !failure && (
|
||||
<div className="mx-2 mt-2 flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs" data-qqq-id="search-fallback">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">{fallback.title}</p>
|
||||
{fallback.detail && <p className="break-words text-muted-foreground">{fallback.detail}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={id}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
aria-busy={searching}
|
||||
className={cn('overflow-y-auto overscroll-contain py-1', recordSearch.isStale && 'opacity-80', listClassName)}
|
||||
data-qqq-id="search-results"
|
||||
>
|
||||
{trimmed && !matches && !searching && (
|
||||
<p className="px-3 py-5 text-center text-sm text-muted-foreground" data-qqq-id="search-results-empty">
|
||||
{recordSearch.available && !failure ? 'No pages or records match' : 'No pages or recent records match'} “{trimmed}”
|
||||
</p>
|
||||
)}
|
||||
{!trimmed && groups.length === 0 && (
|
||||
<p className="px-3 py-5 text-center text-sm text-muted-foreground" data-qqq-id="search-results-hint">
|
||||
{recordSearch.available ? 'Type to find an app, table, process or record.' : 'Type to find an app, table, process or recent record.'}
|
||||
</p>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<div key={group.id} role="group" aria-labelledby={`${id}-group-${group.id.replace(/[^A-Za-z0-9_-]/g, '_')}`} data-qqq-id={`search-group-${group.kind}`}>
|
||||
<div
|
||||
id={`${id}-group-${group.id.replace(/[^A-Za-z0-9_-]/g, '_')}`}
|
||||
className="flex items-center gap-2 px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{group.kind === 'recent' && <Clock className="h-3 w-3" aria-hidden="true" />}
|
||||
{group.kind === 'recent-searches' && <History className="h-3 w-3" aria-hidden="true" />}
|
||||
{group.kind === 'table' && <MetadataIcon icon={group.icon} kind="table" className="h-3 w-3" />}
|
||||
<span className="truncate">{group.heading}</span>
|
||||
{group.count && (
|
||||
<>
|
||||
<span className="ml-auto rounded-full bg-muted px-1.5 py-px text-[0.65rem] font-medium normal-case tracking-normal tabular-nums" aria-hidden="true">
|
||||
{group.count}
|
||||
</span>
|
||||
<span className="sr-only">{` ${group.count} matches`}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{group.options.map((option) => renderOption(option, group.kind === 'top'))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recentSearchGroup && (
|
||||
<div className="flex justify-end border-t border-border px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={clearRecent}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="button-clear-recent-searches"
|
||||
>
|
||||
Clear recent searches
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 QuickSearchPanel — the results half of a search-as-you-type combobox (record
|
||||
* search, ranking, grouping, listbox). Loaded on demand by the header search and the `/`
|
||||
* dialog, which own only the input: the panel hands its keyboard handler and active option
|
||||
* back through `onController`, so every page's first load stays small.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { useQuickSearch } from '@/lib/hooks/use-quick-search'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
import { QuickSearchListbox, quickSearchOptionDomId } from './QuickSearchListbox'
|
||||
|
||||
/** What the input needs from a mounted panel. */
|
||||
export interface QuickSearchPanelController {
|
||||
/** Keyboard handler for the combobox input (↑↓, Enter, Ctrl/⌘+Enter). */
|
||||
onInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
/** DOM id of the active option (`aria-activedescendant`), if any. */
|
||||
activeDescendant?: string
|
||||
/** Whether the panel has anything to show (results, recents or a hint). */
|
||||
hasContent: boolean
|
||||
}
|
||||
|
||||
/** Props for {@link QuickSearchPanel}. */
|
||||
export interface QuickSearchPanelProps {
|
||||
/** DOM id of the listbox (the combobox's `aria-controls`). */
|
||||
listboxId: string
|
||||
/** Whether the search is open. */
|
||||
open: boolean
|
||||
/** Search text (owned by the input). */
|
||||
term: string
|
||||
/** Sets the search text. */
|
||||
setTerm: (term: string) => void
|
||||
/** Navigable pages. */
|
||||
navTargets: NavTarget[]
|
||||
/** Searchable tables (empty: record search unavailable, never called). */
|
||||
searchTables: SearchableTable[]
|
||||
/** Navigates in this tab (the caller closes the search). */
|
||||
onNavigate: (path: string) => void
|
||||
/** Receives the controller whenever it changes (`null` on unmount). */
|
||||
onController: (controller: QuickSearchPanelController | null) => void
|
||||
/** Classes for the scrolling list. */
|
||||
listClassName?: string
|
||||
/** Rendered below the results (e.g. keyboard hints) when there is content. */
|
||||
footer?: React.ReactNode
|
||||
/** Render nothing (not even the typing hint) while there is nothing to show (a dropdown). */
|
||||
hideWhenEmpty?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the quick search results and wires them to the caller's input.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The results (or the typing hint), or nothing when empty and `hideWhenEmpty`.
|
||||
*/
|
||||
export function QuickSearchPanel({ listboxId, open, term, setTerm, navTargets, searchTables, onNavigate, onController, listClassName, footer, hideWhenEmpty = false }: QuickSearchPanelProps) {
|
||||
const search = useQuickSearch({ open, term, setTerm, navTargets, searchTables, onNavigate })
|
||||
const hasContent = term.trim().length > 0 || search.groups.length > 0
|
||||
const activeDescendant = search.activeId ? quickSearchOptionDomId(listboxId, search.activeId) : undefined
|
||||
const { onInputKeyDown } = search
|
||||
|
||||
useEffect(() => {
|
||||
onController({ onInputKeyDown, activeDescendant, hasContent })
|
||||
}, [onController, onInputKeyDown, activeDescendant, hasContent])
|
||||
useEffect(() => () => onController(null), [onController])
|
||||
|
||||
if (!hasContent && hideWhenEmpty) return null
|
||||
return (
|
||||
<>
|
||||
<QuickSearchListbox id={listboxId} search={search} listClassName={listClassName} />
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import type { QFieldMetaData, QTableMetaData } from '@/types'
|
||||
import { previewFields } from './RecordPreview'
|
||||
|
||||
/** A field for the fixture. */
|
||||
const field = (name: string, extra: Partial<QFieldMetaData> = {}) => ({ name, label: name, type: 'STRING', adornments: [], ...extra }) as unknown as QFieldMetaData
|
||||
|
||||
describe('previewFields', () => {
|
||||
it('shows the first-tier fields but never the key, heavy fields, secrets, widgets, code, HTML or binary data', () => {
|
||||
const fields = {
|
||||
id: field('id', { type: 'INTEGER' }),
|
||||
name: field('name'),
|
||||
apiKey: field('apiKey', { adornments: [{ type: 'REVEAL' }] } as Partial<QFieldMetaData>),
|
||||
chart: field('chart', { adornments: [{ type: 'WIDGET' }] } as Partial<QFieldMetaData>),
|
||||
script: field('script', { adornments: [{ type: 'CODE_EDITOR' }] } as Partial<QFieldMetaData>),
|
||||
body: field('body', { type: 'HTML' } as Partial<QFieldMetaData>),
|
||||
blob: field('blob', { type: 'BLOB' } as Partial<QFieldMetaData>),
|
||||
notes: field('notes', { isHeavy: true }),
|
||||
status: field('status'),
|
||||
other: field('other'),
|
||||
}
|
||||
const table = {
|
||||
name: 'thing',
|
||||
primaryKeyField: 'id',
|
||||
fields,
|
||||
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'name', 'apiKey', 'chart', 'script', 'body', 'blob', 'notes', 'status'] }, { name: 'more', tier: 'T2', fieldNames: ['other'] }],
|
||||
} as unknown as QTableMetaData
|
||||
expect(previewFields(table).map((f) => f.name)).toEqual(['name', 'status'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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 RecordPreview — a search result's record without leaving the results: its key
|
||||
* fields (the table's first-tier sections, as on the record view) as text, Yes/No and links,
|
||||
* the fields that matched the search, and quick actions (open, open in a new tab, copy link,
|
||||
* edit when permitted). Loaded on demand by the search page. File thumbnails and chips are
|
||||
* left to the record view: their modules measured +9.3 KB total JS here.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink, Link2, Loader2, Pencil, ArrowRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QFieldMetaData, QIcon, QRecord, QTableMetaData } from '@/types'
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { findAdornment } from '@/lib/utils/adornment-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { isEmail, isHttpUrl } from '@/lib/utils/string-utils'
|
||||
import { recordSearchResultPath } from '@/lib/utils/record-search'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
|
||||
/** Most fields shown in a preview. */
|
||||
const MAX_PREVIEW_FIELDS = 10
|
||||
|
||||
/**
|
||||
* The fields a preview shows: the visible fields of the table's first-tier sections (as the
|
||||
* record view's T1 cards), else all visible fields; never heavy fields, the primary key, secrets,
|
||||
* widgets, code, HTML or binary data.
|
||||
*
|
||||
* @param table - Full table metadata.
|
||||
* @returns Up to {@link MAX_PREVIEW_FIELDS} fields.
|
||||
*/
|
||||
export function previewFields(table: QTableMetaData): QFieldMetaData[] {
|
||||
const fields = table.fields ?? {}
|
||||
const t1 = (table.sections ?? []).filter((section) => !section.isHidden && (!section.tier || section.tier === 'T1' || section.tier === 'basic'))
|
||||
const names = t1.length > 0 ? t1.flatMap((section) => section.fieldNames ?? []) : Object.keys(fields)
|
||||
return [...new Set(names)]
|
||||
.map((name) => fields[name])
|
||||
.filter((field): field is QFieldMetaData => Boolean(field) && !field.isHidden && !field.isHeavy && field.name !== table.primaryKeyField && previewable(field))
|
||||
.slice(0, MAX_PREVIEW_FIELDS)
|
||||
}
|
||||
|
||||
/** Adornments whose fields a glance does not show: secrets, embedded widgets, code and markup. */
|
||||
const NOT_PREVIEWED = ['REVEAL', 'WIDGET', 'CODE_EDITOR', 'RENDER_HTML'] as const
|
||||
|
||||
/**
|
||||
* Whether a preview shows a field: never secrets (REVEAL), widgets, code, HTML or binary data.
|
||||
*
|
||||
* @param field - The field.
|
||||
* @returns True when the preview shows it.
|
||||
*/
|
||||
function previewable(field: QFieldMetaData): boolean {
|
||||
if (field.type === 'BLOB' || field.type === 'HTML') return false
|
||||
return !NOT_PREVIEWED.some((type) => findAdornment(field, type))
|
||||
}
|
||||
|
||||
/** Link classes (taller touch targets on coarse pointers). */
|
||||
const LINK_CLASS = 'text-primary hover:text-primary/80 hover:underline pointer-coarse:inline-flex pointer-coarse:min-h-11 pointer-coarse:items-center'
|
||||
|
||||
/**
|
||||
* A field's value at a glance: Yes/No, email and web links, else the display value (which
|
||||
* QQQ formats for dates, possible values and files). Lighter than the record view's
|
||||
* FieldValue (no widgets), so the preview loads quickly.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.field - The field.
|
||||
* @param props.record - The record.
|
||||
* @param props.table - Its table.
|
||||
* @returns The value.
|
||||
*/
|
||||
function PreviewValue({ field, record, table }: { field: QFieldMetaData; record: QRecord; table: QTableMetaData }) {
|
||||
const rawValue = record.values[field.name]
|
||||
const displayValue = record.displayValues?.[field.name]
|
||||
const value = displayValue ?? rawValue
|
||||
const dataQqqId = `search-preview-value-${field.name}`
|
||||
|
||||
if (value === null || value === undefined || value === '') return <span className="text-muted-foreground" data-qqq-id={dataQqqId}>—</span>
|
||||
|
||||
if (field.type === 'BOOLEAN') {
|
||||
const yes = rawValue === true || rawValue === 'true' || rawValue === 1
|
||||
return <span data-qqq-id={dataQqqId}>{yes ? 'Yes' : 'No'}</span>
|
||||
}
|
||||
const text = String(value)
|
||||
if (field.type === 'STRING' && isHttpUrl(text)) {
|
||||
return <a href={text} target="_blank" rel="noopener noreferrer" className={cn('break-all', LINK_CLASS)} data-qqq-id={dataQqqId}>{text}<span className="sr-only"> (opens in a new tab)</span></a>
|
||||
}
|
||||
if (field.type === 'STRING' && isEmail(text)) {
|
||||
return <a href={`mailto:${text}`} className={cn('break-all', LINK_CLASS)} data-qqq-id={dataQqqId}>{text}</a>
|
||||
}
|
||||
return <span className={cn('whitespace-pre-wrap', field.type === 'TEXT' && 'line-clamp-3')} data-qqq-id={dataQqqId}>{text}</span>
|
||||
}
|
||||
|
||||
/** Props for {@link RecordPreview}. */
|
||||
export interface RecordPreviewProps {
|
||||
/** The search result to preview. */
|
||||
result: RecordSearchResult
|
||||
/** Table label. */
|
||||
tableLabel: string
|
||||
/** Table icon. */
|
||||
icon?: QIcon
|
||||
/** Search text (highlights the label and matched fields). */
|
||||
term: string
|
||||
/** Heading level of the record label (2 in the split view, 2 in the sheet). */
|
||||
headingId?: string
|
||||
}
|
||||
|
||||
/** Classes of a quick action. */
|
||||
const ACTION_CLASS = 'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11'
|
||||
|
||||
/**
|
||||
* Renders a record preview: header, quick actions, matched fields and key fields.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The preview.
|
||||
*/
|
||||
export function RecordPreview({ result, tableLabel, icon, term, headingId }: RecordPreviewProps) {
|
||||
const path = recordSearchResultPath(result)
|
||||
// The queries of useTableMetaData and useRecord (same keys, so the caches are shared),
|
||||
// called directly: importing those hooks here re-bundles them into more screen chunks
|
||||
// (measured +12 KB total JS with Turbopack)
|
||||
const metadata = useQuery<QTableMetaData>({
|
||||
queryKey: queryKeys.tableMetadata(result.tableName),
|
||||
queryFn: () => loadTableMetaData(result.tableName),
|
||||
staleTime: 30 * 60_000, // as useTableMetaData
|
||||
})
|
||||
const recordQuery = useQuery({
|
||||
queryKey: [...queryKeys.tableRecord(result.tableName, result.recordId), { includeAssociations: false }],
|
||||
queryFn: () => getRecord(result.tableName, result.recordId, { includeAssociations: false }),
|
||||
enabled: Boolean(metadata.data) && !metadata.isError,
|
||||
staleTime: 60_000,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const record = recordQuery.data
|
||||
const { isLoading, isError } = recordQuery
|
||||
const table = metadata.data
|
||||
const fields = table ? previewFields(table) : []
|
||||
// as canEditRecords: the edit permission and the TABLE_UPDATE capability
|
||||
const canEdit = Boolean(table?.editPermission) && Boolean(table?.capabilities?.includes('TABLE_UPDATE'))
|
||||
const highlights = (result.highlights ?? []).filter((highlight) => highlight.text.trim())
|
||||
|
||||
/** Copies the record's link. */
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(path, window.location.origin).toString())
|
||||
toast.success('Link copied')
|
||||
} catch {
|
||||
toast.error('Could not copy the link')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="space-y-4" aria-labelledby={headingId} data-qqq-id="search-record-preview">
|
||||
<header className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<MetadataIcon icon={icon} kind="table" className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id={headingId} className="break-words text-lg font-semibold leading-tight text-foreground">
|
||||
<HighlightedText text={record?.recordLabel || result.recordLabel} term={term} />
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{tableLabel} · #{result.recordId}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2" data-qqq-id="search-preview-actions">
|
||||
<Link href={path} prefetch={false} className={`${ACTION_CLASS} border-primary bg-primary text-primary-foreground hover:bg-primary/90`} data-qqq-id="search-preview-open">
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
Open
|
||||
</Link>
|
||||
<a href={path} target="_blank" rel="noopener noreferrer" className={ACTION_CLASS} data-qqq-id="search-preview-new-tab">
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
New tab
|
||||
</a>
|
||||
<button type="button" onClick={() => void copyLink()} className={ACTION_CLASS} data-qqq-id="search-preview-copy-link">
|
||||
<Link2 className="h-4 w-4" aria-hidden="true" />
|
||||
Copy link
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link href={`${path}/edit`} prefetch={false} className={ACTION_CLASS} data-qqq-id="search-preview-edit">
|
||||
<Pencil className="h-4 w-4" aria-hidden="true" />
|
||||
Edit
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{highlights.length > 0 && (
|
||||
<section aria-label="Why it matched" className="rounded-lg bg-muted/50 px-3 py-2">
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Matched</h3>
|
||||
<dl className="space-y-0.5 text-sm">
|
||||
{highlights.map((highlight) => (
|
||||
<div key={highlight.fieldName} className="flex min-w-0 gap-1.5">
|
||||
<dt className="shrink-0 text-muted-foreground">{highlight.fieldLabel ?? highlight.fieldName}:</dt>
|
||||
<dd className="min-w-0 break-words text-foreground">
|
||||
<HighlightedText text={highlight.text} matches={highlight.matches.length > 0 ? highlight.matches : undefined} term={term} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{metadata.isError || isError ? (
|
||||
<p role="alert" className="text-sm text-muted-foreground" data-qqq-id="search-preview-error">
|
||||
The record’s details could not be loaded. Open it to see why.
|
||||
</p>
|
||||
) : !table || isLoading || !record ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading details…
|
||||
</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2" data-qqq-id="search-preview-fields">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="min-w-0">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{field.label}</dt>
|
||||
<dd className="min-w-0 break-words text-sm text-foreground">
|
||||
<PreviewValue field={field} record={record} table={table} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 SearchOptionContent — the inside of one quick search entry: icon, highlighted label,
|
||||
* a secondary line (matched field snippet, secondary values, or where a page lives) and,
|
||||
* for records, when it was last updated. Shared by the header search, the `/` dialog and
|
||||
* the command palette.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ArrowRight, History, Search } from 'lucide-react'
|
||||
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
import type { QuickSearchOption } from '@/lib/search/search-model'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/** Type shown for each app-tree node type. */
|
||||
const TYPE_LABEL: Record<string, string> = { APP: 'App', TABLE: 'Table', PROCESS: 'Process', REPORT: 'Report' }
|
||||
|
||||
/**
|
||||
* The secondary line of a record result: its first matched field as a snippet, else its
|
||||
* secondary values.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.result - The record search result.
|
||||
* @param props.term - Search text.
|
||||
* @returns The line, or `null` when the result has neither.
|
||||
*/
|
||||
export function RecordSnippet({ result, term }: { result: RecordSearchResult; term: string }) {
|
||||
const highlight = result.highlights?.find((candidate) => candidate.text.trim())
|
||||
if (highlight) {
|
||||
return (
|
||||
<>
|
||||
{highlight.fieldLabel && <span className="text-muted-foreground/80">{highlight.fieldLabel}: </span>}
|
||||
<HighlightedText text={highlight.text} matches={highlight.matches.length > 0 ? highlight.matches : undefined} term={term} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
const details = result.details?.filter((detail) => detail.displayValue.trim()).slice(0, 3)
|
||||
if (details && details.length > 0) {
|
||||
return <>{details.map((detail) => detail.displayValue).join(' · ')}</>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Props for {@link SearchOptionContent}. */
|
||||
export interface SearchOptionContentProps {
|
||||
/** The entry. */
|
||||
option: QuickSearchOption
|
||||
/** Search text (for highlighting). */
|
||||
term: string
|
||||
/** Show the table label beside a record (where records of several tables mix, e.g. "Top hits"). */
|
||||
showTable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one quick search entry's content (the caller supplies the interactive element).
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns Icon, label and secondary content.
|
||||
*/
|
||||
export function SearchOptionContent({ option, term, showTable = false }: SearchOptionContentProps) {
|
||||
if (option.kind === 'query') {
|
||||
const Icon = option.completion ? Search : History
|
||||
return (
|
||||
<>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
{option.completion && option.term.toLowerCase().startsWith(term.trim().toLowerCase())
|
||||
? <>{option.term.slice(0, term.trim().length)}<span className="font-semibold">{option.term.slice(term.trim().length)}</span></>
|
||||
: option.term}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (option.kind === 'more') {
|
||||
return (
|
||||
<>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
See all {option.count ? `${option.count} ` : ''}matches in {option.tableLabel}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (option.kind === 'all') {
|
||||
return (
|
||||
<>
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
Search everything for <span className="font-medium">“{option.term}”</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
let icon: React.ReactNode
|
||||
let secondary: React.ReactNode = null
|
||||
let aside: React.ReactNode = null
|
||||
if (option.kind === 'page') {
|
||||
icon = <MetadataIcon icon={option.icon} kind={option.nodeType ? ICON_KIND[option.nodeType] : 'app'} className="text-muted-foreground" />
|
||||
const type = option.nodeType ? TYPE_LABEL[option.nodeType] : undefined
|
||||
secondary = [type, option.context].filter(Boolean).join(' · ') || null
|
||||
} else if (option.kind === 'recent') {
|
||||
icon = <MetadataIcon icon={option.icon} kind="table" className="text-muted-foreground" />
|
||||
secondary = option.tableLabel
|
||||
} else {
|
||||
icon = <MetadataIcon icon={option.icon} kind="table" className="text-muted-foreground" />
|
||||
secondary = <RecordSnippet result={option.result} term={term} />
|
||||
aside = (
|
||||
<span className="flex shrink-0 flex-col items-end gap-0.5 text-xs text-muted-foreground">
|
||||
{showTable && <span className="max-w-[9rem] truncate">{option.tableLabel}</span>}
|
||||
{option.result.updatedAt && <DateTimeText value={option.result.updatedAt} type="DATE_TIME" relative className="text-xs text-muted-foreground" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
<HighlightedText text={option.label} term={term} />
|
||||
</span>
|
||||
{secondary && <span className="block truncate text-xs text-muted-foreground">{secondary}</span>}
|
||||
</span>
|
||||
{aside}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* 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 search page: URL state (q, tables, view, sort), per-table sections and
|
||||
// filter counts, one-table paging, honest failure and fallback states, quick actions
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AxiosError, type AxiosResponse } from 'axios'
|
||||
|
||||
const replaceMock = vi.fn()
|
||||
const urlState = vi.hoisted(() => ({ params: new URLSearchParams() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: replaceMock, back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => '/app/search',
|
||||
useSearchParams: () => urlState.params,
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
|
||||
const ACCOUNT_TABLE = {
|
||||
name: 'account', label: 'Accounts', isHidden: false, readPermission: true, editPermission: true, capabilities: ['TABLE_GET', 'TABLE_QUERY', 'TABLE_UPDATE'],
|
||||
primaryKeyField: 'id',
|
||||
fields: {
|
||||
id: { name: 'id', label: 'Id', type: 'INTEGER', isHidden: false, isHeavy: false, adornments: [] },
|
||||
name: { name: 'name', label: 'Name', type: 'STRING', isHidden: false, isHeavy: false, adornments: [] },
|
||||
status: { name: 'status', label: 'Status', type: 'STRING', isHidden: false, isHeavy: false, adornments: [] },
|
||||
owner: { name: 'owner', label: 'Owner', type: 'STRING', isHidden: false, isHeavy: false, adornments: [] },
|
||||
notes: { name: 'notes', label: 'Notes', type: 'TEXT', isHidden: false, isHeavy: true, adornments: [] },
|
||||
},
|
||||
sections: [
|
||||
{ name: 'identity', label: 'Identity', tier: 'T1', isHidden: false, fieldNames: ['id', 'name', 'status', 'owner', 'notes'] },
|
||||
{ name: 'more', label: 'More', tier: 'T2', isHidden: false, fieldNames: ['notes'] },
|
||||
],
|
||||
}
|
||||
const media = vi.hoisted(() => ({ wide: false }))
|
||||
vi.mock('@/lib/hooks/use-media-query', () => ({ useMediaQuery: () => media.wide, PHONE_MEDIA_QUERY: '(max-width: 767px)' }))
|
||||
vi.mock('@/lib/api/tables', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/tables')>()),
|
||||
getRecord: vi.fn(async (tableName: string, id: string) => ({
|
||||
tableName, recordLabel: `Acme ${id}`,
|
||||
values: { id: Number(id), name: `Acme ${id}`, status: 'Active', owner: 'Avery' },
|
||||
displayValues: { id, name: `Acme ${id}`, status: 'Active', owner: 'Avery' },
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/api/metadata', () => ({
|
||||
loadTableMetaData: vi.fn(async () => ACCOUNT_TABLE),
|
||||
loadMetaData: vi.fn(async () => ({
|
||||
appTree: [{ name: 'crm', label: 'CRM', type: 'APP', children: [
|
||||
{ name: 'account', label: 'Accounts', type: 'TABLE' },
|
||||
{ name: 'acmeSync', label: 'Acme Sync', type: 'PROCESS' },
|
||||
] }],
|
||||
apps: {},
|
||||
processes: {},
|
||||
tables: {
|
||||
account: { name: 'account', label: 'Accounts', isHidden: false, readPermission: true, primaryKeyField: 'id', fields: {}, searchFields: ['name'], icon: { name: 'business' } },
|
||||
contact: { name: 'contact', label: 'Contacts', isHidden: false, readPermission: true, primaryKeyField: 'id', fields: {}, searchFields: ['name'] },
|
||||
},
|
||||
})),
|
||||
}))
|
||||
const searchRecordsMock = vi.fn()
|
||||
vi.mock('@/lib/api/record-search', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/record-search')>()),
|
||||
searchRecords: (...args: unknown[]) => searchRecordsMock(...args),
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
import { SearchPage } from './SearchPage'
|
||||
import { addRecentSearch, getRecentSearches } from '@/lib/search/recent-searches'
|
||||
import { addRecentRecord, clearRecentRecords } from '@/lib/utils/recent-records'
|
||||
|
||||
/**
|
||||
* Renders the page for a URL query string.
|
||||
*
|
||||
* @param query - e.g. `q=acme&tables=account`.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderPage(query: string) {
|
||||
urlState.params = new URLSearchParams(query)
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><SearchPage /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Account results with ids `from`..`to`.
|
||||
*
|
||||
* @param from - First id.
|
||||
* @param to - Last id.
|
||||
* @returns Results.
|
||||
*/
|
||||
function accounts(from: number, to: number) {
|
||||
return Array.from({ length: to - from + 1 }, (_, index) => ({ tableName: 'account', recordId: String(from + index), recordLabel: `Acme ${from + index}` }))
|
||||
}
|
||||
|
||||
const OVERVIEW = {
|
||||
results: [
|
||||
{ tableName: 'account', tableLabel: 'Accounts', recordId: '1', recordLabel: 'Acme Corp', updatedAt: '2026-10-01T10:00:00Z', highlights: [{ fieldName: 'domain', fieldLabel: 'Domain', text: 'acme.com', matches: [{ start: 0, end: 4 }] }], details: [{ fieldName: 'city', fieldLabel: 'City', displayValue: 'Austin' }] },
|
||||
{ tableName: 'contact', tableLabel: 'Contacts', recordId: '9', recordLabel: 'Wile Coyote' },
|
||||
],
|
||||
tables: [
|
||||
{ tableName: 'account', resultCount: 1, hasMore: true, totalCount: 42, source: 'PROVIDER' },
|
||||
{ tableName: 'contact', resultCount: 1, hasMore: false, source: 'PROVIDER' },
|
||||
],
|
||||
provider: { status: 'OK' },
|
||||
}
|
||||
|
||||
describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
media.wide = false
|
||||
replaceMock.mockReset()
|
||||
searchRecordsMock.mockReset()
|
||||
localStorage.removeItem('qqq-recent-searches')
|
||||
clearRecentRecords()
|
||||
})
|
||||
|
||||
it('shows pages, then each table with its count, highlighted snippets, details and filters with counts', async () => {
|
||||
searchRecordsMock.mockResolvedValue(OVERVIEW)
|
||||
renderPage('q=acme')
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Results for “acme”')
|
||||
const accountsSection = await screen.findByRole('region', { name: 'Accounts' })
|
||||
expect(accountsSection).toHaveTextContent('42')
|
||||
const card = within(accountsSection).getAllByRole('listitem')[0]
|
||||
expect(card).toHaveTextContent('Domain:acme.com')
|
||||
expect(card).toHaveTextContent('City: Austin')
|
||||
const chips = card.querySelectorAll('[data-qqq-id="search-result-detail-chip"]')
|
||||
expect(Array.from(chips).map((chip) => chip.textContent)).toEqual(['City: Austin'])
|
||||
expect(card.querySelector('time')).toHaveAttribute('dateTime')
|
||||
expect(Array.from(card.querySelectorAll('mark')).map((mark) => mark.textContent)).toEqual(['Acme', 'acme'])
|
||||
expect(within(accountsSection).getByRole('link', { name: 'Show all' })).toHaveAttribute('href', '/app/search?q=acme&tables=account')
|
||||
expect(screen.getByRole('region', { name: 'Pages' })).toHaveTextContent('Acme Sync')
|
||||
const filters = screen.getByRole('navigation', { name: 'Filter results' })
|
||||
expect(within(filters).getByRole('link', { name: /^All/ })).toHaveAttribute('aria-current', 'page')
|
||||
expect(within(filters).getByRole('link', { name: /Accounts/ })).toHaveTextContent('42')
|
||||
expect(within(filters).getByRole('link', { name: /Pages/ })).toHaveAttribute('href', '/app/search?q=acme&view=pages')
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('acme', expect.objectContaining({ tableNames: ['account', 'contact'], limitPerTable: 8, sort: 'relevance' }))
|
||||
})
|
||||
|
||||
it('pages one table 25 at a time while the backend reports more, without duplicates', async () => {
|
||||
searchRecordsMock.mockImplementation(async (_term: string, options: { tableNames: string[]; offset?: number }) => {
|
||||
if (options.tableNames.length > 1) return OVERVIEW
|
||||
if (!options.offset) return { results: accounts(1, 25), tables: [{ tableName: 'account', hasMore: true, totalCount: 30 }] }
|
||||
return { results: [...accounts(25, 30)], tables: [{ tableName: 'account', hasMore: false, totalCount: 30 }] }
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme&tables=account')
|
||||
const section = await screen.findByRole('region', { name: 'Accounts' })
|
||||
await waitFor(() => expect(within(section).getAllByRole('listitem')).toHaveLength(25))
|
||||
expect(section).toHaveTextContent('25 of 30')
|
||||
await user.click(screen.getByRole('button', { name: 'Show more' }))
|
||||
await waitFor(() => expect(within(section).getAllByRole('listitem')).toHaveLength(30))
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('acme', expect.objectContaining({ tableNames: ['account'], limitPerTable: 25, offset: 25 }))
|
||||
})
|
||||
|
||||
it('says only the first page is shown when an older backend sends no summaries', async () => {
|
||||
searchRecordsMock.mockImplementation(async (_term: string, options: { tableNames: string[] }) => (options.tableNames.length > 1 ? { results: [] } : { results: accounts(1, 25) }))
|
||||
renderPage('q=acme&tables=account')
|
||||
expect(await screen.findByText('Showing the first 25 matches. Add words to narrow the search.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes the order through the URL', async () => {
|
||||
searchRecordsMock.mockResolvedValue(OVERVIEW)
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
await screen.findByRole('region', { name: 'Accounts' })
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sort' }), 'recent')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/app/search?q=acme&sort=recent', { scroll: false })
|
||||
})
|
||||
|
||||
it('updates the URL as the user types, and remembers a submitted search', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [] })
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
const input = await screen.findByRole('searchbox', { name: 'Search pages and records' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'bolt')
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith('/app/search?q=bolt', { scroll: false }))
|
||||
await user.keyboard('{Enter}')
|
||||
expect(getRecentSearches()).toEqual(['bolt'])
|
||||
})
|
||||
|
||||
it('keeps text typed after a debounced URL update when that update arrives late', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [] })
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = renderPage('q=')
|
||||
const input = await screen.findByRole('searchbox', { name: 'Search pages and records' })
|
||||
await user.type(input, 'acme')
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith('/app/search?q=acme', { scroll: false }))
|
||||
await user.type(input, ' co')
|
||||
// the router now delivers the earlier update
|
||||
urlState.params = new URLSearchParams('q=acme')
|
||||
rerender(<QueryClientProvider client={new QueryClient()}><SearchPage /></QueryClientProvider>)
|
||||
expect(input).toHaveValue('acme co')
|
||||
})
|
||||
|
||||
it('names the failure class, keeps page matches and retries', async () => {
|
||||
searchRecordsMock.mockRejectedValueOnce(new AxiosError('timeout of 30000ms exceeded', 'ECONNABORTED'))
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
const failure = await screen.findByText('Record search failed: the server took too long to answer')
|
||||
expect(failure.closest('[data-qqq-id="search-failure"]')).toHaveAttribute('data-failure-kind', 'timeout')
|
||||
expect(screen.getByRole('region', { name: 'Pages' })).toHaveTextContent('Acme Sync')
|
||||
searchRecordsMock.mockResolvedValueOnce(OVERVIEW)
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('region', { name: 'Accounts' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a permission failure without offering a retry', async () => {
|
||||
searchRecordsMock.mockRejectedValue(new AxiosError('Permission denied.', 'ERR_BAD_REQUEST', undefined, null, { status: 403, data: {}, statusText: '', headers: {}, config: {} } as unknown as AxiosResponse))
|
||||
renderPage('q=acme')
|
||||
expect(await screen.findByText('Record search failed: permission denied')).toBeInTheDocument()
|
||||
expect(screen.getByText('Permission denied.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the provider failure above its fallback results', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ ...OVERVIEW, provider: { status: 'FAILED', failureClass: 'SocketTimeoutException', message: 'Read timed out' } })
|
||||
renderPage('q=acme')
|
||||
expect(await screen.findByText('Search index unavailable — showing basic matches instead')).toBeInTheDocument()
|
||||
expect(screen.getByText('SocketTimeoutException: Read timed out')).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'Accounts' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says what matched nothing and offers to search everything from a filter', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [], tables: [] })
|
||||
renderPage('q=zzz&tables=contact')
|
||||
expect(await screen.findByText(/No Contacts records match/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Search everything instead' })).toHaveAttribute('href', '/app/search?q=zzz')
|
||||
})
|
||||
|
||||
it('without a term lists recent searches and recently viewed records', async () => {
|
||||
addRecentSearch('acme')
|
||||
addRecentRecord({ tableName: 'account', tableLabel: 'Accounts', recordId: '1', recordLabel: 'Acme Corp', path: '/app/account/1' })
|
||||
renderPage('')
|
||||
expect(await screen.findByRole('link', { name: 'acme' })).toHaveAttribute('href', '/app/search?q=acme')
|
||||
expect(screen.getByRole('region', { name: 'Recently viewed' })).toHaveTextContent('Acme Corp')
|
||||
expect(searchRecordsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wide screens preview the first result beside the list, follow hover, focus and arrow keys, and offer edit when permitted', async () => {
|
||||
media.wide = true
|
||||
searchRecordsMock.mockResolvedValue({
|
||||
results: [
|
||||
{ tableName: 'account', recordId: '1', recordLabel: 'Acme 1', score: 10, highlights: [{ fieldName: 'name', fieldLabel: 'Name', text: 'Acme 1', matches: [{ start: 0, end: 4 }] }] },
|
||||
{ tableName: 'account', recordId: '2', recordLabel: 'Acme 2', score: 3 },
|
||||
],
|
||||
tables: [{ tableName: 'account', resultCount: 2, hasMore: false }],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
const pane = await screen.findByRole('complementary', { name: 'Record preview' })
|
||||
// T1 fields, without the primary key or heavy fields
|
||||
// the preview chunk loads on demand
|
||||
expect(await within(pane).findByText('Owner', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(within(pane).getByText('Avery')).toBeInTheDocument()
|
||||
expect(within(pane).queryByText('Notes')).not.toBeInTheDocument()
|
||||
expect(within(pane).getByRole('heading', { level: 2 })).toHaveTextContent('Acme 1')
|
||||
expect(within(pane).getByText('Matched')).toBeInTheDocument()
|
||||
expect(within(pane).getByRole('link', { name: 'Edit' })).toHaveAttribute('href', '/app/account/1/edit')
|
||||
expect(within(pane).getByRole('link', { name: 'New tab' })).toHaveAttribute('target', '_blank')
|
||||
// relevance relative to the best result
|
||||
const cards = screen.getAllByRole('listitem').filter((item) => item.getAttribute('data-qqq-id') === 'search-result-card')
|
||||
expect(cards[0]).toHaveAttribute('data-active', 'true')
|
||||
expect(within(cards[0]).getByText('Strong match')).toBeInTheDocument()
|
||||
expect(within(cards[1]).getByText('Partial match')).toBeInTheDocument()
|
||||
// keyboard: focus a result, ArrowDown moves to the next and previews it
|
||||
const links = screen.getAllByRole('link').filter((link) => link.getAttribute('data-qqq-id') === 'search-result-link')
|
||||
links[0].focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(links[1]).toHaveFocus()
|
||||
await waitFor(() => expect(within(pane).getByRole('heading', { level: 2 })).toHaveTextContent('Acme 2'))
|
||||
// pointer: hovering a result previews it
|
||||
await user.hover(cards[0])
|
||||
await waitFor(() => expect(within(pane).getByRole('heading', { level: 2 })).toHaveTextContent('Acme 1'))
|
||||
})
|
||||
|
||||
it('narrow screens open the preview in a side sheet from the Preview button', async () => {
|
||||
searchRecordsMock.mockResolvedValue(OVERVIEW)
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
const accountsSection = await screen.findByRole('region', { name: 'Accounts' })
|
||||
expect(screen.queryByRole('complementary', { name: 'Record preview' })).not.toBeInTheDocument()
|
||||
await user.click(within(accountsSection).getByRole('button', { name: 'Preview Acme Corp' }))
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Preview' })
|
||||
expect(await within(sheet).findByText('Owner', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
await user.click(within(sheet).getByRole('button', { name: 'Close preview' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Preview' })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('copies a record link and offers it in a new tab', async () => {
|
||||
searchRecordsMock.mockResolvedValue(OVERVIEW)
|
||||
const user = userEvent.setup()
|
||||
// after setup: user-event installs its own clipboard
|
||||
const writeText = vi.fn(async () => undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
renderPage('q=acme')
|
||||
const accountsSection = await screen.findByRole('region', { name: 'Accounts' })
|
||||
expect(within(accountsSection).getByRole('link', { name: 'Open Acme Corp in a new tab' })).toHaveAttribute('target', '_blank')
|
||||
await user.click(within(accountsSection).getByRole('button', { name: 'Copy link to Acme Corp' }))
|
||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/app/account/1`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,670 @@
|
||||
/*
|
||||
* 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 SearchPage — the full search results page (`/app/search`). Its state lives in the
|
||||
* URL (`q`, `tables`, `view=pages`, `sort=recent`), so searches can be shared and bookmarked.
|
||||
*
|
||||
* - "All" shows matching pages, then each table's best matches with its count and a link
|
||||
* to that table's results, then matching recently viewed records.
|
||||
* - A table filter lists that table's matches 25 at a time, with "Show more" while the
|
||||
* backend reports more (older backends return one page; the page says so).
|
||||
* - Relevance or most-recent order; result cards with matched-field snippets, secondary
|
||||
* values, last update and quick actions (open, new tab, copy link).
|
||||
* - A failed record search names its failure class and keeps the page and recently viewed
|
||||
* matches; a failed search provider (index) is reported above its fallback results.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { AlertTriangle, Clock, History, Loader2, RotateCw, Search, X } from 'lucide-react'
|
||||
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { searchRecords, type RecordSearchResult } from '@/lib/api/record-search'
|
||||
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 { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
import { getRecentRecords, type RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { searchNavTargets, searchRecentRecords } from '@/lib/utils/navigation-search'
|
||||
import { isRecordSearchTerm, searchableTables } from '@/lib/utils/record-search'
|
||||
import { addRecentSearch, clearRecentSearches, getRecentSearches } from '@/lib/search/recent-searches'
|
||||
import { describeProviderFallback, describeSearchFailure } from '@/lib/search/search-failure'
|
||||
import {
|
||||
groupResultsByTable,
|
||||
labelQuality,
|
||||
orderTableResults,
|
||||
parseSearchPageState,
|
||||
searchPagePath,
|
||||
tableCountLabel,
|
||||
tableHasMore,
|
||||
type SearchPageState,
|
||||
} from '@/lib/search/search-model'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
import { SearchResultCard } from './SearchResultCard'
|
||||
|
||||
/** The record preview (key fields, thumbnails, quick actions), loaded when first shown. */
|
||||
const RecordPreview = dynamic(() => import('./RecordPreview').then((module) => module.RecordPreview), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading preview…
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
/** Wide enough for results and preview side by side. */
|
||||
const SPLIT_VIEW_QUERY = '(min-width: 1280px)'
|
||||
|
||||
/**
|
||||
* A result's key for preview state.
|
||||
*
|
||||
* @param result - The result.
|
||||
* @returns `table:id`.
|
||||
*/
|
||||
function resultKey(result: RecordSearchResult): string {
|
||||
return `${result.tableName}:${result.recordId}`
|
||||
}
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/** Type shown for each app-tree node type. */
|
||||
const TYPE_LABEL: Record<string, string> = { APP: 'App', TABLE: 'Table', PROCESS: 'Process', REPORT: 'Report' }
|
||||
|
||||
/** Records per table in the "All" overview. */
|
||||
const OVERVIEW_PER_TABLE = 8
|
||||
|
||||
/** Records per page in a one-table view (the QQQ 4.1 maximum per table). */
|
||||
const TABLE_PAGE_SIZE = 25
|
||||
|
||||
/** Pages listed in the "All" overview. */
|
||||
const OVERVIEW_PAGES = 6
|
||||
|
||||
/** Delay between typing and updating the URL (which runs the search). */
|
||||
const URL_DEBOUNCE_MS = 250
|
||||
|
||||
/** Classes of a section card. */
|
||||
const SECTION_CLASS = 'overflow-hidden rounded-xl border border-border bg-card shadow-sm'
|
||||
|
||||
/**
|
||||
* A filter chip (a link: filters are URL state).
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.href - Target URL.
|
||||
* @param props.active - Whether this filter is applied.
|
||||
* @param props.label - Visible label.
|
||||
* @param props.count - Count badge.
|
||||
* @param props.icon - Leading icon.
|
||||
* @param props.qqqId - `data-qqq-id`.
|
||||
* @returns The chip.
|
||||
*/
|
||||
function FilterChip({ href, active, label, count, icon, qqqId }: { href: string; active: boolean; label: string; count?: string; icon?: React.ReactNode; qqqId: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
replace
|
||||
scroll={false}
|
||||
prefetch={false}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors pointer-coarse:min-h-11',
|
||||
active ? 'border-primary bg-primary/10 font-medium text-primary' : 'border-border bg-card text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-qqq-id={qqqId}
|
||||
>
|
||||
{icon}
|
||||
<span className="max-w-[12rem] truncate">{label}</span>
|
||||
{count !== undefined && <span className="rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">{count}</span>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the search page.
|
||||
*
|
||||
* @returns The search form, filters and results.
|
||||
*/
|
||||
export function SearchPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { setPageHeader } = useQContext()
|
||||
const state = useMemo(() => parseSearchPageState(searchParams), [searchParams])
|
||||
const { q, sort, view } = state
|
||||
const term = q.trim()
|
||||
const [inputValue, setInputValue] = useState(q)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||
|
||||
const { data: metaData } = useQuery({ queryKey: queryKeys.metadataAll(), queryFn: loadMetaData, staleTime: 1000 * 60 * 30 })
|
||||
const { navTargets } = useAppTreeRoutes(metaData)
|
||||
const allTables = useMemo(() => searchableTables(metaData), [metaData])
|
||||
// tables named in the URL that this user can search (unknown names are ignored)
|
||||
const filterTables = useMemo(() => state.tables.filter((name) => allTables.some((table) => table.name === name)), [state.tables, allTables])
|
||||
const singleTable = filterTables.length === 1 ? allTables.find((table) => table.name === filterTables[0]) : undefined
|
||||
|
||||
useEffect(() => setPageHeader('Search'), [setPageHeader])
|
||||
useEffect(() => {
|
||||
setRecentRecords(getRecentRecords())
|
||||
setRecentSearches(getRecentSearches())
|
||||
}, [])
|
||||
|
||||
// URL → input (back/forward, links), but not the echo of our own debounced update: the
|
||||
// user may have typed more since it was sent
|
||||
const pushedQRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (pushedQRef.current !== null && q.trim() === pushedQRef.current) return
|
||||
pushedQRef.current = null
|
||||
setInputValue(q)
|
||||
}, [q])
|
||||
|
||||
// the latest URL state, for a debounced update that fires after a filter or sort change
|
||||
const latestRef = useRef({ state, filterTables })
|
||||
latestRef.current = { state, filterTables }
|
||||
|
||||
/**
|
||||
* Replaces the URL state (merging with the current state).
|
||||
*
|
||||
* @param next - Changed fields.
|
||||
*/
|
||||
const navigate = (next: Partial<SearchPageState>) => {
|
||||
router.replace(searchPagePath({ ...state, tables: filterTables, ...next }), { scroll: false })
|
||||
}
|
||||
|
||||
// input → URL, debounced: searching as you type
|
||||
useEffect(() => {
|
||||
if (inputValue.trim() === term) return
|
||||
const timer = setTimeout(() => {
|
||||
const latest = latestRef.current
|
||||
pushedQRef.current = inputValue.trim()
|
||||
router.replace(searchPagePath({ ...latest.state, tables: latest.filterTables, q: inputValue }), { scroll: false })
|
||||
}, URL_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only typing schedules a URL update
|
||||
}, [inputValue])
|
||||
|
||||
/**
|
||||
* Commits the search immediately and remembers it.
|
||||
*
|
||||
* @param event - Form submit.
|
||||
*/
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (inputValue.trim()) setRecentSearches(addRecentSearch(inputValue))
|
||||
navigate({ q: inputValue })
|
||||
}
|
||||
|
||||
// Overview: every searchable table (or the URL's tables), a few results each, for counts
|
||||
const overviewTables = filterTables.length > 1 ? allTables.filter((table) => filterTables.includes(table.name)) : allTables
|
||||
const overview = useRecordSearch(term, overviewTables, OVERVIEW_PER_TABLE, { sort, debounceMs: 0 })
|
||||
const overviewGroups = useMemo(
|
||||
() => groupResultsByTable(overview.results, overview.summaries, allTables, term, sort),
|
||||
[overview.results, overview.summaries, allTables, term, sort]
|
||||
)
|
||||
|
||||
// One table: pages of 25 while the backend reports more
|
||||
const tablePages = useInfiniteQuery({
|
||||
queryKey: [...queryKeys.search(), 'table-pages', term, singleTable?.name ?? '', sort],
|
||||
queryFn: ({ pageParam, signal }) => searchRecords(term, { tableNames: singleTable ? [singleTable.name] : [], limitPerTable: TABLE_PAGE_SIZE, offset: pageParam, sort, signal }),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last, _pages, lastOffset) => {
|
||||
const summary = last.tables?.find((table) => table.tableName === singleTable?.name)
|
||||
return summary?.hasMore && last.results.length > 0 ? lastOffset + TABLE_PAGE_SIZE : undefined
|
||||
},
|
||||
enabled: Boolean(singleTable) && isRecordSearchTerm(term),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
const tableResults = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const all: RecordSearchResult[] = []
|
||||
for (const page of tablePages.data?.pages ?? []) {
|
||||
for (const result of page.results) {
|
||||
if (result.tableName !== singleTable?.name || seen.has(result.recordId)) continue
|
||||
seen.add(result.recordId)
|
||||
all.push(result)
|
||||
}
|
||||
}
|
||||
return orderTableResults(all, term, sort)
|
||||
}, [tablePages.data, singleTable, term, sort])
|
||||
const tableSummary = tablePages.data?.pages[0]?.tables?.find((table) => table.tableName === singleTable?.name)
|
||||
const tableFailure = useMemo(() => (tablePages.isError ? describeSearchFailure(tablePages.error) : undefined), [tablePages.isError, tablePages.error])
|
||||
const tableFallback = describeProviderFallback(tablePages.data?.pages[0]?.provider)
|
||||
|
||||
// Pages and recently viewed records match locally
|
||||
const pages = useMemo(() => {
|
||||
if (!term) return []
|
||||
return searchNavTargets(navTargets, term)
|
||||
.map((target, index) => ({ target, index, quality: labelQuality(target.label, term) }))
|
||||
.sort((a, b) => b.quality - a.quality || a.index - b.index)
|
||||
.map(({ target }) => target)
|
||||
}, [navTargets, term])
|
||||
const foundPaths = useMemo(() => new Set(overview.results.map((result) => `/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`)), [overview.results])
|
||||
const recentMatches = useMemo(() => (term ? searchRecentRecords(recentRecords, term).filter((record) => !foundPaths.has(record.path)).slice(0, 5) : []), [recentRecords, term, foundPaths])
|
||||
|
||||
const recordSearchAvailable = allTables.length > 0
|
||||
const failure = singleTable ? tableFailure : overview.failure
|
||||
const fallback = singleTable ? tableFallback : overview.fallback
|
||||
const searching = singleTable ? tablePages.isFetching && !tablePages.isFetchingNextPage : overview.isSearching
|
||||
const recordCount = overviewGroups.reduce((sum, group) => sum + group.results.length, 0)
|
||||
const pagesCount = pages.length
|
||||
const nothingFound = Boolean(term) && !searching && !failure
|
||||
&& (view === 'pages' ? pagesCount === 0 : singleTable ? tableResults.length === 0 : pagesCount === 0 && recordCount === 0 && recentMatches.length === 0)
|
||||
|
||||
/**
|
||||
* Remembers the search when the user opens a result.
|
||||
*
|
||||
* @param event - Click anywhere in the results.
|
||||
*/
|
||||
const rememberOnOpen = (event: React.MouseEvent) => {
|
||||
if (term && event.target instanceof Element && event.target.closest('a[href]')) setRecentSearches(addRecentSearch(term))
|
||||
}
|
||||
|
||||
const filterHref = (next: Partial<SearchPageState>) => searchPagePath({ q: term, sort, ...next })
|
||||
|
||||
// Record preview: a split view beside the results on wide screens (following hover, focus
|
||||
// and the arrow keys), a side sheet elsewhere (its button, or a long press)
|
||||
const splitView = useMediaQuery(SPLIT_VIEW_QUERY)
|
||||
const [previewKey, setPreviewKey] = useState<string | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const shownResults = useMemo(() => {
|
||||
if (view === 'pages') return []
|
||||
if (singleTable) return tableResults.map((result) => ({ result, tableLabel: singleTable.label, icon: singleTable.icon }))
|
||||
return overviewGroups.flatMap((group) => group.results.map((result) => ({ result, tableLabel: group.tableLabel, icon: group.icon })))
|
||||
}, [view, singleTable, tableResults, overviewGroups])
|
||||
const previewed = shownResults.find((entry) => resultKey(entry.result) === previewKey) ?? (splitView ? shownResults[0] : undefined)
|
||||
const showSplit = splitView && Boolean(term) && shownResults.length > 0
|
||||
// relevance relative to the best score per table (only providers send scores)
|
||||
const bestScore = useMemo(() => {
|
||||
const best = new Map<string, number>()
|
||||
for (const { result } of shownResults) {
|
||||
if (result.score !== undefined && result.score > (best.get(result.tableName) ?? 0)) best.set(result.tableName, result.score)
|
||||
}
|
||||
return best
|
||||
}, [shownResults])
|
||||
|
||||
/**
|
||||
* Props that tie a result card to the preview.
|
||||
*
|
||||
* @param result - The card's result.
|
||||
* @returns Active state, activation and preview handlers, and relevance.
|
||||
*/
|
||||
const previewProps = (result: RecordSearchResult) => {
|
||||
const key = resultKey(result)
|
||||
const best = bestScore.get(result.tableName)
|
||||
return {
|
||||
active: showSplit && previewed !== undefined && resultKey(previewed.result) === key,
|
||||
onActivate: showSplit ? () => setPreviewKey(key) : undefined,
|
||||
onPreview: () => {
|
||||
setPreviewKey(key)
|
||||
if (!splitView) setSheetOpen(true)
|
||||
},
|
||||
relevance: result.score !== undefined && best ? Math.max(0, Math.min(1, result.score / best)) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ↑/↓ on a result moves to the previous or next result (and so the preview).
|
||||
*
|
||||
* @param event - Keydown within the results.
|
||||
*/
|
||||
const handleResultsKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
if (!(event.target instanceof HTMLElement) || !event.target.matches('[data-qqq-id="search-result-link"]')) return
|
||||
const links = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-qqq-id="search-result-link"]'))
|
||||
const next = links[links.indexOf(event.target) + (event.key === 'ArrowDown' ? 1 : -1)]
|
||||
if (!next) return
|
||||
event.preventDefault()
|
||||
next.focus()
|
||||
next.scrollIntoView?.({ block: 'nearest' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto w-full space-y-5', showSplit ? 'max-w-[110rem]' : 'max-w-5xl')} data-qqq-id="search-results-page">
|
||||
<h1 className="break-words text-2xl font-bold tracking-tight text-foreground">
|
||||
{term ? <>Results for “{term}”</> : 'Search'}
|
||||
</h1>
|
||||
|
||||
<form role="search" onSubmit={handleSubmit} className="relative" aria-label="Search">
|
||||
<Search className="pointer-events-none 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={recordSearchAvailable ? 'Find an app, table, process or record…' : 'Find an app, table, process or recent record…'}
|
||||
aria-label={recordSearchAvailable ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
className="w-full rounded-xl border border-input bg-card py-3 pl-10 pr-10 text-base focus:outline-none focus:ring-2 focus:ring-ring sm:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
data-qqq-id="search-input"
|
||||
/>
|
||||
{searching && <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" aria-hidden="true" />}
|
||||
</form>
|
||||
|
||||
{term && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<nav aria-label="Filter results" className="-mx-1 flex min-w-0 gap-2 overflow-x-auto px-1 pb-1" data-qqq-id="search-filters">
|
||||
<FilterChip href={filterHref({})} active={view !== 'pages' && filterTables.length === 0} label="All" qqqId="search-filter-all" />
|
||||
<FilterChip href={filterHref({ view: 'pages' })} active={view === 'pages'} label="Pages" count={String(pagesCount)} qqqId="search-filter-pages" />
|
||||
{overviewGroups.map((group) => (
|
||||
<FilterChip
|
||||
key={group.tableName}
|
||||
href={filterHref({ tables: [group.tableName] })}
|
||||
active={singleTable?.name === group.tableName}
|
||||
label={group.tableLabel}
|
||||
count={tableCountLabel(group.results.length, group.summary)}
|
||||
icon={<MetadataIcon icon={group.icon} kind="table" className="h-3.5 w-3.5" />}
|
||||
qqqId={`search-filter-table-${group.tableName}`}
|
||||
/>
|
||||
))}
|
||||
{singleTable && !overviewGroups.some((group) => group.tableName === singleTable.name) && (
|
||||
<FilterChip href={filterHref({ tables: [singleTable.name] })} active label={singleTable.label} qqqId={`search-filter-table-${singleTable.name}`} />
|
||||
)}
|
||||
</nav>
|
||||
{view !== 'pages' && recordSearchAvailable && (
|
||||
<label className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
Sort
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => navigate({ sort: event.target.value === 'recent' ? 'recent' : 'relevance' })}
|
||||
className="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"
|
||||
data-qqq-id="search-sort"
|
||||
>
|
||||
<option value="relevance">Best match</option>
|
||||
<option value="recent">Recently updated</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record search progress and problems, announced politely */}
|
||||
<div role="status" aria-live="polite" data-qqq-id="search-records-status">
|
||||
{searching && <span className="sr-only">Searching records…</span>}
|
||||
{failure && (
|
||||
<div className="flex flex-wrap items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm" data-qqq-id="search-failure" data-failure-kind={failure.kind}>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground">{failure.title}</p>
|
||||
{failure.detail && <p className="break-words text-muted-foreground">{failure.detail}</p>}
|
||||
<p className="text-muted-foreground">Pages and recently viewed records below still match.</p>
|
||||
</div>
|
||||
{failure.retryable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (singleTable ? void tablePages.refetch() : overview.retry())}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="button-search-retry"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fallback && !failure && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm" data-qqq-id="search-fallback">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">{fallback.title}</p>
|
||||
{fallback.detail && <p className="break-words text-muted-foreground">{fallback.detail}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!term && (
|
||||
<div className="space-y-5" data-qqq-id="search-prompt-state">
|
||||
<p className="rounded-xl border border-border bg-card px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
{recordSearchAvailable ? 'Enter text above to find apps, tables, processes and records.' : 'Enter text above to find apps, tables, processes and recently viewed records.'}
|
||||
</p>
|
||||
{recentSearches.length > 0 && (
|
||||
<section aria-labelledby="search-recent-searches" className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 id="search-recent-searches" className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<History className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Recent searches
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearRecentSearches()
|
||||
setRecentSearches([])
|
||||
}}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
data-qqq-id="button-clear-recent-searches"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((recent) => (
|
||||
<FilterChip key={recent} href={searchPagePath({ q: recent })} active={false} label={recent} qqqId="search-recent-search" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{recentRecords.length > 0 && (
|
||||
<section aria-labelledby="search-recently-viewed" className={SECTION_CLASS}>
|
||||
<h2 id="search-recently-viewed" className="flex items-center gap-2 border-b border-border px-4 py-3 text-sm font-semibold text-foreground sm:px-6">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Recently viewed
|
||||
</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{recentRecords.slice(0, 8).map((record) => (
|
||||
<li key={record.path}>
|
||||
<Link href={record.path} prefetch={false} className="flex min-w-0 items-center gap-3 px-4 py-3 text-sm hover:bg-accent sm:px-6">
|
||||
<MetadataIcon icon={record.tableIcon} kind="table" className="text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-medium text-foreground">{record.recordLabel}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{record.tableLabel}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nothingFound && (
|
||||
<div className="rounded-xl border border-border bg-card px-6 py-10 text-center" data-qqq-id="search-empty-state">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{view === 'pages' ? 'No pages match' : singleTable ? `No ${singleTable.label} records match` : recordSearchAvailable ? 'No pages or records match' : 'No pages or recent records match'} “{term}”.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Check the spelling, or try fewer or different words.</p>
|
||||
{(singleTable || view === 'pages' || filterTables.length > 1) && (
|
||||
<Link href={filterHref({})} replace className="mt-3 inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-foreground hover:bg-accent" data-qqq-id="button-search-everywhere">
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Search everything instead
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{term && (
|
||||
<div className={cn(showSplit && 'grid grid-cols-[minmax(0,1fr)_minmax(24rem,34rem)] items-start gap-6')}>
|
||||
{/* delegated click: remembers the search when a result link is followed; ↑/↓ move between results */}
|
||||
<div className="min-w-0 space-y-5" onClick={rememberOnOpen} onKeyDown={handleResultsKeyDown}>
|
||||
{view !== 'pages' && !singleTable && pages.length > 0 && (
|
||||
<PagesSection pages={pages.slice(0, OVERVIEW_PAGES)} term={term} moreHref={pages.length > OVERVIEW_PAGES ? filterHref({ view: 'pages' }) : undefined} total={pages.length} />
|
||||
)}
|
||||
{view === 'pages' && pages.length > 0 && <PagesSection pages={pages} term={term} total={pages.length} />}
|
||||
|
||||
{view !== 'pages' && !singleTable && overviewGroups.map((group) => {
|
||||
const more = tableHasMore(group.results.length, group.summary, OVERVIEW_PER_TABLE)
|
||||
return (
|
||||
<section key={group.tableName} aria-labelledby={`search-table-${group.tableName}`} className={cn(SECTION_CLASS, overview.isStale && 'opacity-80')} data-qqq-id={`search-group-table-${group.tableName}`}>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 sm:px-6">
|
||||
<MetadataIcon icon={group.icon} kind="table" className="text-muted-foreground" />
|
||||
<h2 id={`search-table-${group.tableName}`} className="min-w-0 truncate text-sm font-semibold text-foreground">{group.tableLabel}</h2>
|
||||
<span className="rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">{tableCountLabel(group.results.length, group.summary)}</span>
|
||||
{more && (
|
||||
<Link href={filterHref({ tables: [group.tableName] })} replace scroll={false} prefetch={false} className="ml-auto shrink-0 text-sm font-medium text-primary hover:underline" data-qqq-id={`search-more-${group.tableName}`}>
|
||||
Show all
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{group.results.map((result) => (
|
||||
<SearchResultCard key={result.recordId} result={result} tableLabel={group.tableLabel} icon={group.icon} term={term} {...previewProps(result)} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
{singleTable && tableResults.length > 0 && (
|
||||
<section aria-labelledby="search-table-results" className={SECTION_CLASS} data-qqq-id={`search-group-table-${singleTable.name}`}>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 sm:px-6">
|
||||
<MetadataIcon icon={singleTable.icon} kind="table" className="text-muted-foreground" />
|
||||
<h2 id="search-table-results" className="min-w-0 truncate text-sm font-semibold text-foreground">{singleTable.label}</h2>
|
||||
<span className="rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">
|
||||
{tableSummary?.totalCount !== undefined ? `${tableResults.length} of ${tableSummary.totalCount}` : tableCountLabel(tableResults.length, tablePages.hasNextPage ? { tableName: singleTable.name, hasMore: true } : undefined)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{tableResults.map((result) => (
|
||||
<SearchResultCard key={result.recordId} result={result} tableLabel={singleTable.label} icon={singleTable.icon} term={term} {...previewProps(result)} />
|
||||
))}
|
||||
</ul>
|
||||
{tablePages.hasNextPage && (
|
||||
<div className="border-t border-border px-4 py-3 text-center sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void tablePages.fetchNextPage()}
|
||||
disabled={tablePages.isFetchingNextPage}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-60"
|
||||
data-qqq-id="button-search-show-more"
|
||||
>
|
||||
{tablePages.isFetchingNextPage && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
Show more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!tablePages.hasNextPage && !tableSummary && tableResults.length >= TABLE_PAGE_SIZE && (
|
||||
<p className="border-t border-border px-4 py-3 text-center text-sm text-muted-foreground sm:px-6" data-qqq-id="search-first-page-only">
|
||||
Showing the first {TABLE_PAGE_SIZE} matches. Add words to narrow the search.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view !== 'pages' && !singleTable && recentMatches.length > 0 && (
|
||||
<section aria-labelledby="search-recent-matches" className={SECTION_CLASS} data-qqq-id="search-group-records">
|
||||
<h2 id="search-recent-matches" className="flex items-center gap-2 border-b border-border px-4 py-3 text-sm font-semibold text-foreground sm:px-6">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Recently viewed
|
||||
</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{recentMatches.map((record) => (
|
||||
<li key={record.path}>
|
||||
<Link href={record.path} prefetch={false} className="flex min-w-0 items-center gap-3 px-4 py-3 text-sm hover:bg-accent sm:px-6" data-qqq-id={`search-recent-${record.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={record.tableIcon} kind="table" className="text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-medium text-foreground"><HighlightedText text={record.recordLabel} term={term} /></span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{record.tableLabel}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{showSplit && previewed && (
|
||||
<aside
|
||||
aria-label="Record preview"
|
||||
className="sticky top-4 max-h-[calc(100dvh-6rem)] overflow-y-auto overscroll-contain rounded-xl border border-border bg-card p-5 shadow-sm"
|
||||
data-qqq-id="search-preview-pane"
|
||||
>
|
||||
<RecordPreview key={resultKey(previewed.result)} result={previewed.result} tableLabel={previewed.tableLabel} icon={previewed.icon} term={term} headingId="search-preview-title" />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Narrow screens: the preview opens as a side sheet */}
|
||||
<Dialog.Root open={sheetOpen && !splitView && previewed !== undefined} onOpenChange={setSheetOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-[2000] bg-black/40 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
className="fixed inset-y-0 right-0 z-[2001] flex w-full max-w-md flex-col bg-card shadow-xl focus:outline-none"
|
||||
aria-describedby={undefined}
|
||||
data-qqq-id="search-preview-sheet"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<Dialog.Title className="text-sm font-semibold text-muted-foreground">Preview</Dialog.Title>
|
||||
<Dialog.Close className="flex h-11 w-11 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" aria-label="Close preview">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-5">
|
||||
{previewed && <RecordPreview key={resultKey(previewed.result)} result={previewed.result} tableLabel={previewed.tableLabel} icon={previewed.icon} term={term} headingId="search-preview-sheet-title" />}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The matching pages section.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.pages - Pages to list.
|
||||
* @param props.term - Search text.
|
||||
* @param props.total - All matching pages.
|
||||
* @param props.moreHref - Link to every matching page, when not all are listed.
|
||||
* @returns The section.
|
||||
*/
|
||||
function PagesSection({ pages, term, total, moreHref }: { pages: ReturnType<typeof searchNavTargets>; term: string; total: number; moreHref?: string }) {
|
||||
return (
|
||||
<section aria-labelledby="search-pages" className={SECTION_CLASS} data-qqq-id="search-group-pages">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 sm:px-6">
|
||||
<h2 id="search-pages" className="text-sm font-semibold text-foreground">Pages</h2>
|
||||
<span className="rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">{total}</span>
|
||||
{moreHref && (
|
||||
<Link href={moreHref} replace scroll={false} prefetch={false} className="ml-auto text-sm font-medium text-primary hover:underline" data-qqq-id="search-more-pages">
|
||||
Show all
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{pages.map((target) => {
|
||||
const context = target.ancestors.map((ancestor) => ancestor.label).join(' / ')
|
||||
return (
|
||||
<li key={target.path}>
|
||||
<Link href={target.path} prefetch={false} className="flex min-w-0 items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent sm:px-6" data-qqq-id={`search-page-${target.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={target.icon} kind={ICON_KIND[target.nodeType] ?? 'app'} className="text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-medium text-foreground"><HighlightedText text={target.label} term={term} /></span>
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">{[TYPE_LABEL[target.nodeType], context].filter(Boolean).join(' · ')}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* 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 SearchResultCard — one record on the search page: highlighted label, matched field
|
||||
* snippets, secondary values, last update, and quick actions (open, open in a new tab,
|
||||
* copy link).
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Eye, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QIcon } from '@/types'
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
import { recordSearchResultPath } from '@/lib/utils/record-search'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
|
||||
/** Props for {@link SearchResultCard}. */
|
||||
export interface SearchResultCardProps {
|
||||
/** The record. */
|
||||
result: RecordSearchResult
|
||||
/** Table label (shown when `showTable`). */
|
||||
tableLabel: string
|
||||
/** Table icon. */
|
||||
icon?: QIcon
|
||||
/** Search text (highlights labels and snippets without backend ranges). */
|
||||
term: string
|
||||
/** Show the table label (results of several tables mix). */
|
||||
showTable?: boolean
|
||||
/** Whether this result is the one previewed. */
|
||||
active?: boolean
|
||||
/** Makes this result the previewed one (pointer over it, or keyboard focus on it). */
|
||||
onActivate?: () => void
|
||||
/** Opens the preview (its button, or a long press on touch screens). */
|
||||
onPreview?: () => void
|
||||
/** Relevance relative to the best result shown, 0..1, when the provider sent scores. */
|
||||
relevance?: number
|
||||
}
|
||||
|
||||
/** Long-press duration that opens the preview on touch screens. */
|
||||
const LONG_PRESS_MS = 500
|
||||
|
||||
/**
|
||||
* A three-step relevance meter.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.value - Relevance, 0..1.
|
||||
* @returns The meter.
|
||||
*/
|
||||
function RelevanceMeter({ value }: { value: number }) {
|
||||
const steps = value >= 0.75 ? 3 : value >= 0.4 ? 2 : 1
|
||||
const label = steps === 3 ? 'Strong match' : steps === 2 ? 'Good match' : 'Partial match'
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1" title={`${label} (${Math.round(value * 100)}% of the best result)`} data-qqq-id="search-result-relevance">
|
||||
<span className="flex items-end gap-0.5" aria-hidden="true">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<span key={step} className={cn('w-1 rounded-sm', step === 1 ? 'h-1.5' : step === 2 ? 'h-2' : 'h-2.5', step <= steps ? 'bg-primary' : 'bg-muted-foreground/30')} />
|
||||
))}
|
||||
</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a record link to the clipboard and confirms with a toast.
|
||||
*
|
||||
* @param path - App path of the record.
|
||||
*/
|
||||
async function copyLink(path: string): Promise<void> {
|
||||
const url = new URL(path, window.location.origin).toString()
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success('Link copied')
|
||||
} catch {
|
||||
toast.error('Could not copy the link')
|
||||
}
|
||||
}
|
||||
|
||||
/** Classes of a quick action button. */
|
||||
const ACTION_CLASS = 'h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
|
||||
/**
|
||||
* Renders one record result as a list item.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns An `<li>` with the record link and its quick actions.
|
||||
*/
|
||||
export function SearchResultCard({ result, tableLabel, icon, term, showTable = false, active = false, onActivate, onPreview, relevance }: SearchResultCardProps) {
|
||||
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const longPressed = useRef(false)
|
||||
|
||||
/** Cancels a pending long press. */
|
||||
const cancelPress = () => {
|
||||
if (pressTimer.current) clearTimeout(pressTimer.current)
|
||||
pressTimer.current = null
|
||||
}
|
||||
const path = recordSearchResultPath(result)
|
||||
// On phones the preview sheet offers New tab and Copy link, so the card keeps only Preview
|
||||
// and its text gets the width
|
||||
const secondaryActionClass = cn(ACTION_CLASS, onPreview ? 'hidden sm:inline-flex' : 'inline-flex')
|
||||
const highlights = (result.highlights ?? []).filter((highlight) => highlight.text.trim()).slice(0, 3)
|
||||
const details = (result.details ?? []).filter((detail) => detail.displayValue.trim()).slice(0, 4)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn('group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent/50 sm:px-6', active && 'bg-accent shadow-[inset_3px_0_0_var(--color-primary)]')}
|
||||
data-qqq-id="search-result-card"
|
||||
data-active={active || undefined}
|
||||
onMouseEnter={onActivate}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== 'touch' || !onPreview) return
|
||||
longPressed.current = false
|
||||
cancelPress()
|
||||
pressTimer.current = setTimeout(() => {
|
||||
longPressed.current = true
|
||||
onPreview()
|
||||
}, LONG_PRESS_MS)
|
||||
}}
|
||||
onPointerUp={cancelPress}
|
||||
onPointerMove={(event) => { if (event.pointerType === 'touch' && (Math.abs(event.movementX) > 4 || Math.abs(event.movementY) > 4)) cancelPress() }}
|
||||
onPointerCancel={cancelPress}
|
||||
onClickCapture={(event) => {
|
||||
// a long press opened the preview: do not also follow the link
|
||||
if (longPressed.current) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
longPressed.current = false
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { if (longPressed.current) event.preventDefault() }}
|
||||
>
|
||||
<MetadataIcon icon={icon} kind="table" className="mt-0.5 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<Link
|
||||
href={path}
|
||||
prefetch={false}
|
||||
className="min-w-0 break-words font-medium text-foreground after:absolute after:inset-0 after:content-[''] hover:underline focus:outline-none focus-visible:underline"
|
||||
data-qqq-id="search-result-link"
|
||||
data-result-key={`${result.tableName}:${result.recordId}`}
|
||||
onFocus={onActivate}
|
||||
>
|
||||
<HighlightedText text={result.recordLabel} term={term} />
|
||||
</Link>
|
||||
{showTable && <span className="text-xs text-muted-foreground">{tableLabel}</span>}
|
||||
{relevance !== undefined && <RelevanceMeter value={relevance} />}
|
||||
</div>
|
||||
{highlights.length > 0 && (
|
||||
<dl className="mt-1 space-y-0.5 text-sm text-muted-foreground">
|
||||
{highlights.map((highlight) => (
|
||||
<div key={highlight.fieldName} className="flex min-w-0 gap-1.5">
|
||||
{highlight.fieldLabel && <dt className="shrink-0 text-muted-foreground/80">{highlight.fieldLabel}:</dt>}
|
||||
<dd className="min-w-0 break-words">
|
||||
<HighlightedText text={highlight.text} matches={highlight.matches.length > 0 ? highlight.matches : undefined} term={term} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{(details.length > 0 || result.updatedAt) && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{details.map((detail) => (
|
||||
<span
|
||||
key={detail.fieldName}
|
||||
className="inline-flex min-w-0 max-w-full items-baseline gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5"
|
||||
data-qqq-id="search-result-detail-chip"
|
||||
>
|
||||
{detail.fieldLabel && <span className="shrink-0 text-muted-foreground">{detail.fieldLabel}<span className="sr-only">: </span></span>}
|
||||
<span className="min-w-0 break-words font-medium text-foreground">{detail.displayValue}</span>
|
||||
</span>
|
||||
))}
|
||||
{result.updatedAt && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Updated <DateTimeText value={result.updatedAt} type="DATE_TIME" relative className="text-xs text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Quick actions sit above the stretched link */}
|
||||
<div className="relative z-10 flex shrink-0 items-center gap-0.5 sm:opacity-60 sm:group-hover:opacity-100 sm:focus-within:opacity-100">
|
||||
{onPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPreview}
|
||||
className={cn(ACTION_CLASS, 'inline-flex')}
|
||||
aria-label={`Preview ${result.recordLabel}`}
|
||||
aria-pressed={active}
|
||||
title="Preview"
|
||||
data-qqq-id="search-result-preview"
|
||||
>
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={secondaryActionClass}
|
||||
aria-label={`Open ${result.recordLabel} in a new tab`}
|
||||
title="Open in new tab"
|
||||
data-qqq-id="search-result-new-tab"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyLink(path)}
|
||||
className={secondaryActionClass}
|
||||
aria-label={`Copy link to ${result.recordLabel}`}
|
||||
title="Copy link"
|
||||
data-qqq-id="search-result-copy-link"
|
||||
>
|
||||
<Link2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -91,7 +91,10 @@ export function MarkdownView({ source }: { source: string }) {
|
||||
while (i < lines.length && !lines[i].trim().startsWith('```')) code.push(lines[i++])
|
||||
i++
|
||||
blocks.push(
|
||||
<pre key={key} className="overflow-x-auto rounded bg-muted p-2 font-mono text-xs">
|
||||
<pre
|
||||
key={key}
|
||||
className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-3 font-mono text-xs"
|
||||
>
|
||||
{code.join('\n')}
|
||||
</pre>
|
||||
)
|
||||
@@ -147,5 +150,5 @@ export function MarkdownView({ source }: { source: string }) {
|
||||
paragraph.push(lines[i++])
|
||||
blocks.push(<p key={key}>{renderInline(paragraph.join(' '), key)}</p>)
|
||||
}
|
||||
return <div className="space-y-2 text-sm">{blocks}</div>
|
||||
return <div className="space-y-2 break-words text-sm text-foreground">{blocks}</div>
|
||||
}
|
||||
@@ -24,7 +24,23 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
Copy,
|
||||
Eye,
|
||||
History,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Share2,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
@@ -48,6 +64,17 @@ import {
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { coerceParameterValue, extractSqlParameters, newCellKey } from '@/lib/utils/sql-workbench'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import {
|
||||
ALERT_CLASS,
|
||||
BADGE_CLASS,
|
||||
buttonClass,
|
||||
CARD_CLASS,
|
||||
iconButtonClass,
|
||||
INPUT_CLASS,
|
||||
SELECT_CLASS,
|
||||
} from '@/components/ui/controls'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { MarkdownView } from './MarkdownView'
|
||||
import { SqlCell } from './SqlCell'
|
||||
|
||||
@@ -77,6 +104,16 @@ const PARAMETER_TYPES: NonNullable<SqlNotebookParameter['type']>[] = [
|
||||
'DATE_TIME',
|
||||
]
|
||||
|
||||
/** Plain labels of the parameter types. */
|
||||
const PARAMETER_TYPE_LABELS: Record<NonNullable<SqlNotebookParameter['type']>, string> = {
|
||||
STRING: 'Text',
|
||||
INTEGER: 'Whole number',
|
||||
DECIMAL: 'Decimal',
|
||||
BOOLEAN: 'Yes / no',
|
||||
DATE: 'Date',
|
||||
DATE_TIME: 'Date and time',
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the notebook (when saved) and renders its editor.
|
||||
*
|
||||
@@ -94,14 +131,15 @@ export function NotebookEditor(props: NotebookEditorProps) {
|
||||
|
||||
if (notebookId !== null && isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
<p className="inline-flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading notebook…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (notebookId !== null && (error || !data)) {
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
<p className={ALERT_CLASS} role="alert">
|
||||
Could not open the notebook: {getErrorMessage(error, 'not found')}
|
||||
</p>
|
||||
)
|
||||
@@ -158,6 +196,7 @@ function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; sav
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
const [previewing, setPreviewing] = useState<Record<string, boolean>>({})
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const canEdit = notebook.canEdit
|
||||
|
||||
// The request is parent state that outlives this draft (it remounts on save, restore,
|
||||
@@ -284,13 +323,61 @@ function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; sav
|
||||
updateCells(next)
|
||||
}
|
||||
|
||||
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
|
||||
const addCell = (cellType: SqlNotebookCell['cellType']) => {
|
||||
const cellKey = newCellKey()
|
||||
if (cellType === 'MARKDOWN') setPreviewing({ ...previewing, [cellKey]: false })
|
||||
updateCells([...cells, { cellKey, cellType, content: '', title: null, backendName: null }])
|
||||
}
|
||||
|
||||
/**
|
||||
* The move and delete buttons of a cell, shown in its header.
|
||||
*
|
||||
* @param index - The cell's position.
|
||||
* @returns The buttons, or nothing for a read-only notebook.
|
||||
*/
|
||||
const cellActions = (index: number) =>
|
||||
canEdit ? (
|
||||
<span className="inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClass()}
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label={`Move cell ${index + 1} up`}
|
||||
title="Move up"
|
||||
data-qqq-id={`button-sql-cell-up-${index}`}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClass()}
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === cells.length - 1}
|
||||
aria-label={`Move cell ${index + 1} down`}
|
||||
title="Move down"
|
||||
data-qqq-id={`button-sql-cell-down-${index}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButtonClass('destructive')}
|
||||
onClick={() => updateCells(cells.filter((_, i) => i !== index))}
|
||||
aria-label={`Delete cell ${index + 1}`}
|
||||
title="Delete cell"
|
||||
data-qqq-id={`button-sql-cell-delete-${index}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="@container space-y-4">
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded border px-2 py-1 text-base font-semibold"
|
||||
className={cn(INPUT_CLASS, 'h-10 text-base font-semibold')}
|
||||
value={title}
|
||||
aria-label="Notebook title"
|
||||
onChange={(e) => {
|
||||
@@ -300,299 +387,382 @@ function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; sav
|
||||
readOnly={!canEdit}
|
||||
data-qqq-id="input-sql-notebook-title"
|
||||
/>
|
||||
<label className="inline-flex items-center gap-1 text-xs">
|
||||
Default backend
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={defaultBackendName}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => {
|
||||
setDefaultBackendName(e.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
data-qqq-id="select-sql-notebook-backend"
|
||||
>
|
||||
{capabilities.backends.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} bg-primary text-primary-foreground`}
|
||||
onClick={save}
|
||||
disabled={saving || !dirty}
|
||||
data-qqq-id="button-sql-notebook-save"
|
||||
>
|
||||
{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
|
||||
</button>
|
||||
)}
|
||||
{saved && canEdit && capabilities.canShare && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => publishSqlNotebook(notebook.id, notebook.visibility !== 'SHARED'),
|
||||
notebook.visibility === 'SHARED'
|
||||
? 'Removed from the library'
|
||||
: 'Published to the library'
|
||||
)
|
||||
}
|
||||
data-qqq-id="button-sql-notebook-publish"
|
||||
>
|
||||
{notebook.visibility === 'SHARED' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
{saved && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => duplicateSqlNotebook(notebook.id),
|
||||
'Copied to your notebooks',
|
||||
(n) => (n ? n.id : null)
|
||||
)
|
||||
}
|
||||
data-qqq-id="button-sql-notebook-duplicate"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
)}
|
||||
{saved && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => setShowVersions(!showVersions)}
|
||||
aria-expanded={showVersions}
|
||||
data-qqq-id="button-sql-notebook-versions"
|
||||
>
|
||||
Versions
|
||||
</button>
|
||||
)}
|
||||
{saved && canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} text-destructive`}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete “${notebook.title}” and its versions?`))
|
||||
void act(
|
||||
() => deleteSqlNotebook(notebook.id),
|
||||
'Notebook deleted',
|
||||
() => null
|
||||
)
|
||||
}}
|
||||
data-qqq-id="button-sql-notebook-delete"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<label className="inline-flex min-w-0 items-center gap-2 text-sm text-foreground">
|
||||
<span className="whitespace-nowrap">Default backend</span>
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
value={defaultBackendName}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => {
|
||||
setDefaultBackendName(e.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
data-qqq-id="select-sql-notebook-backend"
|
||||
>
|
||||
{capabilities.backends.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2 @2xl:ml-auto">
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('primary', 'sm')}
|
||||
onClick={save}
|
||||
disabled={saving || !dirty}
|
||||
data-qqq-id="button-sql-notebook-save"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : dirty ? (
|
||||
<Save className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
|
||||
</button>
|
||||
)}
|
||||
{saved && canEdit && capabilities.canShare && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => publishSqlNotebook(notebook.id, notebook.visibility !== 'SHARED'),
|
||||
notebook.visibility === 'SHARED'
|
||||
? 'Removed from the library'
|
||||
: 'Published to the library'
|
||||
)
|
||||
}
|
||||
data-qqq-id="button-sql-notebook-publish"
|
||||
>
|
||||
<Share2 className="h-4 w-4" aria-hidden="true" />
|
||||
{notebook.visibility === 'SHARED' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
{saved && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => duplicateSqlNotebook(notebook.id),
|
||||
'Copied to your notebooks',
|
||||
(n) => (n ? n.id : null)
|
||||
)
|
||||
}
|
||||
data-qqq-id="button-sql-notebook-duplicate"
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
Duplicate
|
||||
</button>
|
||||
)}
|
||||
{saved && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
onClick={() => setShowVersions(!showVersions)}
|
||||
aria-expanded={showVersions}
|
||||
data-qqq-id="button-sql-notebook-versions"
|
||||
>
|
||||
<History className="h-4 w-4" aria-hidden="true" />
|
||||
Versions
|
||||
</button>
|
||||
)}
|
||||
{saved && canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('destructive', 'sm')}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
data-qqq-id="button-sql-notebook-delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!canEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shared by {notebook.ownerName ?? notebook.ownerUserId}. You can run it; duplicate it to
|
||||
keep changes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{confirmingDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
title={notebook.title}
|
||||
onCancel={() => setConfirmingDelete(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmingDelete(false)
|
||||
void act(
|
||||
() => deleteSqlNotebook(notebook.id),
|
||||
'Notebook deleted',
|
||||
() => null
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showVersions && (
|
||||
<ul className="space-y-1 rounded-md border p-2 text-xs" aria-label="Versions">
|
||||
{versions.map((v) => (
|
||||
<li key={v.versionNumber} className="flex items-center gap-2">
|
||||
<span className="font-mono">v{v.versionNumber}</span>
|
||||
<span>{v.savedByName}</span>
|
||||
<span className="text-muted-foreground">{v.createDate}</span>
|
||||
{v.comment && <span className="truncate">{v.comment}</span>}
|
||||
{canEdit && v.versionNumber !== notebook.currentVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} ml-auto`}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => restoreSqlNotebookVersion(notebook.id, v.versionNumber),
|
||||
`Restored version ${v.versionNumber}`
|
||||
)
|
||||
}
|
||||
data-qqq-id={`button-sql-restore-${v.versionNumber}`}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<section className={cn(CARD_CLASS, 'p-3')} aria-labelledby="sql-versions-heading">
|
||||
<h3 id="sql-versions-heading" className="mb-2 text-sm font-semibold">
|
||||
Versions
|
||||
</h3>
|
||||
<ul className="divide-y divide-border" aria-label="Versions">
|
||||
{versions.map((v) => (
|
||||
<li
|
||||
key={v.versionNumber}
|
||||
className="flex flex-wrap items-start gap-x-3 gap-y-1 py-2 text-sm"
|
||||
>
|
||||
<span className={BADGE_CLASS}>Version {v.versionNumber}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-foreground">
|
||||
{v.comment ?? (v.versionNumber === notebook.currentVersion ? 'Current' : '')}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-x-2 text-xs text-muted-foreground">
|
||||
{v.savedByName && <span>{v.savedByName}</span>}
|
||||
<DateTimeText
|
||||
value={v.createDate}
|
||||
type="DATE_TIME"
|
||||
className="text-xs text-muted-foreground"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
{canEdit && v.versionNumber !== notebook.currentVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => restoreSqlNotebookVersion(notebook.id, v.versionNumber),
|
||||
`Restored version ${v.versionNumber}`
|
||||
)
|
||||
}
|
||||
data-qqq-id={`button-sql-restore-${v.versionNumber}`}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{parameterNames.length > 0 && (
|
||||
<fieldset className="flex flex-wrap gap-3 rounded-md border p-2 text-xs">
|
||||
<legend className="px-1 font-medium">Parameters</legend>
|
||||
{parameterNames.map((name) => {
|
||||
const type = parameters.find((p) => p.name === name)?.type ?? 'STRING'
|
||||
return (
|
||||
<span key={name} className="inline-flex items-center gap-1">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="font-mono">:{name}</span>
|
||||
<input
|
||||
className="w-36 rounded border px-1 py-0.5"
|
||||
type={
|
||||
type === 'DATE'
|
||||
? 'date'
|
||||
: type === 'DATE_TIME'
|
||||
? 'datetime-local'
|
||||
: type === 'INTEGER' || type === 'DECIMAL'
|
||||
? 'number'
|
||||
: 'text'
|
||||
}
|
||||
value={values[name] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
|
||||
data-qqq-id={`input-sql-param-${name}`}
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={type}
|
||||
aria-label={`Type of :${name}`}
|
||||
onChange={(e) =>
|
||||
setParameterType(name, e.target.value as SqlNotebookParameter['type'])
|
||||
}
|
||||
data-qqq-id={`select-sql-param-type-${name}`}
|
||||
>
|
||||
{PARAMETER_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<fieldset className={cn(CARD_CLASS, 'p-3')}>
|
||||
<legend className="px-1 text-sm font-semibold">Parameters</legend>
|
||||
<div className="grid gap-3 @lg:grid-cols-[repeat(auto-fill,minmax(18rem,1fr))]">
|
||||
{parameterNames.map((name) => {
|
||||
const type = parameters.find((p) => p.name === name)?.type ?? 'STRING'
|
||||
return (
|
||||
<div key={name} className="space-y-1">
|
||||
<label
|
||||
htmlFor={`sql-param-${name}`}
|
||||
className="block font-mono text-sm text-foreground"
|
||||
>
|
||||
:{name}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id={`sql-param-${name}`}
|
||||
className={cn(INPUT_CLASS, 'h-8 min-w-0 flex-1 py-1')}
|
||||
type={
|
||||
type === 'DATE'
|
||||
? 'date'
|
||||
: type === 'DATE_TIME'
|
||||
? 'datetime-local'
|
||||
: type === 'INTEGER' || type === 'DECIMAL'
|
||||
? 'number'
|
||||
: 'text'
|
||||
}
|
||||
value={values[name] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
|
||||
data-qqq-id={`input-sql-param-${name}`}
|
||||
/>
|
||||
<select
|
||||
className={cn(SELECT_CLASS, 'w-32')}
|
||||
value={type}
|
||||
aria-label={`Type of :${name}`}
|
||||
onChange={(e) =>
|
||||
setParameterType(name, e.target.value as SqlNotebookParameter['type'])
|
||||
}
|
||||
data-qqq-id={`select-sql-param-type-${name}`}
|
||||
>
|
||||
{PARAMETER_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{PARAMETER_TYPE_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{cells.map((cell, index) => (
|
||||
<div key={cell.cellKey} className="group relative">
|
||||
{canEdit && (
|
||||
<div className="absolute -right-1 -top-2 z-10 hidden gap-1 group-focus-within:flex group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border bg-background p-0.5"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label={`Move cell ${index + 1} up`}
|
||||
data-qqq-id={`button-sql-cell-up-${index}`}
|
||||
>
|
||||
<span aria-hidden>▲</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border bg-background p-0.5"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === cells.length - 1}
|
||||
aria-label={`Move cell ${index + 1} down`}
|
||||
data-qqq-id={`button-sql-cell-down-${index}`}
|
||||
>
|
||||
<span aria-hidden>▼</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border bg-background p-0.5 text-destructive"
|
||||
onClick={() => updateCells(cells.filter((_, i) => i !== index))}
|
||||
aria-label={`Delete cell ${index + 1}`}
|
||||
data-qqq-id={`button-sql-cell-delete-${index}`}
|
||||
>
|
||||
<span aria-hidden>✕</span>
|
||||
</button>
|
||||
{cells.map((cell, index) =>
|
||||
cell.cellType === 'MARKDOWN' ? (
|
||||
<section
|
||||
key={cell.cellKey}
|
||||
className={cn(CARD_CLASS, 'space-y-2 p-3')}
|
||||
aria-label={`Markdown cell ${index + 1}`}
|
||||
data-qqq-id={`section-sql-markdown-${index}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Notes</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('ghost', 'sm')}
|
||||
onClick={() =>
|
||||
setPreviewing({ ...previewing, [cell.cellKey]: !previewing[cell.cellKey] })
|
||||
}
|
||||
data-qqq-id={`button-sql-markdown-toggle-${index}`}
|
||||
>
|
||||
{previewing[cell.cellKey] === false ? (
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Pencil className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{previewing[cell.cellKey] === false ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
)}
|
||||
{cellActions(index)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{cell.cellType === 'MARKDOWN' ? (
|
||||
<section className="rounded-md border p-3" aria-label={`Markdown cell ${index + 1}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 text-xs text-muted-foreground underline"
|
||||
onClick={() =>
|
||||
setPreviewing({ ...previewing, [cell.cellKey]: !previewing[cell.cellKey] })
|
||||
{previewing[cell.cellKey] === false ? (
|
||||
<textarea
|
||||
className={cn(INPUT_CLASS, 'block min-h-28 resize-y font-mono')}
|
||||
value={cell.content}
|
||||
aria-label={`Markdown for cell ${index + 1}`}
|
||||
onChange={(e) =>
|
||||
updateCells(
|
||||
cells.map((c, i) => (i === index ? { ...c, content: e.target.value } : c))
|
||||
)
|
||||
}
|
||||
data-qqq-id={`button-sql-markdown-toggle-${index}`}
|
||||
>
|
||||
{previewing[cell.cellKey] === false ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
{previewing[cell.cellKey] === false ? (
|
||||
<textarea
|
||||
className="block h-28 w-full rounded border p-2 font-mono text-sm"
|
||||
value={cell.content}
|
||||
aria-label={`Markdown for cell ${index + 1}`}
|
||||
onChange={(e) =>
|
||||
updateCells(
|
||||
cells.map((c, i) => (i === index ? { ...c, content: e.target.value } : c))
|
||||
)
|
||||
}
|
||||
data-qqq-id={`textarea-sql-markdown-${index}`}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownView source={cell.content || '*Empty markdown cell*'} />
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<SqlCell
|
||||
cell={cell}
|
||||
index={index}
|
||||
capabilities={capabilities}
|
||||
defaultBackendName={defaultBackendName}
|
||||
paramsFor={paramsFor}
|
||||
notebookId={saved ? notebook.id : null}
|
||||
canEdit={canEdit}
|
||||
snapshots={snapshots.filter((s) => s.cellKey === cell.cellKey)}
|
||||
onChange={(next) => updateCells(cells.map((c, i) => (i === index ? next : c)))}
|
||||
onSnapshotsChanged={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
data-qqq-id={`textarea-sql-markdown-${index}`}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownView source={cell.content || '*Empty notes*'} />
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<SqlCell
|
||||
key={cell.cellKey}
|
||||
cell={cell}
|
||||
index={index}
|
||||
capabilities={capabilities}
|
||||
defaultBackendName={defaultBackendName}
|
||||
paramsFor={paramsFor}
|
||||
notebookId={saved ? notebook.id : null}
|
||||
canEdit={canEdit}
|
||||
actions={cellActions(index)}
|
||||
snapshots={snapshots.filter((s) => s.cellKey === cell.cellKey)}
|
||||
onChange={(next) => updateCells(cells.map((c, i) => (i === index ? next : c)))}
|
||||
onSnapshotsChanged={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
updateCells([
|
||||
...cells,
|
||||
{
|
||||
cellKey: newCellKey(),
|
||||
cellType: 'SQL',
|
||||
content: '',
|
||||
title: null,
|
||||
backendName: null,
|
||||
},
|
||||
])
|
||||
}
|
||||
data-qqq-id="button-sql-add-sql-cell"
|
||||
>
|
||||
+ SQL cell
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
const cellKey = newCellKey()
|
||||
setPreviewing({ ...previewing, [cellKey]: false })
|
||||
updateCells([
|
||||
...cells,
|
||||
{ cellKey, cellType: 'MARKDOWN', content: '', title: null, backendName: null },
|
||||
])
|
||||
}}
|
||||
data-qqq-id="button-sql-add-markdown-cell"
|
||||
>
|
||||
+ Markdown cell
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
onClick={() => addCell('SQL')}
|
||||
data-qqq-id="button-sql-add-sql-cell"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Add SQL cell
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
onClick={() => addCell('MARKDOWN')}
|
||||
data-qqq-id="button-sql-add-markdown-cell"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Add notes cell
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks before a notebook and its versions are deleted (the app's standard alert dialog).
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.title - The notebook's title.
|
||||
* @param props.onConfirm - Deletes it.
|
||||
* @param props.onCancel - Closes the dialog.
|
||||
* @returns The dialog (open while mounted).
|
||||
*/
|
||||
function ConfirmDeleteDialog({
|
||||
title,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
title: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog.Root open onOpenChange={(open) => !open && onCancel()}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<AlertDialog.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto',
|
||||
'rounded-xl border border-border bg-background text-foreground shadow-lg focus:outline-none'
|
||||
)}
|
||||
data-qqq-id="dialog-sql-notebook-delete"
|
||||
>
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialog.Title className="text-lg font-semibold">
|
||||
Delete notebook?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description className="text-sm text-muted-foreground">
|
||||
“{title}” and all of its versions will be deleted. This cannot be undone.
|
||||
</AlertDialog.Description>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 rounded-b-xl bg-muted px-6 py-4">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('secondary')}
|
||||
data-qqq-id="button-sql-notebook-delete-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</AlertDialog.Cancel>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('danger')}
|
||||
onClick={onConfirm}
|
||||
data-qqq-id="button-sql-notebook-delete-confirm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
/**
|
||||
* @file PlanTree — a query plan as a collapsible tree: each node's operation, target, cost range,
|
||||
* estimated rows and (with ANALYZE) actual time, rows and loops, with a bar scaled to the most
|
||||
* expensive node. A toggle shows the raw plan text.
|
||||
* expensive node. A toggle button shows the raw plan text instead.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -26,6 +26,8 @@ import React, { useState } from 'react'
|
||||
|
||||
import type { SqlExplainResponse, SqlPlanNode } from '@/types'
|
||||
import { planMax } from '@/lib/utils/sql-workbench'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { ALERT_CLASS, buttonClass } from '@/components/ui/controls'
|
||||
|
||||
/**
|
||||
* Formats a plan number.
|
||||
@@ -60,36 +62,41 @@ function PlanNodeItem({
|
||||
const extras = Object.entries(node.extra ?? {})
|
||||
const summary = (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
||||
<span className="font-medium">{node.label}</span>
|
||||
<span className="font-semibold text-foreground">{node.label}</span>
|
||||
{node.detail && <span className="text-muted-foreground">{node.detail}</span>}
|
||||
{node.totalCost !== null && (
|
||||
<span className="font-mono text-xs">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
cost {n(node.startupCost)}..{n(node.totalCost)}
|
||||
</span>
|
||||
)}
|
||||
{node.planRows !== null && <span className="font-mono text-xs">rows {n(node.planRows)}</span>}
|
||||
{node.planRows !== null && (
|
||||
<span className="font-mono text-xs text-muted-foreground">rows {n(node.planRows)}</span>
|
||||
)}
|
||||
{node.actualTimeMs !== null && (
|
||||
<span className="font-mono text-xs">
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
actual {n(node.actualTimeMs)} ms · {n(node.actualRows)} rows × {n(node.loops)}
|
||||
</span>
|
||||
)}
|
||||
{width > 0 && (
|
||||
<span className="inline-block h-1.5 w-24 rounded bg-muted" aria-hidden>
|
||||
<span className="block h-1.5 rounded bg-primary" style={{ width: `${width}%` }} />
|
||||
<span className="inline-block h-1.5 w-24 rounded-full bg-muted" aria-hidden>
|
||||
{/* the bar's length is data, so it is the one inline style */}
|
||||
<span className="block h-1.5 rounded-full bg-primary" style={{ width: `${width}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<li className="my-1">
|
||||
<li className="my-1.5">
|
||||
<details open>
|
||||
<summary className="cursor-pointer">{summary}</summary>
|
||||
<summary className="cursor-pointer rounded-md px-1 py-0.5 marker:text-muted-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{summary}
|
||||
</summary>
|
||||
{extras.length > 0 && (
|
||||
<dl className="ml-4 grid grid-cols-[auto_1fr] gap-x-2 text-xs text-muted-foreground">
|
||||
<dl className="ml-5 mt-0.5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 text-xs text-muted-foreground">
|
||||
{extras.map(([k, v]) => (
|
||||
<React.Fragment key={k}>
|
||||
<dt>{k}</dt>
|
||||
<dd className="truncate font-mono">
|
||||
<dd className="break-words font-mono text-foreground">
|
||||
{typeof v === 'string' ? v : JSON.stringify(v)}
|
||||
</dd>
|
||||
</React.Fragment>
|
||||
@@ -97,7 +104,7 @@ function PlanNodeItem({
|
||||
</dl>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
<ul className="ml-4 border-l pl-3">
|
||||
<ul className="ml-3 border-l border-border pl-3">
|
||||
{node.children.map((child, i) => (
|
||||
<PlanNodeItem key={i} node={child} max={max} useActual={useActual} />
|
||||
))}
|
||||
@@ -119,7 +126,7 @@ export function PlanTree({ explain }: { explain: SqlExplainResponse }) {
|
||||
const [raw, setRaw] = useState(false)
|
||||
if (explain.status !== 'SUCCEEDED' || !explain.plan) {
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
<p className={ALERT_CLASS} role="alert">
|
||||
{explain.error ?? 'The plan could not be produced.'}
|
||||
</p>
|
||||
)
|
||||
@@ -128,21 +135,24 @@ export function PlanTree({ explain }: { explain: SqlExplainResponse }) {
|
||||
const max = planMax(explain.plan, useActual)
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{explain.analyze ? 'EXPLAIN ANALYZE (executed, rolled back)' : 'EXPLAIN (estimated)'}
|
||||
{explain.analyze
|
||||
? 'Actual plan: the statement ran and was rolled back'
|
||||
: 'Estimated plan'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto rounded border px-2 py-0.5"
|
||||
className={cn(buttonClass('secondary', 'sm'), 'ml-auto')}
|
||||
aria-pressed={raw}
|
||||
onClick={() => setRaw(!raw)}
|
||||
data-qqq-id="button-sql-plan-raw"
|
||||
>
|
||||
{raw ? 'Tree' : 'Raw'}
|
||||
Raw text
|
||||
</button>
|
||||
</div>
|
||||
{raw ? (
|
||||
<pre className="max-h-96 overflow-auto rounded bg-muted p-2 font-mono text-xs">
|
||||
<pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-3 font-mono text-xs text-foreground">
|
||||
{explain.raw}
|
||||
</pre>
|
||||
) : (
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
/**
|
||||
* @file QuickChart — a bar, line or pie chart of a result's current rows: pick the category
|
||||
* column and the value columns. Uses Recharts, as the dashboard chart widgets do.
|
||||
* column and the value columns. Uses Recharts, as the dashboard chart widgets do, with the
|
||||
* theme's chart palette and surfaces so it reads in light and dark mode.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -40,17 +41,27 @@ import {
|
||||
|
||||
import type { SqlColumn, SqlValue } from '@/types'
|
||||
import { numericColumnIndexes, toChartRows } from '@/lib/utils/sql-workbench'
|
||||
import { CHECKBOX_CLASS, SELECT_CLASS } from '@/components/ui/controls'
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#06b6d4',
|
||||
'#8b5cf6',
|
||||
'#84cc16',
|
||||
'#ec4899',
|
||||
]
|
||||
/**
|
||||
* Series colors: the theme's chart palette tokens, which the dark theme redefines, so the chart
|
||||
* follows light and dark mode (Recharts writes them as SVG presentation values).
|
||||
*/
|
||||
export const CHART_COLORS = Array.from({ length: 10 }, (_, i) => `var(--color-chart-${i + 1})`)
|
||||
|
||||
/** Axis ticks and legend text: the muted text color of the surrounding card. */
|
||||
const TICK = { fontSize: 12, fill: 'currentColor' }
|
||||
|
||||
/** Tooltip box on the popover surface. */
|
||||
const TOOLTIP_STYLE: React.CSSProperties = {
|
||||
backgroundColor: 'var(--color-popover)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '0.5rem',
|
||||
color: 'var(--color-popover-foreground)',
|
||||
fontSize: '12px',
|
||||
}
|
||||
const TOOLTIP_ITEM_STYLE: React.CSSProperties = { color: 'var(--color-popover-foreground)' }
|
||||
const TOOLTIP_CURSOR = { fill: 'var(--color-muted)', opacity: 0.6 }
|
||||
|
||||
/**
|
||||
* Renders the chart builder.
|
||||
@@ -82,11 +93,11 @@ export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlV
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
Chart
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-foreground">
|
||||
<label className="inline-flex items-center gap-2">
|
||||
Chart type
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
className={SELECT_CLASS}
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'bar' | 'line' | 'pie')}
|
||||
data-qqq-id="select-sql-chart-type"
|
||||
@@ -96,10 +107,10 @@ export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlV
|
||||
<option value="pie">Pie</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<label className="inline-flex min-w-0 items-center gap-2">
|
||||
Category
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
className={SELECT_CLASS}
|
||||
value={xIndex}
|
||||
onChange={(e) => setXIndex(Number(e.target.value))}
|
||||
data-qqq-id="select-sql-chart-x"
|
||||
@@ -111,12 +122,13 @@ export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlV
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="inline-flex flex-wrap items-center gap-2">
|
||||
<fieldset className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<legend className="sr-only">Values</legend>
|
||||
<span>Values</span>
|
||||
<span aria-hidden="true">Values</span>
|
||||
{numeric.map((i) => (
|
||||
<label key={i} className="inline-flex items-center gap-1">
|
||||
<label key={i} className="inline-flex cursor-pointer items-center gap-1.5">
|
||||
<input
|
||||
className={CHECKBOX_CLASS}
|
||||
type={type === 'pie' ? 'radio' : 'checkbox'}
|
||||
name="sql-chart-y"
|
||||
checked={yIndexes.includes(i)}
|
||||
@@ -137,47 +149,68 @@ export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlV
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
className="h-72 w-full"
|
||||
className="h-80 w-full text-muted-foreground"
|
||||
role="img"
|
||||
aria-label={`${type} chart of ${series.join(', ') || 'no values'} by ${columns[xIndex]?.label}`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{type === 'pie' ? (
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey={series[0] ?? ''} nameKey="x" outerRadius="80%" label>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={series[0] ?? ''}
|
||||
nameKey="x"
|
||||
outerRadius="75%"
|
||||
label={TICK}
|
||||
stroke="var(--color-card)"
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} itemStyle={TOOLTIP_ITEM_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
</PieChart>
|
||||
) : type === 'line' ? (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="x" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.15} />
|
||||
<XAxis dataKey="x" tick={TICK} stroke="currentColor" strokeOpacity={0.4} />
|
||||
<YAxis tick={TICK} stroke="currentColor" strokeOpacity={0.4} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
itemStyle={TOOLTIP_ITEM_STYLE}
|
||||
cursor={{ stroke: 'currentColor', strokeOpacity: 0.3 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s}
|
||||
type="monotone"
|
||||
dataKey={s}
|
||||
stroke={COLORS[i % COLORS.length]}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="x" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.15} />
|
||||
<XAxis dataKey="x" tick={TICK} stroke="currentColor" strokeOpacity={0.4} />
|
||||
<YAxis tick={TICK} stroke="currentColor" strokeOpacity={0.4} />
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
itemStyle={TOOLTIP_ITEM_STYLE}
|
||||
cursor={TOOLTIP_CURSOR}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
{series.map((s, i) => (
|
||||
<Bar key={s} dataKey={s} fill={COLORS[i % COLORS.length]} />
|
||||
<Bar
|
||||
key={s}
|
||||
dataKey={s}
|
||||
fill={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
|
||||
@@ -18,20 +18,49 @@
|
||||
* @file ResultGrid — typed, sortable, paginated view of a workbench result. The first page comes
|
||||
* with the run; later pages and sorts are read from the server's cached copy of the result
|
||||
* (`GET /executions/{uuid}/rows`), so SQL never re-runs. Copies the page as TSV and links the
|
||||
* CSV / XLSX / JSON exports of the whole (capped) result.
|
||||
* CSV / Excel / JSON exports of the whole (capped) result from an Export menu.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { SqlColumn, SqlValue } from '@/types'
|
||||
import type { SqlColumn, SqlColumnKind, SqlExportFormat, SqlValue } from '@/types'
|
||||
import { getSqlRows, sqlExportUrl } from '@/lib/api/sql-workbench'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { formatSqlValue, isBinaryValue, prettyJson, rowsToTsv } from '@/lib/utils/sql-workbench'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import {
|
||||
ALERT_CLASS,
|
||||
buttonClass,
|
||||
MENU_CONTENT_CLASS,
|
||||
MENU_ITEM_CLASS,
|
||||
} from '@/components/ui/controls'
|
||||
|
||||
/** Column kinds aligned right (as numbers are in the record grid). */
|
||||
const RIGHT_ALIGNED = new Set<SqlColumnKind>(['number'])
|
||||
|
||||
/** Column kinds kept on one line. */
|
||||
const NO_WRAP = new Set<SqlColumnKind>(['number', 'boolean', 'date', 'datetime', 'time'])
|
||||
|
||||
/** The export formats, with plain labels. */
|
||||
const EXPORT_FORMATS: { format: SqlExportFormat; label: string }[] = [
|
||||
{ format: 'csv', label: 'CSV' },
|
||||
{ format: 'xlsx', label: 'Excel (XLSX)' },
|
||||
{ format: 'json', label: 'JSON' },
|
||||
]
|
||||
|
||||
/** Props for {@link ResultGrid}. */
|
||||
export interface ResultGridProps {
|
||||
@@ -69,8 +98,10 @@ export function SqlValueCell({ value, column }: { value: SqlValue; column: SqlCo
|
||||
if (column.kind === 'json' && typeof value === 'string') {
|
||||
return (
|
||||
<details>
|
||||
<summary className="max-w-xs cursor-pointer truncate font-mono text-xs">{value}</summary>
|
||||
<pre className="mt-1 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
|
||||
<summary className="max-w-xs cursor-pointer truncate font-mono text-xs text-foreground">
|
||||
{value}
|
||||
</summary>
|
||||
<pre className="mt-1 max-w-md whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-2 font-mono text-xs text-foreground">
|
||||
{prettyJson(value)}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -78,7 +109,12 @@ export function SqlValueCell({ value, column }: { value: SqlValue; column: SqlCo
|
||||
}
|
||||
if (column.kind === 'boolean') return <span className="font-mono">{String(value)}</span>
|
||||
return (
|
||||
<span className={cn(column.kind === 'number' && 'font-mono tabular-nums')}>
|
||||
<span
|
||||
className={cn(
|
||||
(column.kind === 'number' || NO_WRAP.has(column.kind)) && 'tabular-nums',
|
||||
column.kind === 'number' && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{formatSqlValue(value)}
|
||||
</span>
|
||||
)
|
||||
@@ -149,27 +185,45 @@ export function ResultGrid(props: ResultGridProps) {
|
||||
const from = rowCount === 0 ? 0 : page * pageSize + 1
|
||||
const to = Math.min(rowCount, (page + 1) * pageSize)
|
||||
|
||||
// The record grid's look (DataGrid, standard density): muted header, bordered rows, row hover.
|
||||
// Only the grid scrolls sideways or down, inside its own box; the page never does.
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[28rem] overflow-auto rounded-md border">
|
||||
<div
|
||||
className="relative max-h-[32rem] w-full overflow-auto rounded-md border border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Query result rows"
|
||||
data-qqq-id="grid-sql-result"
|
||||
>
|
||||
{isFetching && (
|
||||
<div className="sticky left-0 top-0 z-[4] h-0.5 w-full overflow-hidden bg-primary/20">
|
||||
<div className="h-full w-1/3 animate-pulse bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
<table
|
||||
className="w-full border-collapse text-sm"
|
||||
aria-label="Query result"
|
||||
aria-busy={isFetching}
|
||||
>
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
<thead className="sticky top-0 z-[3]">
|
||||
<tr className="border-b border-border bg-muted">
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
scope="col"
|
||||
className="whitespace-nowrap border-b px-2 py-1 text-left font-medium"
|
||||
className={cn(
|
||||
'whitespace-nowrap bg-muted px-4 py-2 text-left align-top font-semibold text-foreground',
|
||||
column.kind === 'number' && 'text-right'
|
||||
)}
|
||||
aria-sort={
|
||||
sort?.column === index
|
||||
? sort.dir === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
staticRows
|
||||
? undefined
|
||||
: sort?.column === index
|
||||
? sort.dir === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
>
|
||||
{staticRows ? (
|
||||
@@ -177,17 +231,17 @@ export function ResultGrid(props: ResultGridProps) {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
className="inline-flex items-center gap-1 rounded hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => toggleSort(index)}
|
||||
title={`${column.typeName} — sort`}
|
||||
title={`${column.typeName}: select to sort`}
|
||||
data-qqq-id={`button-sql-sort-${index}`}
|
||||
>
|
||||
{column.label}
|
||||
{sort?.column === index &&
|
||||
(sort.dir === 'asc' ? (
|
||||
<span aria-hidden>▲</span>
|
||||
<ArrowUp className="h-3 w-3 text-primary" aria-hidden="true" />
|
||||
) : (
|
||||
<span aria-hidden>▼</span>
|
||||
<ArrowDown className="h-3 w-3 text-primary" aria-hidden="true" />
|
||||
))}
|
||||
</button>
|
||||
)}
|
||||
@@ -197,13 +251,17 @@ export function ResultGrid(props: ResultGridProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, r) => (
|
||||
<tr key={r} className="odd:bg-background even:bg-muted/30">
|
||||
<tr
|
||||
key={r}
|
||||
className="border-b border-border transition-colors last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
{columns.map((column, c) => (
|
||||
<td
|
||||
key={c}
|
||||
className={cn(
|
||||
'border-b px-2 py-1 align-top',
|
||||
column.kind === 'number' && 'text-right'
|
||||
'px-4 py-2 align-top text-foreground',
|
||||
RIGHT_ALIGNED.has(column.kind) && 'text-right',
|
||||
NO_WRAP.has(column.kind) && 'whitespace-nowrap'
|
||||
)}
|
||||
>
|
||||
<SqlValueCell value={row[c] ?? null} column={column} />
|
||||
@@ -215,65 +273,93 @@ export function ResultGrid(props: ResultGridProps) {
|
||||
</table>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
<p className={ALERT_CLASS} role="alert">
|
||||
Could not load rows: {error instanceof Error ? error.message : 'error'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Rows {from}–{to} of {rowCount}
|
||||
Rows {from.toLocaleString()}–{to.toLocaleString()} of {rowCount.toLocaleString()}
|
||||
{truncated && (
|
||||
<span className="ml-1 text-amber-600">
|
||||
<span className="ml-1 font-medium text-amber-800 dark:text-amber-300">
|
||||
(capped by the {truncatedReason === 'BYTES' ? 'size' : 'row'} limit)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!staticRows && pages > 1 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-0.5 disabled:opacity-50"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
data-qqq-id="button-sql-page-prev"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
<span className="whitespace-nowrap">
|
||||
Page {page + 1} of {pages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-0.5 disabled:opacity-50"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
disabled={page + 1 >= pages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
data-qqq-id="button-sql-page-next"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-2 @lg:ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
onClick={copy}
|
||||
data-qqq-id="button-sql-copy"
|
||||
>
|
||||
<ClipboardCopy className="h-4 w-4" aria-hidden="true" />
|
||||
Copy page
|
||||
</button>
|
||||
{!staticRows &&
|
||||
(['csv', 'xlsx', 'json'] as const).map((format) => (
|
||||
<a
|
||||
key={format}
|
||||
href={sqlExportUrl(executionUuid, format)}
|
||||
download
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
|
||||
data-qqq-id={`link-sql-export-${format}`}
|
||||
>
|
||||
{format.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
{!staticRows && (
|
||||
<DropdownMenu.Root modal={false}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass('secondary', 'sm')}
|
||||
data-qqq-id="button-sql-export"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
Export
|
||||
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className={MENU_CONTENT_CLASS}
|
||||
aria-label="Export formats"
|
||||
>
|
||||
{EXPORT_FORMATS.map(({ format, label }) => (
|
||||
<DropdownMenu.Item key={format} asChild>
|
||||
<a
|
||||
href={sqlExportUrl(executionUuid, format)}
|
||||
download
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-qqq-id={`link-sql-export-${format}`}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user