Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77eef1c816
|
||
|
|
61be012fc2
|
||
|
|
bc01d10a27 | ||
|
|
d3daa4d067
|
||
|
|
49788dd4e8
|
||
|
|
1412af5f69 | ||
|
|
9150fb732f
|
||
|
|
7f03ba1f4c
|
||
|
|
68c2567f02
|
||
|
|
984c26b62b
|
||
|
|
310c23f9cd
|
||
|
|
de387a61f7
|
||
|
|
e333223470 | ||
|
|
c064048c6f
|
||
|
|
2b4d82309c
|
||
|
|
4fc3dfd7aa
|
||
|
|
8c9619e7f7
|
||
|
|
d3298cca2b
|
||
|
|
edaa1ffb33
|
||
|
|
c564a29ce8
|
||
|
|
3ab0ed88fa
|
||
|
|
12ef8571f1
|
||
|
|
a4c8eaf8c9
|
||
|
|
26ab79f752
|
||
|
|
b0b0d469fe | ||
|
|
3ceed91214
|
||
|
|
4e12a5d524
|
||
|
|
18d1fd6e37 | ||
|
|
eee644a280 | ||
|
|
7682aa1ff0 | ||
|
|
8e334cd944 | ||
|
|
255554a184 | ||
|
|
e1d60b9634 | ||
|
|
d81b7ed5ae | ||
|
|
b8313b6819 | ||
|
|
22c9e02f20 | ||
|
|
138c3c6db5 | ||
|
|
70f26e9c9a | ||
|
|
2bc75d779e | ||
|
|
17e22edaee | ||
|
|
66d02c7b6a | ||
|
|
20fe7b7b9b | ||
|
|
4190ee01ba | ||
|
|
62d1e77847 | ||
|
|
a23fd997db | ||
|
|
f367756b8c | ||
|
|
4a055463bd | ||
|
|
c1a4efd7d7 | ||
|
|
0222f0dcad | ||
|
|
4fea636f24 | ||
|
|
5ca3e0b17a | ||
|
|
e0fb2598b0 | ||
|
|
61286717e5 | ||
|
|
a32ef74a9d | ||
|
|
d292c76530 | ||
|
|
e3fc4b2f52 | ||
|
|
b5f8be7645 | ||
|
|
8f552e2e29 | ||
|
|
1f18575abe | ||
|
|
6a5c1bb237 | ||
|
|
58a896345f | ||
|
|
bd904fe4ac | ||
|
|
09c271384c | ||
|
|
05763450ff | ||
|
|
aa0129f024 | ||
|
|
2bdc048440 | ||
|
|
7bd157dd81 | ||
|
|
2535e98c7e | ||
|
|
2853b9b148 | ||
|
|
29cb5584fc | ||
|
|
f36d8709c9 | ||
|
|
f7b2aa52a8 | ||
|
|
a1d3e48419 | ||
|
|
ea73118fca | ||
|
|
c629eb0b6c | ||
|
|
571ee7bc88 | ||
|
|
12cf7745f6 | ||
|
|
0f6581b6d7 | ||
|
|
46a1d5d7ec | ||
|
|
226efa841f | ||
|
|
dd130238be | ||
|
|
d8c803ef5f | ||
|
|
5e3738b73f | ||
|
|
289eca19d4 | ||
|
|
7ff90f1a63 | ||
|
|
50a935a4a5 | ||
|
|
40e987d4d0 | ||
|
|
71f5a39b09 | ||
|
|
45f99f27ce | ||
|
|
d17e880e79 | ||
|
|
f8fdb8e997 | ||
|
|
ad154b5b56 | ||
|
|
c6a58933a6 | ||
|
|
26e3a0d004 | ||
|
|
c9d7fcd393 | ||
|
|
b3e1ddda8b | ||
|
|
8d4668cdab | ||
|
|
254a0a2050 | ||
|
|
52cc038fa6 | ||
|
|
6090f2f9b4 | ||
|
|
32a9560970 | ||
|
|
96085c72e2 | ||
|
|
c029ad2a70 | ||
|
|
32a3b281ff | ||
|
|
a0a18cfa76 |
No files matched your search
@@ -72,6 +72,11 @@ jobs:
|
||||
- name: Acceptance (${{ matrix.browser }})
|
||||
env:
|
||||
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
|
||||
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
|
||||
# Fixed NAV023/NAV056 ownership capture only; original assertions and diagnostics remain strict.
|
||||
QQQ_ACCEPTANCE_BROWSER_TRACE: ${{ (matrix.browser == 'webkit' || matrix.browser == 'mobile,tablet') && 'navigation' || '0' }}
|
||||
# Retain native browser crashes alongside traces (QQQ#904, QQQ#960).
|
||||
DEBUG: pw:browser
|
||||
run: node scripts/acceptance.mjs --skip-build
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
|
||||
@@ -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.
|
||||
|
||||
[Next UI 1.0.0-RC.8 is published](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.8) for application testing. Installation requirements, verified artifacts and known gaps are in the [RC8 notes](docs/releases/1.0.0-RC.8.md). Final 1.0 remains held for owner application testing and explicit approval.
|
||||
**This checkout targets Next UI 1.0.0-RC.12**, with the always-available record Actions menu, private query-state clearing between users, standalone branding and CSP, grid focus and other fixes. Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing; see the [RC12 notes](docs/releases/1.0.0-RC.12.md) for the preparation checkpoint and verification scope. [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
**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,11 +14,11 @@ 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.8</version>
|
||||
<version>1.0.0-RC.12</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
The explicit version overrides an older BOM-managed frontend. RC8 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. RC12 requires a published QQQ 4.1 candidate: 4.1.0-RC.1 or later, and 4.1.0-RC.2 or later for standalone branding images (QQQ #1009); see the release notes for the verified backend. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
|
||||
|
||||
The 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>`.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ COPY --from=frontend --chown=node:node /app/.next/static ./.next/static
|
||||
COPY --from=frontend --chown=node:node /app/public ./public
|
||||
# server.js wrapped with the dashboard security headers (QRun-IO/qqq#734); add origins the
|
||||
# dashboard must reach (QuickSight, custom component bundles) with QQQ_DASHBOARD_CSP_SOURCES
|
||||
COPY --from=frontend --chown=node:node /app/standalone/qqq-server.mjs /app/standalone/security-headers.mjs /app/standalone/backend-prefixes.json ./
|
||||
COPY --from=frontend --chown=node:node /app/standalone/qqq-server.mjs /app/standalone/security-headers.mjs /app/standalone/branding-assets.mjs /app/standalone/backend-prefixes.json ./
|
||||
COPY LICENSE ./LICENSE
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -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.
|
||||
|
||||
+31
-3
@@ -10,12 +10,36 @@ GitHub issues are the source of truth for scope, current status, remaining work
|
||||
| Feature acceptance and default adoption | [qqq#649](https://github.com/QRun-IO/qqq/issues/649) |
|
||||
| Behavior and metadata compatibility | [qqq#714](https://github.com/QRun-IO/qqq/issues/714) |
|
||||
| Original Next visual review and owner approval | [qqq#711](https://github.com/QRun-IO/qqq/issues/711) |
|
||||
| Browser failures and incomplete run evidence | [frontend#12](https://github.com/QRun-IO/qqq-frontend-next/issues/12) |
|
||||
| Browser failures and incomplete run evidence | [qqq#985](https://github.com/QRun-IO/qqq/issues/985) |
|
||||
| Real application soak and final release hold | [qqq#712](https://github.com/QRun-IO/qqq/issues/712) |
|
||||
| Cross-application launch/restart acceptance | [qqq#650](https://github.com/QRun-IO/qqq/issues/650) |
|
||||
| ESB integration and acceptance | [qqq#986](https://github.com/QRun-IO/qqq/issues/986) |
|
||||
| Paired runtime provenance and CI handoff | [qqq#905](https://github.com/QRun-IO/qqq/issues/905) |
|
||||
|
||||
Use existing issues for the same requirement or failure. Create focused Next release issues in QRun-IO/qqq and link them to #713 and their feature parent. Keep existing frontend-repository issues linked; do not duplicate their scope.
|
||||
Create **all Next UI tickets in [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues)** and add them to the [QQQ Roadmap, Project 12](https://github.com/orgs/QRun-IO/projects/12). Use existing issues for the same requirement or failure, and link each focused ticket to #713 and its feature or investigation parent. Frontend issues #12 and #10 were transferred to central issues #985 and #986; use the central identifiers and preserve their report history.
|
||||
|
||||
## Required ticket fields
|
||||
|
||||
Every new ticket needs these fields on the issue and its Project 12 item; complete them before work advances to **Ready**:
|
||||
|
||||
| Field | Required value |
|
||||
|---|---|
|
||||
| Issue Type | Native issue: Bug, Feature, Task or Epic. Mirror it in the Project field, using Planning Epic for Epic; Improvement is a Project-only category. |
|
||||
| Assignees | The person accountable for completing or coordinating the ticket |
|
||||
| Milestone | The agreed delivery milestone; keep it consistent with the target version |
|
||||
| Priority | Critical, High, Medium or Low, with an impact-based rationale in the issue |
|
||||
| Component | The owning implementation component: `qqq-frontend-next` for Next source, or the actual backend/sample component for a dependency. |
|
||||
| Target Version | The agreed version of the owning component or an explicit pending release decision. A backend dependency in the Next UI 1.0 milestone does not imply a backend 1.0 release. Never imply delivery in an already published RC. |
|
||||
| Status | The current workflow state below |
|
||||
|
||||
Record unresolved ownership or release decisions explicitly while a ticket remains in **Backlog**. Do not invent dates, estimates or a new release version to fill a field. A browser assertion failure alone does not establish high product severity.
|
||||
|
||||
## Workflow and evidence
|
||||
|
||||
- **Backlog / Ready:** capture the expected behavior, scope, acceptance criteria, required fields and dependencies; Ready means actionable work with an owner.
|
||||
- **In progress:** link the active branch or work and record reproduction, confirmed cause, implementation progress and remaining blockers.
|
||||
- **In review:** link the reviewable PR, source commit and verification evidence; keep unmet acceptance criteria visible. A local fix or passing subset does not imply merge or release.
|
||||
- **Done:** close only after the accepted fix is integrated and every acceptance criterion is verified, or an explicit approved disposition is recorded. Link the merged PR/commit and evidence. Identify whether the change is unreleased or delivered, with the version and release link when published.
|
||||
|
||||
## Updating an issue
|
||||
|
||||
@@ -30,4 +54,8 @@ Use existing issues for the same requirement or failure. Create focused Next rel
|
||||
|
||||
Preserve the original Next design at `e42ad2b2bcdc76311e13002a22dd70e3ec437192`. Material Dashboard is the behavior and metadata compatibility reference. Prioritize primary-browser functional depth and disclose secondary-browser failures.
|
||||
|
||||
RC8 is a prerelease for application testing. Final 1.0 remains held for the owner's requested soak and explicit final approval. This project does not authorize releasing QQQ 4.1.
|
||||
[RC9](releases/1.0.0-RC.9.md) is the published prerelease for application testing. Its immutable-tag acceptance run has known Firefox/tablet failures; consult the release notes and #985 for the exact evidence. The browser fixes in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) are [unreleased](releases/unreleased.md).
|
||||
|
||||
Every release and changelog entry must link its central QQQ issues and distinguish product changes, test/infrastructure corrections, verification and unresolved work. A published RC's tag and artifacts remain immutable; subsequent fixes require a separately authorized version and release. Update tickets and Project 12 with the actual delivery version and release link after publication.
|
||||
|
||||
Final 1.0 remains held for the owner's requested application soak and renewed, explicit final approval ([#712](https://github.com/QRun-IO/qqq/issues/712)). Passing CI, an issue marked Done or elapsed time does not release that hold. This project does not authorize releasing QQQ 4.1.
|
||||
@@ -71,11 +71,11 @@ QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet \
|
||||
QQQ_ACCEPTANCE_BROWSERS=mobile,tablet node scripts/acceptance.mjs --skip-build
|
||||
```
|
||||
|
||||
| Project | Playwright device | Engine (Playwright 1.58.2) | Runs |
|
||||
| Project | Playwright device | Engine (Playwright 1.64.0-alpha-2026-10-01) | Runs |
|
||||
|----------|-------------------|----------------------------|------|
|
||||
| chromium | Desktop Chrome | Chrome for Testing 145 | every spec |
|
||||
| firefox | Desktop Firefox | Firefox 146 | every spec |
|
||||
| webkit | Desktop Safari | WebKit 26.0 | every spec |
|
||||
| chromium | Desktop Chrome | Chrome for Testing 155 | every spec |
|
||||
| firefox | Desktop Firefox | Firefox 156 | every spec |
|
||||
| webkit | Desktop Safari | WebKit 26.6 | every spec |
|
||||
| mobile | Pixel 7 (412 × 839, touch) | Chromium | tests tagged `@mobile` |
|
||||
| tablet | iPad (gen 7) (810 × 1080, touch) | WebKit | tests tagged `@mobile` or `@tablet` |
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# ESB integration acceptance
|
||||
|
||||
The ten required [ESB matrix scenarios](../../tests/acceptance/matrix/esb.json) pass in Chromium, Firefox and WebKit: 30 cases with no failures, skips or retries. They exercise the stock sample’s embedded Artemis broker, table-event delivery into `syncPerson`, service/table/process views, permission denial, subscription browsing, trigger pause/resume/restart and confirmed dead-letter replay. A further 38 Chromium navigation, permission and table Developer-view cases, plus 12 security-variant and search cases, pass without skips or retries. The main and security fixtures use separate broker ports. These are focused ESB results; general Next browser/parity and final release acceptance remain tracked separately.
|
||||
|
||||
The backend integration fixes are [QQQ PR #980](https://github.com/QRun-IO/qqq/pull/980), based on `5bf02d54`: normal sample ESB registration, API documentation route isolation and nullable subscription/dead-letter pause state. Frontend verification passes 105 focused ESB tests, all 1,951 unit tests with coverage thresholds, type checking, the production static export and bundle budgets. The [acceptance README](../../tests/acceptance/README.md) gives the commands and fixture boundaries; [Next #10](https://github.com/QRun-IO/qqq-frontend-next/issues/10) and [QQQ #739](https://github.com/QRun-IO/qqq/issues/739) track delivery. RabbitMQ and broker management behavior are covered by the backend conformance suite; these browser tests use Artemis without an HTTP management endpoint, so unavailable broker actions remain hidden.
|
||||
@@ -12,6 +12,8 @@ Recorded runs: `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-result
|
||||
|
||||
Corrected RC8 source cd3d473: 602 local and 602 hosted Chromium cases passed, with zero failed/skipped/flaky cases or report errors. Hosted run 36798799502. Three approved external-service exclusions remain. Earlier failed runs are retained and described in docs/releases/1.0.0-RC.8.md; this matrix records the corrected primary runs only. Other browser profiles and whole-product compatibility/visual approval remain separate.
|
||||
|
||||
The additional ESB integration scenarios and their focused three-browser results are recorded in [ESB acceptance](esb.md). The historical full-suite counts above predate those ten new matrix rows.
|
||||
|
||||
## Navigation, application structure, branding and icons
|
||||
|
||||
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Next UI agent guide
|
||||
|
||||
> **This checkout targets RC12.** Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. [RC12 notes](../releases/1.0.0-RC.12.md) record the preparation checkpoint and verification scope; RC11 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
|
||||
> **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 latest published prerelease is [1.0.0-RC.8](../releases/1.0.0-RC.8.md) at `cd3d473`, 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 RC12 preparation checkpoint was [1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) at `2853b9b`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
|
||||
|
||||
## Operating rule: metadata is the product boundary
|
||||
|
||||
@@ -223,7 +225,7 @@ Google Drive picker changes span the frontend component/SDK loader and Javalin's
|
||||
and v1 public-environment allow-list (#704; backend PR #908 merged to develop as `bac663044`). The frontend needs those backend changes to receive its public configuration and load under the configured CSP. PRC-065 tests controlled SDK responses with
|
||||
real process execution and SQL; PRC-039 is the approved real-account exclusion. Preserve
|
||||
the shared-drive view, folder-only validation, cancellation and token expiry handling.
|
||||
Never log or persist the access token, and never publish OAuth client secrets in metadata. Javalin conditional CSP covers its static export only; standalone Node/container deployments also require runtime `QQQ_DASHBOARD_CSP_SOURCES` using the [documented Google directives](./next-ui-developer-guide.md#google-drive-process-configuration). Check the actual dashboard HTML response from the selected host. RC1 remains immutable and retains its historical placeholder; these changes belong to the ongoing final 1.0 work.
|
||||
Never log or persist the access token, and never publish OAuth client secrets in metadata. Javalin applies conditional CSP to its static export; standalone Node/container deployments derive the same sources from backend `dashboardCspSources` authentication metadata. Older backends that omit that optional field require runtime `QQQ_DASHBOARD_CSP_SOURCES` using the [documented Google directives](./next-ui-developer-guide.md#google-drive-process-configuration). Check the actual dashboard HTML response from the selected host. RC1 remains immutable and retains its historical placeholder; these changes belong to the ongoing final 1.0 work.
|
||||
|
||||
### Script editor suggestions
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# Next UI developer guide
|
||||
|
||||
> **This checkout targets RC12.** Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. [RC12 notes](../releases/1.0.0-RC.12.md) record the preparation checkpoint and verification scope; RC11 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
|
||||
> **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 latest published prerelease is [1.0.0-RC.8](../releases/1.0.0-RC.8.md) (commit `cd3d473`); 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 RC12 preparation checkpoint was [1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) (commit `2853b9b`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
|
||||
|
||||
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.
|
||||
|
||||
@@ -31,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.5</version>
|
||||
<version>1.0.0-RC.12</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@@ -44,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). Use version `1.0.0-RC.8` for the published prerelease, overriding an older BOM-managed frontend. 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.12`, overriding an older BOM-managed frontend; confirm its availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. It requires a published QQQ 4.1 candidate (4.1.0-RC.1 or later; 4.1.0-RC.2 or later for standalone branding images, QQQ #1009); see the release notes for the verified backend. Use a matching local backend/frontend build to test later branch changes.
|
||||
|
||||
| Setting | Behavior in this checkout |
|
||||
|---|---|
|
||||
@@ -455,8 +459,9 @@ Client secrets are not browser configuration and are not published in v1 metadat
|
||||
The Next UI middleware must include the #704 changes (backend PR #908, merged to develop as `bac663044`) that publish these two settings
|
||||
and permit Google's SDK and picker origins in CSP only when both are configured.
|
||||
Those conditional headers apply to the Javalin-hosted static export. The standalone
|
||||
Node/container host must receive `QQQ_DASHBOARD_CSP_SOURCES` at runtime; it does not derive
|
||||
Google allowances from instance metadata. Merge these sources with any other deployment
|
||||
Node/container host also derives them when the backend publishes `dashboardCspSources`
|
||||
in authentication metadata. With an older backend that omits that optional field, supply
|
||||
`QQQ_DASHBOARD_CSP_SOURCES` at runtime. Merge these sources with any other deployment
|
||||
additions and start through [`standalone/qqq-server.mjs`](../../standalone/qqq-server.mjs):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,23 +10,16 @@ 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.
|
||||
|
||||
Latest published candidate: **[1.0.0-RC.7](1.0.0-RC.7.md)**. Its notes record installation, backend requirements, exact artifact verification and known failures. RC1 through RC6 remain available and unchanged.
|
||||
Previously verified public candidate at the RC12 preparation checkpoint: **[1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC10 remain available and unchanged.
|
||||
|
||||
## Fixes during testing
|
||||
|
||||
Development and verification continue on `feature/next-1.0`, tracked in [PR #14](https://github.com/QRun-IO/qqq-frontend-next/pull/14). The branch can include changes beyond the latest published RC. Check the version of each preview separately; a local preview may intentionally preserve an earlier design checkpoint. Branch changes are **not retroactive changes to published RC artifacts**.
|
||||
This checkout targets [RC12](1.0.0-RC.12.md); confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. The candidate adds the always-available record Actions menu ([#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)), private query-state clearing between users (QQQ #1006), standalone branding and CSP derivation (QQQ #1009, #1017), grid and report focus fixes, and the other changes listed in the RC12 notes. These changes are absent from immutable RC11. Candidate notes distinguish versioned local checks from public delivery and retain the historical browser failures and remaining RC limits. Local previews may intentionally preserve an earlier version.
|
||||
|
||||
The branch includes subsequent report editor fixes, script completions, and corrections found in the original Next UI visual review. The latest visual corrections restore Copy form alignment and selected-section defaults, and expose the full table checkbox target on touch devices. Verification details are in the [visual review](../acceptance/next-ui-visual-review.md). The review remains open for other screen families.
|
||||
|
||||
For the complete change history, compare the immutable RC1 tag with the branch:
|
||||
|
||||
```sh
|
||||
git fetch origin tag v1.0.0-RC.1
|
||||
git log --oneline v1.0.0-RC.1..feature/next-1.0
|
||||
```
|
||||
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
|
||||
|
||||
## Reporting application issues
|
||||
|
||||
Include the exact UI artifact/version or branch commit, QQQ backend version, page/workflow, expected and actual results, reproduction steps, and relevant screenshots or sanitized errors. This distinguishes an RC1 issue already fixed on the branch from a new regression. Never include credentials or sensitive application records.
|
||||
Create or update the central QQQ ticket and Project 12 item using the [tracking policy](../ISSUE-TRACKING.md). Include the exact UI artifact/version or branch commit, QQQ backend version, page/workflow, expected and actual results, reproduction steps, and relevant screenshots or sanitized errors. This distinguishes an issue in a published RC from a later branch regression. Never include credentials or sensitive application records.
|
||||
|
||||
The compatibility ledger and remaining acceptance work still govern final readiness. The testing hold does not waive those requirements or authorize a QQQ 4.1 release.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Next UI 1.0.0-RC.10 — preparation
|
||||
|
||||
RC10 is being prepared for application testing and **is not yet published**. [RC9](1.0.0-RC.9.md) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.10` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.10`. Public checksums, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Changes from RC9
|
||||
|
||||
- Fix section-hash loss and stale immediate URLs when changing record tabs or views (#989), preserving URL context, return links, focus and history. The baseline eventually restored the tab; this is not a claim of proven tab loss.
|
||||
- Preserve search-dialog keyboard focus (#950), WebKit possible-value selections (#978), and authentication loading through logout/provider departure (#984).
|
||||
- Add ESB developer views and overview/management controls, with real-backend acceptance. See the [ESB contract](../acceptance/esb.md).
|
||||
- Improve browser readiness and diagnostic checks while preserving assertions. The pinned Playwright runner contains the corrected Linux WebKit library; stable-runner migration remains #973. See [change details](unreleased.md).
|
||||
|
||||
## Verification
|
||||
|
||||
Verification uses source `5e3738b73f14d7ed25580560f3c97b91aed58fce` ([PR21](https://github.com/QRun-IO/qqq-frontend-next/pull/21)) with RC10 metadata and QQQ backend `55b33e6bd7f8cc2aef3b41546ef8409ad307ff05`.
|
||||
|
||||
- **1,979 unit tests / 184 files**, typecheck, lint/license (zero errors, five existing warnings), static export, Maven packaging and bundle budgets pass.
|
||||
- The actual RC10 JAR passes **85/85** auth/focus/selection/ESB real-backend cases: Chromium, Firefox and WebKit 21 each; mobile and tablet 11 each.
|
||||
- Four navigation regressions pass **8/8 in Firefox/WebKit**, using the same JAR's assets with mocked APIs. These are separate from real-backend API coverage.
|
||||
|
||||
Both browser reports have zero failures, skips, flaky outcomes or report errors, with retries disabled. All 192 dashboard files are packaged without a mock service worker; Maven and compiled frontend versions are RC10. Served JavaScript and font bytes match the JAR. The primary fixture serves the JAR directly; authentication variants use verified assets extracted from it. These are local candidate checks, not public delivery receipts. PR23 changes test selection and documentation only; production code and test assertions are unchanged from this verified candidate.
|
||||
|
||||
## Browser coverage and remaining RC limits
|
||||
|
||||
The historical [PR18 run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) failed its touch-coverage gate despite all 2,972 executed cases passing: ESB-001–010 were required but not selected on phone and tablet. The original failed result is retained under [#990](https://github.com/QRun-IO/qqq/issues/990).
|
||||
|
||||
[PR23](https://github.com/QRun-IO/qqq-frontend-next/pull/23) corrects selection by adding touch markers to the ten existing ESB tests, preserving production code and every assertion. Its separate focused real-broker run on the PR18 export and QQQ `55b33e6` passed all 20 phone/tablet cases with zero retries, failures, skips, flaky outcomes or report errors. This focused result is not a fresh full hosted matrix pass; full hosted verification remains pending at this preparation checkpoint.
|
||||
|
||||
The secondary-browser/tablet RC exception remains documented in [#985](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5943724086) and [#798](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5943457468). No gate, test requirement or exclusion was weakened. This exception does not waive product defects or authorize GA.
|
||||
|
||||
Remaining parity, real Auth0/Drive/QuickSight checks, browser investigations, visual review and application soak stay tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#985](https://github.com/QRun-IO/qqq/issues/985), [#711](https://github.com/QRun-IO/qqq/issues/711) and [#712](https://github.com/QRun-IO/qqq/issues/712). The container backend origin is fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin. This candidate does not itself publish QQQ 4.1.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Next UI 1.0.0-RC.11 — preparation
|
||||
|
||||
At this preparation checkpoint, RC11 is being prepared for application testing and **is not yet published**. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.11` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.11`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Changes from RC10
|
||||
|
||||
[PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24), merged source `a1d3e48419812fcb8d47091cb4af315cad4e76e9` (tree-identical to reviewed head `ea73118fcabdf3c08ea8614c8bd0ad63749fb598`), invalidates stale searchable-selector results immediately when input changes (#970), and keeps script-completion suggestions open when a delayed scroll event reports an unchanged position (#954). Actual position changes still dismiss suggestions. No acceptance assertion or gate is weakened.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR24's [hosted primary acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36954433088) passes on its exact source: 614 cases and 401 required rows. The actual merged source above has the same tree. With RC11 metadata, fresh verification passes **1,984 unit tests / 184 files**, typecheck, lint/license (zero errors, five existing warnings), static export, Maven packaging and bundle budgets. All **192 dashboard files** match the export byte-for-byte, with no mock service worker and matching RC11 frontend/Maven versions.
|
||||
|
||||
The actual RC11 JAR passes **125/125 real-backend cases** against QQQ `55b33e6bd7f8cc2aef3b41546ef8409ad307ff05`: 25 each in Chromium, Firefox, WebKit, phone and tablet. These include the 105 auth/focus/selection/ESB cases and 20 selector/completion regression cases. A separate **8/8 record-navigation regressions in Firefox/WebKit** use the same JAR assets with mocked APIs. Both reports have zero failures, skips, flaky outcomes or report errors, with retries disabled. Served versioned JavaScript and font bytes match the JAR. The scoped matrix check passes only the exercised rows; it is not a full hosted matrix result. These local candidate checks are separate from eventual public-artifact verification; no RC10 test count or artifact digest is attributed to RC11.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
Historical observations remain preserved: REC-054 on mobile light expected three completion options but found none; PRC-061 in WebKit left the Casey selection at its placeholder. See [#954](https://github.com/QRun-IO/qqq/issues/954), [#970](https://github.com/QRun-IO/qqq/issues/970) and [RC10 delivery notes](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10). The original [PR18 touch-coverage failure](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) remains historical evidence. [#990 is complete](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159): PR23’s complete touch report covers all 401 required IDs on each profile, with zero missing, and all ten ESB scenarios pass first attempt on both phone and tablet. That report records **1,149 passes and one unrelated tablet NAV-056 failure**; its full touch gate and whole workflow remain **failed**. [NAV-056 diagnostics remain under investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383), provisionally tracked in [#904](https://github.com/QRun-IO/qqq/issues/904#issuecomment-5944558037), without claiming a shared historical cause. Scoped #990 completion does not imply an all-browser pass or waive that failure.
|
||||
|
||||
The documented secondary-browser/tablet RC exception remains under [#985](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5943724086) and [#798](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5943457468); it does not waive new product defects or authorize GA. Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711) and [#712](https://github.com/QRun-IO/qqq/issues/712). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin. This candidate does not publish QQQ 4.1.
|
||||
@@ -0,0 +1,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,41 @@
|
||||
# Next UI 1.0.0-RC.9
|
||||
|
||||
Release candidate for application testing, October 1, 2026. The [GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) records public artifact availability and final delivery verification; a source tag alone is not delivery confirmation. Final 1.0 remains held for owner application testing and explicit approval.
|
||||
|
||||
## Changes from RC8
|
||||
|
||||
RC9 integrates the GitHub tracking policy, current roadmap links, and updated developer/agent documentation. Application code and dependencies are unchanged from RC8. This candidate does not claim fixes for the open functional or visual findings.
|
||||
|
||||
[QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713) is the source of truth for current status and remaining work. Original issue reports and historical acceptance evidence remain available. See the [tracking policy](../ISSUE-TRACKING.md).
|
||||
|
||||
## Verification baseline and known issues
|
||||
|
||||
The documentation source `354ee2709d8ee553de5e3ad823ce9d1190887d4c`, merged through PR #16, passed general CI (typecheck, lint, unit coverage, mocked Chromium E2E, Storybook and bundle budgets) and both container architecture builds. Real-backend [run 36861609503](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36861609503) passed all 602 Chromium cases and all 391 required rows, with three approved real-provider exclusions.
|
||||
|
||||
Its secondary reports remain failed: Firefox 599 passed / 3 failed (NAV-014, NAV-016 and REC-056 legacy-font diagnostics, [#951](https://github.com/QRun-IO/qqq/issues/951)), WebKit 601 / 1 (WID-073 metadata access-control diagnostic, [#961](https://github.com/QRun-IO/qqq/issues/961)), and combined touch 1,124 / 2 (tablet NAV-025 and REL-005). Reports contain zero skips/flaky outcomes/report errors. These are pre-RC9-version source results; final tag results and public artifact receipts are recorded separately below. No all-browser pass is claimed.
|
||||
|
||||
Current open work includes embedded report/query equivalence, remaining input configurations, subpath hosting, real Auth0/Google Drive/QuickSight checks, browser investigations, original-Next visual approval, real-application soak and final release approval. See [#713](https://github.com/QRun-IO/qqq/issues/713) and [browser evidence](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual issues. Material remains the behavior reference; the original Next design remains the visual reference.
|
||||
|
||||
## Versioned local verification
|
||||
|
||||
RC9 passes all 1,919 unit tests across 181 files, typechecking, lint/license (zero errors, five existing warnings), production export, Maven packaging and unchanged bundle budgets. All 193 packaged dashboard files are present; the Maven version is RC9 and no mock service worker is included. The actual locally packaged RC9 JAR passes 28 affected desktop/mobile browser cases with zero failures/skips/flaky outcomes/report errors. These checks cover API controls/theme/search focus, exact numeric persistence and composite field submission against backend e0d57dd31. They do not replace public artifact verification or certify other profiles.
|
||||
|
||||
## Installation
|
||||
|
||||
Maven coordinate: `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.9`.
|
||||
|
||||
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.9` (AMD64/ARM64 verified). The container backend origin is fixed at build time; the published image targets `host.docker.internal:8000`. Use the JAR or rebuild for another origin.
|
||||
|
||||
As with RC8, verification uses the QQQ 4.1 development backend. The locally verified backend source is `e0d57dd31b979b17215370546ef4e26cd8a38d21`; hosted workflows resolve their configured QQQ ref separately. This release does not publish QQQ 4.1 or certify every real consumer/backend combination.
|
||||
|
||||
## Public delivery — October 1, 2026
|
||||
|
||||
[RC9](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) was published at 19:25:28 UTC as a prerelease, latest=false. PR16 and PR14 are merged. The signed immutable tag points to `c029ad2a70948aac6469d8a71ccefc80c6e00d74`.
|
||||
|
||||
- Public JAR: 1,864,121 bytes; SHA256 `969034566c6b30a5034680dcf2d46aec327240db6cdd3fb7b31f1fae7cb9f17d`; SHA1 `3943c8641c14554999206cf52357c32b8f397b7b`.
|
||||
- Release signature verified against fingerprint `68A49476754103988EA3ED924B88CBB3D6DCB237`; a clean ordinary Maven consumer resolved identical bytes without manual installation.
|
||||
- Actual public JAR: all 28 targeted desktop/mobile cases passed, zero failures/skips/flaky outcomes/report errors. All 193 dashboard files and RC9 version markers are present, without the mock service worker.
|
||||
- Public image: `sha256:c2c684733deb98fd8355d4a9e2a53bf4af429bd16d9e7802641f9ef9d44e1017`. Anonymous AMD64/ARM64 pulls and both architecture startup, route, JavaScript, pinned-font and version checks passed.
|
||||
- Merged-source general CI36912597438 passed. Fresh full dependency audit reports zero advisories across1,038dependencies; GitHub marked all four outstanding alerts fixed, none dismissed. Issues #647/#698 are closed with that evidence.
|
||||
|
||||
Full immutable-tag acceptance [36912641399](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36912641399) completed after publication: Chromium 602 passed / 0 failed; desktop WebKit 602 / 0; Firefox 600 / 2; mobile 563 / 0; tablet 560 / 3. All reports contain zero skips, flaky outcomes and report errors. Firefox failed NAV-016 legacy-font diagnostics and REC-048 validation-toast visibility; tablet failed NAV-015 JavaScript loading, NAV-016 root navigation (both WebKit internal errors), and NAV-025 Clear-search focus in dark mode. The overall run failed; later passing cases do not resolve older investigations. Follow [frontend#12](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual failure tracking and [#713](https://github.com/QRun-IO/qqq/issues/713) for the live backlog. Final 1.0 remains held.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Next UI — unreleased
|
||||
|
||||
[RC12 preparation](1.0.0-RC.12.md) collects every change merged since [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11): the always-available record Actions menu ([#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)), private query-state clearing between users, standalone branding and CSP derivation, grid and report focus fixes, and browser-testing hardening. RC11 is published and immutable. Changes merged after the RC12 preparation commit are listed below. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
|
||||
## New features
|
||||
|
||||
- SQL Workbench: render the `SQL_WORKBENCH` widget of the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit as a super-user SQL notebook.
|
||||
- Personal and shared notebooks with SQL and markdown cells, `:parameters`, versions and pinned results.
|
||||
- A CodeMirror 6 editor with dialect highlighting, schema and QQQ-name autocomplete, and formatting.
|
||||
- A typed, sortable, paged result grid with CSV/XLSX/JSON export, quick charts, and EXPLAIN plan trees.
|
||||
- Read-only runs by default, and a two-step write confirmation for users with the write permission.
|
||||
- CodeMirror and the formatter load only on that page (new `lazyChunks` budgets).
|
||||
|
||||
## Features
|
||||
|
||||
- Show `FILE_PREVIEW`-adorned fields as thumbnails, and open a preview on click or Enter. Thumbnails appear in the query grid and card view, the record view (a larger thumbnail with the file name), associated-record tables, child-record list widgets and record-grid widgets. They load lazily at a fixed size, so loading does not shift the layout, with a loading pulse and a file-type icon fallback. The preview is an accessible dialog: focus is trapped and restored, Escape closes it, and Left/Right/Home/End step through the other previews on the same screen. It offers zoom and fit, open-original and download links, and shows type, size, dimensions and caption. PDFs served by the backend (same-origin `/qqq/` paths) and video/audio play inline; other types, and other or cross-origin PDFs (frames are limited to `'self'`), get a file card or open in a new tab. Thumbnails are shown only for explicitly adorned fields; plain URL values are never auto-detected. URLs are limited to http(s), same-origin paths and inline images. The lightbox loads on demand (about 3.5 KB gzip). Requires the QQQ backend adornment from [qqq#1056](https://github.com/QRun-IO/qqq/issues/1056).
|
||||
|
||||
## 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.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
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).
|
||||
@@ -1,5 +1,49 @@
|
||||
# Next UI security review (QRun-IO/qqq#696)
|
||||
|
||||
## Current status — October 4, 2026
|
||||
|
||||
This addendum supersedes the historical disposition statements below where noted.
|
||||
The September 25 review remains intact as evidence of its original source and scope.
|
||||
Current findings and release decisions are tracked in
|
||||
[QQQ #696](https://github.com/QRun-IO/qqq/issues/696#issuecomment-5982040326).
|
||||
This is a scoped security review, not a certification of ASVS Level 2 compliance.
|
||||
|
||||
| Finding | Current disposition |
|
||||
| --- | --- |
|
||||
| NUI-04: dashboard headers | [#695](https://github.com/QRun-IO/qqq/issues/695) is closed for the Javalin static-export scope, with current-source provider tests and browser evidence. Standalone qualification is separate. |
|
||||
| NUI-05: readable session cookie | [#733](https://github.com/QRun-IO/qqq/issues/733) is closed. Next resumes through the backend-owned cookie; the legacy endpoint correction is merged. Material's explicit readable-cookie compatibility mode remains supported. |
|
||||
| NUI-06: previous-user browser state | The additional query-filter and saved-view-pointer retention finding is fixed in [#1006](https://github.com/QRun-IO/qqq/issues/1006), merged in frontend `f367756`. Global visual preferences are preserved. |
|
||||
| NUI-09: standalone headers | [#734](https://github.com/QRun-IO/qqq/issues/734) and the metadata-derived CSP correction [#1017](https://github.com/QRun-IO/qqq/issues/1017) are closed after scoped qualification and integration. The paired implementation passed 48 standalone first-attempt cases across five profiles. [Frontend PR #40](https://github.com/QRun-IO/qqq-frontend-next/pull/40) merged as `138c3c6d`, tree `328bc322`, exactly matching the qualified combined candidate. Its separate full Javalin acceptance passed 3,015/3,015 first attempts against backend `1d16a70c`. HEAD/304, bounded metadata and Java-customizer limits remain documented in the closeout. |
|
||||
| NUI-11: custom CSS | Current code treats application `customCss` as trusted developer configuration and inserts it with `textContent`. It is not filtered by the historical deny-list or sanitized by DOMPurify. CSP and application configuration remain separate controls. |
|
||||
| Auth0 token verification | [#1008](https://github.com/QRun-IO/qqq/issues/1008) confirms issuer/audience validation and cache-boundary findings. [Backend PR #1012](https://github.com/QRun-IO/qqq/pull/1012) contains the correction; 164 focused tests passed on its combined backend tree. The exact-head CI passed and the correction merged into backend develop as `fc67393b`. An unconfigured audience remains supported; no audience enforcement is claimed in that configuration. |
|
||||
| Signed back-channel logout | [#909](https://github.com/QRun-IO/qqq/issues/909) remains open for stable release delivery. [Backend PR #1021](https://github.com/QRun-IO/qqq/pull/1021) merged the qualified correction into stable main as `9168e434`; its merge tree equals the qualified candidate and the automatic post-merge reactor/sample/static gates passed. Published 4.0.0 remains affected; planned 4.0.1 is not published or artifact-qualified. PR #1018 is closed as superseded, not merged; its original Mongo failure is retained. The corrected 4.1 RC line remains distinct. |
|
||||
|
||||
The other scoped finding dispositions remain as reconciled in #696. NUI-10's
|
||||
runtime-style exception remains deliberate; it does not allow inline scripts.
|
||||
Negative security tests intentionally produce denied-resource and CSP diagnostics.
|
||||
A passing test run does not mean those diagnostic arrays are all empty.
|
||||
|
||||
### Evidence limits
|
||||
|
||||
The reference remains ASVS 4.0.3. Owned identity-provider fixtures do not qualify a
|
||||
real Auth0 tenant, MFA or step-up policy, provider-grant revocation, or deployment
|
||||
TLS, HSTS and proxy trust. The approved real-provider exclusion remains explicit.
|
||||
Configurable TABLE_BASED idle expiration does not prove a 12-hour active / 30-minute
|
||||
idle policy. Cookie-prefix and shared-host suitability, generic CSRF/Origin
|
||||
protection, all-session revocation, and application-sensitive storage/cache policy
|
||||
also remain outside the demonstrated guarantees. These limits are not newly
|
||||
confirmed High/Critical exploits and must not be presented as universal compliance.
|
||||
|
||||
The 48-case standalone result covers its selected security scope and paired
|
||||
candidate backend. Its analytics case uses an owned plugin through the real
|
||||
application hook, not a vendor SDK or real account. It does not
|
||||
replace full-browser acceptance. The separate 3,015-case Javalin gate qualifies
|
||||
the merged PR40 tree; neither result resolves separately tracked browser failures
|
||||
or certifies a real provider or deployment. Final 1.0 still requires owner visual
|
||||
acceptance and real-application soak.
|
||||
|
||||
## Historical review — September 25, 2026
|
||||
|
||||
Independent review of the QQQ Next admin UI against OWASP ASVS 4.0.3 Level 2, for the
|
||||
Next UI 1.0 milestone (epic QRun-IO/qqq#713). Reviewed on 2026-09-25 at
|
||||
`feature/GH-649-next-acceptance` (`2b6569f`, release 0.2.0) with the backend at
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.8",
|
||||
"version": "1.0.0-RC.12",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
@@ -28,7 +28,14 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/lang-sql": "6.9.0",
|
||||
"@codemirror/language": "6.11.2",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -54,13 +61,14 @@
|
||||
"react-hook-form": "^7.71.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "15.6.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.1",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "1.64.0-alpha-2026-10-01",
|
||||
"@storybook/addon-docs": "10.6.0",
|
||||
"@storybook/nextjs-vite": "10.6.0",
|
||||
"@storybook/react": "10.6.0",
|
||||
|
||||
+13
-1
@@ -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.",
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Raise a cap only with a measured reason in the commit.",
|
||||
"firstLoadJsGzipKB": 350,
|
||||
"routes": {
|
||||
"/": 255,
|
||||
@@ -12,7 +12,9 @@
|
||||
"/app/_": 350,
|
||||
"/app/_/_": 350,
|
||||
"/app/_/_/copy": 390,
|
||||
"/app/_/_/dev": 360,
|
||||
"/app/_/_/edit": 350,
|
||||
"/app/_/dev": 360,
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350
|
||||
},
|
||||
@@ -24,6 +26,16 @@
|
||||
"$comment": "RapiDoc (the table developer view's API docs, QRun-IO/qqq#714) loads only when that view opens: import('rapidoc') in TableApiDocs. Measured 204.5 KB gzip at 1.0; budget about 5% above.",
|
||||
"marker": "customElements.define(\"rapi-doc\"",
|
||||
"gzipKB": 215
|
||||
},
|
||||
"sqlWorkbench": {
|
||||
"$comment": "CodeMirror 6 (state, view, language, autocomplete, lang-sql) plus the SQL Workbench widget, loaded when the SQL_WORKBENCH widget renders: import() in WidgetRenderer. Measured 127.2 KB gzip at introduction; budget about 6% above.",
|
||||
"marker": "qqq-sql-workbench-editor",
|
||||
"gzipKB": 135
|
||||
},
|
||||
"sqlFormatter": {
|
||||
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
|
||||
"marker": "logicalOperatorNewline",
|
||||
"gzipKB": 65
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+203
-40
@@ -13,9 +13,30 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete':
|
||||
specifier: 6.18.6
|
||||
version: 6.18.6
|
||||
'@codemirror/commands':
|
||||
specifier: 6.8.1
|
||||
version: 6.8.1
|
||||
'@codemirror/lang-sql':
|
||||
specifier: 6.9.0
|
||||
version: 6.9.0
|
||||
'@codemirror/language':
|
||||
specifier: 6.11.2
|
||||
version: 6.11.2
|
||||
'@codemirror/state':
|
||||
specifier: 6.5.2
|
||||
version: 6.5.2
|
||||
'@codemirror/view':
|
||||
specifier: 6.38.1
|
||||
version: 6.38.1
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))
|
||||
'@lezer/highlight':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -72,7 +93,7 @@ importers:
|
||||
version: 0.485.0(react@19.2.4)
|
||||
next:
|
||||
specifier: 16.3.6
|
||||
version: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
rapidoc:
|
||||
specifier: 9.3.8
|
||||
version: 9.3.8
|
||||
@@ -91,6 +112,9 @@ importers:
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
sql-formatter:
|
||||
specifier: 15.6.6
|
||||
version: 15.6.6
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
@@ -100,19 +124,19 @@ importers:
|
||||
devDependencies:
|
||||
'@axe-core/playwright':
|
||||
specifier: ^4.11.1
|
||||
version: 4.11.1(playwright-core@1.58.2)
|
||||
version: 4.11.1(playwright-core@1.64.0-alpha-2026-10-01)
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.4
|
||||
version: 3.3.4
|
||||
'@playwright/test':
|
||||
specifier: ^1.58.2
|
||||
version: 1.58.2
|
||||
specifier: 1.64.0-alpha-2026-10-01
|
||||
version: 1.64.0-alpha-2026-10-01
|
||||
'@storybook/addon-docs':
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
|
||||
'@storybook/nextjs-vite':
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
|
||||
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
|
||||
'@storybook/react':
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
@@ -335,6 +359,24 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@codemirror/autocomplete@6.18.6':
|
||||
resolution: {integrity: sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==}
|
||||
|
||||
'@codemirror/commands@6.8.1':
|
||||
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
||||
|
||||
'@codemirror/lang-sql@6.9.0':
|
||||
resolution: {integrity: sha512-xmtpWqKSgum1B1J3Ro6rf7nuPqf2+kJQg5SjrofCAcyCThOe0ihSktSoXfXuhQBnwx1QbmreBbLJM5Jru6zitg==}
|
||||
|
||||
'@codemirror/language@6.11.2':
|
||||
resolution: {integrity: sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==}
|
||||
|
||||
'@codemirror/state@6.5.2':
|
||||
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
|
||||
|
||||
'@codemirror/view@6.38.1':
|
||||
resolution: {integrity: sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -875,12 +917,24 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lezer/common@1.5.3':
|
||||
resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==}
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
||||
|
||||
'@lezer/lr@1.4.11':
|
||||
resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==}
|
||||
|
||||
'@lit-labs/ssr-dom-shim@1.6.0':
|
||||
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
|
||||
|
||||
'@lit/reactive-element@2.1.2':
|
||||
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.4':
|
||||
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
@@ -1200,9 +1254,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@playwright/test@1.58.2':
|
||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||
engines: {node: '>=18'}
|
||||
'@playwright/test@1.64.0-alpha-2026-10-01':
|
||||
resolution: {integrity: sha512-N/mTE29hNg/pw/ega+N+cEvPmAFwrprMxoacDbaO0fDlpyQ6n/SvCRF9dv6mLa1tjcy9oPStW2o7C1HP2Mh8lQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
@@ -2748,6 +2802,9 @@ packages:
|
||||
core-js-pure@3.50.0:
|
||||
resolution: {integrity: sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==}
|
||||
|
||||
crelt@1.0.7:
|
||||
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2909,6 +2966,9 @@ packages:
|
||||
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
discontinuous-range@1.0.0:
|
||||
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3250,11 +3310,6 @@ packages:
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -3886,6 +3941,9 @@ packages:
|
||||
module-alias@2.3.4:
|
||||
resolution: {integrity: sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==}
|
||||
|
||||
moo@0.5.3:
|
||||
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3923,6 +3981,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
nearley@2.20.1:
|
||||
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
|
||||
hasBin: true
|
||||
|
||||
needle@2.9.1:
|
||||
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
|
||||
engines: {node: '>= 4.4.x'}
|
||||
@@ -4101,14 +4163,14 @@ packages:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
playwright-core@1.58.2:
|
||||
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
|
||||
engines: {node: '>=18'}
|
||||
playwright-core@1.64.0-alpha-2026-10-01:
|
||||
resolution: {integrity: sha512-Dk7ENTj8unFs3o5KNTIhFXO+JN/YGU4FrQvPmxI0Uri9j/5r8Mk0IzmFkNpvx6n83Kcls38qRdkD/fTdAfWvtA==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.58.2:
|
||||
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
|
||||
engines: {node: '>=18'}
|
||||
playwright@1.64.0-alpha-2026-10-01:
|
||||
resolution: {integrity: sha512-Nbe+dTDPKcZjI+lIUe6WLUb3KQ3Wll/SPkf7oCzytUoPZo+wzZY6J2DaD8+7dXMsq+8lUB2dZOMimouiYwpGuw==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
@@ -4168,6 +4230,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
railroad-diagrams@1.0.0:
|
||||
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
|
||||
|
||||
ramda-adjunct@5.1.0:
|
||||
resolution: {integrity: sha512-8qCpl2vZBXEJyNbi4zqcgdfHtcdsWjOGbiNSEnEBrM6Y0OKOT8UxJbIVGm1TIcjaSu2MxaWcgtsNlKlCk7o7qg==}
|
||||
engines: {node: '>=0.10.3'}
|
||||
@@ -4177,6 +4242,10 @@ packages:
|
||||
ramda@0.30.1:
|
||||
resolution: {integrity: sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==}
|
||||
|
||||
randexp@0.4.6:
|
||||
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
randexp@0.5.3:
|
||||
resolution: {integrity: sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4319,6 +4388,10 @@ packages:
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
ret@0.1.15:
|
||||
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
ret@0.2.2:
|
||||
resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4475,6 +4548,10 @@ packages:
|
||||
spdx-license-ids@3.0.23:
|
||||
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
|
||||
|
||||
sql-formatter@15.6.6:
|
||||
resolution: {integrity: sha512-bZydXEXhaNDQBr8xYHC3a8thwcaMuTBp0CkKGjwGYDsIB26tnlWeWPwJtSQ0TEwiJcz9iJJON5mFPkx7XroHcg==}
|
||||
hasBin: true
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@@ -4564,6 +4641,9 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
style-mod@4.1.4:
|
||||
resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -4918,6 +4998,9 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5066,10 +5149,10 @@ snapshots:
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 10.4.3
|
||||
|
||||
'@axe-core/playwright@4.11.1(playwright-core@1.58.2)':
|
||||
'@axe-core/playwright@4.11.1(playwright-core@1.64.0-alpha-2026-10-01)':
|
||||
dependencies:
|
||||
axe-core: 4.11.1
|
||||
playwright-core: 1.58.2
|
||||
playwright-core: 1.64.0-alpha-2026-10-01
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
@@ -5212,6 +5295,49 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@codemirror/autocomplete@6.18.6':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.2
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@codemirror/commands@6.8.1':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.2
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@codemirror/lang-sql@6.9.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.6
|
||||
'@codemirror/language': 6.11.2
|
||||
'@codemirror/state': 6.5.2
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.11
|
||||
|
||||
'@codemirror/language@6.11.2':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.11
|
||||
style-mod: 4.1.4
|
||||
|
||||
'@codemirror/state@6.5.2':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.4
|
||||
|
||||
'@codemirror/view@6.38.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
crelt: 1.0.7
|
||||
style-mod: 4.1.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -5656,12 +5782,24 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@lezer/common@1.5.3': {}
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@lezer/lr@1.4.11':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@lit-labs/ssr-dom-shim@1.6.0': {}
|
||||
|
||||
'@lit/reactive-element@2.1.2':
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.6.0
|
||||
|
||||
'@marijn/find-cluster-break@1.0.4': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.14
|
||||
@@ -5884,9 +6022,9 @@ snapshots:
|
||||
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.58.2':
|
||||
'@playwright/test@1.64.0-alpha-2026-10-01':
|
||||
dependencies:
|
||||
playwright: 1.58.2
|
||||
playwright: 1.64.0-alpha-2026-10-01
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
@@ -6386,18 +6524,18 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))':
|
||||
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))':
|
||||
dependencies:
|
||||
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
|
||||
'@storybook/react': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
'@storybook/react-vite': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
|
||||
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4)
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)
|
||||
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
|
||||
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
@@ -7707,8 +7845,7 @@ snapshots:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@2.20.3:
|
||||
optional: true
|
||||
commander@2.20.3: {}
|
||||
|
||||
comment-parser@1.4.5: {}
|
||||
|
||||
@@ -7720,6 +7857,8 @@ snapshots:
|
||||
|
||||
core-js-pure@3.50.0: {}
|
||||
|
||||
crelt@1.0.7: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -7853,6 +7992,8 @@ snapshots:
|
||||
|
||||
diff-sequences@29.6.3: {}
|
||||
|
||||
discontinuous-range@1.0.0: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
@@ -8369,9 +8510,6 @@ snapshots:
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -9004,6 +9142,8 @@ snapshots:
|
||||
|
||||
module-alias@2.3.4: {}
|
||||
|
||||
moo@0.5.3: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
@@ -9043,6 +9183,13 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
nearley@2.20.1:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
moo: 0.5.3
|
||||
railroad-diagrams: 1.0.0
|
||||
randexp: 0.4.6
|
||||
|
||||
needle@2.9.1:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
@@ -9053,7 +9200,7 @@ snapshots:
|
||||
|
||||
neotraverse@1.0.1: {}
|
||||
|
||||
next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.3.6
|
||||
'@swc/helpers': 0.5.23
|
||||
@@ -9072,7 +9219,7 @@ snapshots:
|
||||
'@next/swc-linux-x64-musl': 16.3.6
|
||||
'@next/swc-win32-arm64-msvc': 16.3.6
|
||||
'@next/swc-win32-x64-msvc': 16.3.6
|
||||
'@playwright/test': 1.58.2
|
||||
'@playwright/test': 1.64.0-alpha-2026-10-01
|
||||
sharp: 0.35.4(@types/node@22.19.11)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -9269,13 +9416,11 @@ snapshots:
|
||||
|
||||
picomatch@4.0.7: {}
|
||||
|
||||
playwright-core@1.58.2: {}
|
||||
playwright-core@1.64.0-alpha-2026-10-01: {}
|
||||
|
||||
playwright@1.58.2:
|
||||
playwright@1.64.0-alpha-2026-10-01:
|
||||
dependencies:
|
||||
playwright-core: 1.58.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
playwright-core: 1.64.0-alpha-2026-10-01
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
@@ -9337,12 +9482,19 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
railroad-diagrams@1.0.0: {}
|
||||
|
||||
ramda-adjunct@5.1.0(ramda@0.30.1):
|
||||
dependencies:
|
||||
ramda: 0.30.1
|
||||
|
||||
ramda@0.30.1: {}
|
||||
|
||||
randexp@0.4.6:
|
||||
dependencies:
|
||||
discontinuous-range: 1.0.0
|
||||
ret: 0.1.15
|
||||
|
||||
randexp@0.5.3:
|
||||
dependencies:
|
||||
drange: 1.1.1
|
||||
@@ -9520,6 +9672,8 @@ snapshots:
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
ret@0.1.15: {}
|
||||
|
||||
ret@0.2.2: {}
|
||||
|
||||
rettime@0.10.1: {}
|
||||
@@ -9729,6 +9883,11 @@ snapshots:
|
||||
|
||||
spdx-license-ids@3.0.23: {}
|
||||
|
||||
sql-formatter@15.6.6:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
nearley: 2.20.1
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stack-utils@2.0.6:
|
||||
@@ -9852,6 +10011,8 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
style-mod@4.1.4: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -10180,13 +10341,13 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-plugin-storybook-nextjs@10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)):
|
||||
vite-plugin-storybook-nextjs@10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)):
|
||||
dependencies:
|
||||
'@next/env': 16.3.6
|
||||
'@rollup/pluginutils': 5.4.0(rollup@4.63.5)
|
||||
magic-string: 0.30.21
|
||||
module-alias: 2.3.4
|
||||
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pathe: 2.0.3
|
||||
probe-image-size: 7.4.0
|
||||
semver: 7.8.5
|
||||
@@ -10255,6 +10416,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.8</revision>
|
||||
<revision>1.0.0-RC.12</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
|
||||
@@ -18,7 +18,7 @@ rmSync(classes, { recursive: true, force: true })
|
||||
mkdirSync(classes, { recursive: true })
|
||||
const fixtureDirectory = path.resolve('tests/acceptance/fixture')
|
||||
const sources = readdirSync(fixtureDirectory).filter((name) => name.endsWith('.java')).map((name) => path.join(fixtureDirectory, name))
|
||||
const javac = spawnSync('javac', ['-proc:none', '-encoding', 'UTF-8', '-cp', fixtureClasspath, '-d', classes, ...sources], { stdio: 'inherit' })
|
||||
const javac = spawnSync('javac', ['-proc:none', '-sourcepath', '', '-encoding', 'UTF-8', '-cp', fixtureClasspath, '-d', classes, ...sources], { stdio: 'inherit' })
|
||||
if (javac.status !== 0) {
|
||||
console.error('Acceptance fixture compilation failed.')
|
||||
process.exit(1)
|
||||
@@ -29,6 +29,7 @@ const server = spawn('java', [
|
||||
'-Dqqq.sample.mockAuthentication=true',
|
||||
'-Dqqq.sample.sharing=true',
|
||||
`-Dqqq.sample.port=${port}`,
|
||||
`-Dqqq.sample.esb.port=${process.env.QQQ_ACCEPTANCE_ESB_PORT ?? '61616'}`,
|
||||
'-Duser.timezone=UTC',
|
||||
// javalin mode: the fresh export shadows any dashboard bundled in the sample jar
|
||||
'-cp', [...(ACCEPTANCE_MODE === 'javalin' ? [EXPORT_CLASSPATH] : []), classes, fixtureClasspath].join(path.delimiter),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process'
|
||||
import { cpSync, existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { BUILD_MARKER } from './acceptance-paths.mjs'
|
||||
import { assertExplicitCspSources } from './acceptance-standalone.mjs'
|
||||
|
||||
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
|
||||
const standalone = path.resolve('.next/standalone')
|
||||
@@ -19,16 +20,16 @@ if (!existsSync(path.join(standalone, 'server.js')) || !existsSync(BUILD_MARKER)
|
||||
console.error(`No production build for ${backend}; run node scripts/acceptance.mjs.`)
|
||||
process.exit(1)
|
||||
}
|
||||
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true })
|
||||
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true })
|
||||
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'backend-prefixes.json']) {
|
||||
// Use the JS copy path for owner-readable files on Docker Desktop mounts.
|
||||
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'branding-assets.mjs', 'backend-prefixes.json']) {
|
||||
cpSync(path.resolve('standalone', file), path.join(standalone, file))
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's policy additions (its QuickSight embed, custom component bundles and
|
||||
* the fixture's loopback service), which the QQQ server derives in javalin mode and a
|
||||
* container deployment sets in QQQ_DASHBOARD_CSP_SOURCES.
|
||||
* Only the fixture's explicit owned frame/image/media overrides. Application
|
||||
* metadata sources must arrive through the production metadata contract.
|
||||
*
|
||||
* @returns {Promise<string>} The sources, once the acceptance backend is up.
|
||||
*/
|
||||
@@ -47,6 +48,7 @@ async function dashboardCspSources() {
|
||||
}
|
||||
|
||||
const sources = await dashboardCspSources()
|
||||
assertExplicitCspSources(sources)
|
||||
const server = spawn('node', ['qqq-server.mjs'], {
|
||||
cwd: standalone,
|
||||
stdio: 'inherit',
|
||||
|
||||
@@ -40,3 +40,11 @@ export const BUILD_MARKER = path.resolve('.next/acceptance-backend.txt')
|
||||
export const EXPORT_CLASSPATH = path.resolve('test-results/acceptance/export-classpath')
|
||||
|
||||
export const ACCEPTANCE_MODE = process.env.QQQ_ACCEPTANCE_MODE === 'standalone' ? 'standalone' : 'javalin'
|
||||
|
||||
/** Separate backend, browser and provider origins for authentication variants. */
|
||||
export const SECURITY_BACKEND_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT ?? Number(process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? 18765) + 10)
|
||||
export const SECURITY_FRONTEND_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_FRONTEND_PORT ?? Number(process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? 13765) + 20)
|
||||
export const SECURITY_IDP_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_IDP_PORT ?? Number(process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? 13765) + 10)
|
||||
export const SECURITY_BACKEND_URL = `http://127.0.0.1:${SECURITY_BACKEND_PORT}`
|
||||
export const SECURITY_UI_URL = ACCEPTANCE_MODE === 'standalone' ? `http://127.0.0.1:${SECURITY_FRONTEND_PORT}` : SECURITY_BACKEND_URL
|
||||
export const SECURITY_STANDALONE = path.resolve('test-results/acceptance/security-standalone')
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/** @file Stages and validates an immutable standalone build for an authentication backend. */
|
||||
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
const prefixes = createRequire(path.resolve('package.json'))('./standalone/backend-prefixes.json')
|
||||
|
||||
/** Rejects a missing or differently routed build, regardless of any retained Javalin export. */
|
||||
export function assertStandaloneTarget(directory, backend) {
|
||||
if (!existsSync(path.join(directory, 'server.js')) || !existsSync(path.join(directory, 'qqq-server.mjs'))
|
||||
|| readFileSync(path.join(directory, 'acceptance-backend.txt'), 'utf8').trim() !== backend) {
|
||||
throw new Error(`No standalone security build for ${backend}; run acceptance without --skip-build.`)
|
||||
}
|
||||
const { rewrites } = JSON.parse(readFileSync(path.join(directory, '.next/routes-manifest.json'), 'utf8'))
|
||||
const routes = Array.isArray(rewrites) ? rewrites : [...rewrites.beforeFiles, ...rewrites.afterFiles, ...rewrites.fallback]
|
||||
for (const prefix of prefixes) {
|
||||
if (!routes.some(({ source, destination }) => source === `/${prefix}/:path*` && destination === `${backend}/${prefix}/:path*`)) {
|
||||
throw new Error(`Standalone security build has the wrong backend rewrite for ${prefix}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the same standalone payload as the container image, before the next clean build. */
|
||||
export function stageStandalone(directory, backend, source = process.cwd()) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
// The JS copy path preserves readable files on Docker Desktop mounts; relative links
|
||||
// must remain relative so deleting the original build cannot invalidate this stage.
|
||||
cpSync(path.join(source, '.next/standalone'), directory, { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.join(source, '.next/static'), path.join(directory, '.next/static'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.join(source, 'public'), path.join(directory, 'public'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'branding-assets.mjs', 'backend-prefixes.json']) {
|
||||
cpSync(path.join(source, 'standalone', file), path.join(directory, file))
|
||||
}
|
||||
writeFileSync(path.join(directory, 'acceptance-backend.txt'), `${backend}\n`)
|
||||
assertStandaloneTarget(directory, backend)
|
||||
}
|
||||
|
||||
/** Prevents the fixture from supplying the metadata admissions the production wrapper must derive. */
|
||||
export function assertExplicitCspSources(text) {
|
||||
for (const part of text.split(';')) {
|
||||
const [directive, ...sources] = part.trim().split(/\s+/).filter(Boolean)
|
||||
if (!directive) continue
|
||||
if (!['frame-src', 'img-src', 'media-src'].includes(directive) || sources.length !== 1) {
|
||||
throw new Error('Only explicit owned-service CSP overrides are allowed')
|
||||
}
|
||||
let origin
|
||||
try { origin = new URL(sources[0]) } catch { /* rejected below */ }
|
||||
if (!origin || !['http:', 'https:'].includes(origin.protocol) || origin.origin !== sources[0] || sources[0].includes('*')) {
|
||||
throw new Error('Only explicit owned-service CSP overrides are allowed')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar } from './acceptance-paths.mjs'
|
||||
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar, SECURITY_BACKEND_URL, SECURITY_STANDALONE } from './acceptance-paths.mjs'
|
||||
|
||||
import { stageStandalone } from './acceptance-standalone.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const skipBuild = args.includes('--skip-build')
|
||||
@@ -38,7 +40,12 @@ if (ACCEPTANCE_MODE === 'javalin') {
|
||||
cpSync('out', path.join(EXPORT_CLASSPATH, 'next-dashboard'), { recursive: true, filter: (source) => !source.endsWith('mockServiceWorker.js') })
|
||||
}
|
||||
if (!skipBuild && ACCEPTANCE_MODE === 'standalone') {
|
||||
const status = run('pnpm', ['build'], { QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
// A second immutable build is required: Next bakes the rewrite destination at build time.
|
||||
const securityStatus = run('pnpm', ['build'], { QQQ_NEXT_OUTPUT: 'standalone', QQQ_BACKEND_URL: SECURITY_BACKEND_URL, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (securityStatus !== 0) process.exit(securityStatus)
|
||||
stageStandalone(SECURITY_STANDALONE, SECURITY_BACKEND_URL)
|
||||
rmSync('.next', { recursive: true, force: true })
|
||||
const status = run('pnpm', ['build'], { QQQ_NEXT_OUTPUT: 'standalone', QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (status !== 0) process.exit(status)
|
||||
writeFileSync(BUILD_MARKER, backend + '\n')
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { addRecentRecord, removeRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { recordAnalytics } from '@/lib/analytics'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { getProcessesForRecordView } from '@/lib/utils/process-utils'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { auditSource } from '@/lib/api/audits'
|
||||
@@ -191,7 +191,7 @@ export default function RecordViewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
|
||||
const tableProcesses = metaData ? getProcessesForRecordView(metaData, slug) : []
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
@@ -124,6 +124,11 @@ const greeting = {
|
||||
}
|
||||
|
||||
describe('SlugPage process initialization', () => {
|
||||
beforeAll(async () => {
|
||||
// Keep cold screen compilation outside the metadata-readiness assertion deadline.
|
||||
await import('@/components/process/ProcessRun')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
params.slug = greeting.name
|
||||
|
||||
@@ -24,11 +24,16 @@ import { toast } from 'sonner'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EsbCapabilities, EsbPermissions } from '@/types'
|
||||
import { processInit } from '@/lib/api/processes'
|
||||
import { processInit, processStatus } from '@/lib/api/processes'
|
||||
import { orderEsb } from '@/mocks/fixtures/esb'
|
||||
import { EsbQueueActions, EsbTriggerActions, destinationQueue, type EsbQueueRef } from './EsbActions'
|
||||
import {
|
||||
EsbQueueActions,
|
||||
EsbTriggerActions,
|
||||
destinationQueue,
|
||||
type EsbQueueRef,
|
||||
} from './EsbActions'
|
||||
|
||||
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn() }))
|
||||
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
|
||||
vi.mock('sonner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('sonner')>()),
|
||||
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() },
|
||||
@@ -102,28 +107,44 @@ describe('EsbActions', () => {
|
||||
it('needs esbOperate for pausing a queue and esbDelete for purging and deleting', () => {
|
||||
const { unmount } = render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<EsbQueueActions queue={fulfillmentQueue()} permissions={{ canOperate: true, canDelete: false }} />
|
||||
<EsbQueueActions
|
||||
queue={fulfillmentQueue()}
|
||||
permissions={{ canOperate: true, canDelete: false }}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Pause queue orderFulfillment' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Purge orderFulfillment' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete older messages in orderFulfillment' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete older messages in orderFulfillment' })
|
||||
).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderWithClient(
|
||||
<EsbQueueActions queue={fulfillmentQueue()} permissions={{ canOperate: false, canDelete: true }} />
|
||||
<EsbQueueActions
|
||||
queue={fulfillmentQueue()}
|
||||
permissions={{ canOperate: false, canDelete: true }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Purge orderFulfillment' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers resume instead of pause for a paused queue', () => {
|
||||
renderWithClient(
|
||||
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: true }} permissions={ALL} />
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Resume queue orderFulfillment' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Resume queue orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers pause for a running trigger and resume for a paused one', () => {
|
||||
@@ -150,10 +171,29 @@ describe('EsbActions', () => {
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Done.'))
|
||||
})
|
||||
|
||||
it('replays all dead letters with esbReplayDeadLetters', async () => {
|
||||
it.each([1, null])('confirms replay-all with a safe count of %s', async (count) => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
|
||||
renderWithClient(
|
||||
<EsbTriggerActions
|
||||
trigger={{
|
||||
...fulfillTrigger,
|
||||
deadLetter: { ...fulfillTrigger.deadLetter, messageCount: count },
|
||||
}}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Replay dead letters for Fulfill Order' }))
|
||||
let dialog = await screen.findByRole('alertdialog')
|
||||
expect(dialog).toHaveTextContent('orderFulfillment.dlq')
|
||||
expect(dialog).toHaveTextContent(
|
||||
count === null ? 'all dead letters (count unknown)' : '1 message'
|
||||
)
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Replay dead letters for Fulfill Order' }))
|
||||
dialog = await screen.findByRole('alertdialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Replay dead letters' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbReplayDeadLetters', {
|
||||
values: { triggerName: 'fulfillOrder.orderFulfillment', all: true },
|
||||
@@ -188,7 +228,9 @@ describe('EsbActions', () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbQueueActions queue={fulfillmentQueue()} permissions={ALL} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })
|
||||
)
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
expect(dialog).toHaveTextContent('orderFulfillment')
|
||||
expect(dialog).toHaveTextContent('7 messages')
|
||||
@@ -227,6 +269,86 @@ describe('EsbActions', () => {
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Broker unavailable'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Resume Cancel Order', 'esbResumeTrigger', 'cancelOrder.orderEvents'],
|
||||
['Restart Fulfill Order', 'esbRestartTrigger', 'fulfillOrder.orderFulfillment'],
|
||||
])('sends the exact trigger input for %s', async (label, processName, triggerName) => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbTriggerActions
|
||||
trigger={triggerName === cancelTrigger.name ? cancelTrigger : fulfillTrigger}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith(processName, { values: { triggerName } })
|
||||
)
|
||||
})
|
||||
|
||||
it('sends provider and broker queue when resuming delivery', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: true }} permissions={ALL} />
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Resume queue orderFulfillment' }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbResumeQueue', {
|
||||
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('hides replay for an empty dead-letter queue and pause when queue state is unknown', () => {
|
||||
renderWithClient(
|
||||
<>
|
||||
<EsbTriggerActions
|
||||
trigger={{
|
||||
...fulfillTrigger,
|
||||
deadLetter: { ...fulfillTrigger.deadLetter, messageCount: 0 },
|
||||
}}
|
||||
permissions={ALL}
|
||||
/>
|
||||
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: null }} permissions={ALL} />
|
||||
</>
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /Replay/ })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Pause queue|Resume queue/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['COMPLETE', 'ERROR'] as const)(
|
||||
'keeps controls pending and reports only the final async %s outcome',
|
||||
async (type) => {
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'JOB_STARTED',
|
||||
processUUID: 'uuid',
|
||||
jobUUID: 'job',
|
||||
})
|
||||
vi.mocked(processStatus).mockResolvedValue(
|
||||
type === 'COMPLETE'
|
||||
? { type, processUUID: 'uuid', values: { message: 'Restarted 2 consumers.', count: 2 } }
|
||||
: { type, error: 'Restart failed.' }
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Restart Fulfill Order' }))
|
||||
expect(screen.getByRole('button', { name: 'Restart Fulfill Order' })).toBeDisabled()
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(type === 'COMPLETE' ? toast.success : toast.error).toHaveBeenCalledWith(
|
||||
type === 'COMPLETE' ? 'Restarted 2 consumers.' : 'Restart failed.'
|
||||
),
|
||||
{ timeout: 2500 }
|
||||
)
|
||||
expect(type === 'COMPLETE' ? toast.error : toast.success).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Restart Fulfill Order' })).toBeEnabled()
|
||||
}
|
||||
)
|
||||
|
||||
it('treats a topic as having no broker queue', () => {
|
||||
expect(destinationQueue(orderEsb.publications[0].destination)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -78,7 +78,7 @@ export function deadLetterQueue(trigger: EsbTrigger): EsbQueueRef {
|
||||
brokerQueueName: trigger.deadLetter.brokerName,
|
||||
capabilities: trigger.destination.capabilities,
|
||||
messageCount: trigger.deadLetter.messageCount,
|
||||
paused: null,
|
||||
paused: trigger.deadLetter.paused ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,14 +144,19 @@ export function EsbTriggerActions({
|
||||
* @returns A group of buttons.
|
||||
*/
|
||||
function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
|
||||
const action = useEsbAction()
|
||||
const [confirmReplay, setConfirmReplay] = useState(false)
|
||||
const action = useEsbAction(() => setConfirmReplay(false))
|
||||
const run = (processName: string, values: EsbActionValues = {}) =>
|
||||
action.mutate({ processName, values: { triggerName: trigger.name, ...values } })
|
||||
const paused = trigger.state === 'PAUSED'
|
||||
const id = trigger.name
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1" role="group" aria-label={`Actions for ${trigger.processLabel}`}>
|
||||
<div
|
||||
className="flex flex-wrap gap-1"
|
||||
role="group"
|
||||
aria-label={`Actions for ${trigger.processLabel}`}
|
||||
>
|
||||
{paused ? (
|
||||
<ActionButton
|
||||
label="Resume"
|
||||
@@ -182,9 +187,25 @@ function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
|
||||
ariaLabel={`Replay dead letters for ${trigger.processLabel}`}
|
||||
qqqId={`button-esb-replay-dead-letters-${id}`}
|
||||
disabled={action.isPending}
|
||||
onClick={() => run('esbReplayDeadLetters', { all: true })}
|
||||
onClick={() => setConfirmReplay(true)}
|
||||
/>
|
||||
)}
|
||||
<EsbConfirmDialog
|
||||
open={confirmReplay}
|
||||
title="Replay dead letters"
|
||||
confirmLabel="Replay dead letters"
|
||||
destructive={false}
|
||||
pending={action.isPending}
|
||||
onCancel={() => setConfirmReplay(false)}
|
||||
onConfirm={() => run('esbReplayDeadLetters', { all: true })}
|
||||
>
|
||||
Replay{' '}
|
||||
{trigger.deadLetter.messageCount === null
|
||||
? 'all dead letters (count unknown)'
|
||||
: `all ${messageCountText(trigger.deadLetter.messageCount)}`}{' '}
|
||||
from queue {trigger.deadLetter.brokerName} through {trigger.processLabel}? Successful
|
||||
messages will be removed.
|
||||
</EsbConfirmDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,14 +217,17 @@ function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
|
||||
* @param props - Component props.
|
||||
* @param props.queue - The broker queue.
|
||||
* @param props.permissions - The current user's ESB permissions.
|
||||
* @param props.onDone - Called after success to clear any browsed selection.
|
||||
* @returns The buttons, or `null` when none is available.
|
||||
*/
|
||||
export function EsbQueueActions({
|
||||
queue,
|
||||
permissions,
|
||||
onDone,
|
||||
}: {
|
||||
queue: EsbQueueRef
|
||||
permissions: EsbPermissions
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const canPause = permissions.canOperate && queue.capabilities.pauseQueue && queue.paused !== null
|
||||
const canPurge = permissions.canDelete && queue.capabilities.purge
|
||||
@@ -215,6 +239,7 @@ export function EsbQueueActions({
|
||||
canPause={canPause}
|
||||
canPurge={canPurge}
|
||||
canDeleteOlder={canDeleteOlder}
|
||||
onDone={onDone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -227,6 +252,7 @@ export function EsbQueueActions({
|
||||
* @param props.canPause - Whether to offer pause or resume.
|
||||
* @param props.canPurge - Whether to offer purge.
|
||||
* @param props.canDeleteOlder - Whether to offer deleting messages older than a time.
|
||||
* @param props.onDone - Called after an action succeeds.
|
||||
* @returns A group of buttons.
|
||||
*/
|
||||
function QueueButtons({
|
||||
@@ -234,11 +260,13 @@ function QueueButtons({
|
||||
canPause,
|
||||
canPurge,
|
||||
canDeleteOlder,
|
||||
onDone,
|
||||
}: {
|
||||
queue: EsbQueueRef
|
||||
canPause: boolean
|
||||
canPurge: boolean
|
||||
canDeleteOlder: boolean
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<'purge' | 'deleteOlder' | null>(null)
|
||||
const [olderThan, setOlderThan] = useState('')
|
||||
@@ -247,7 +275,10 @@ function QueueButtons({
|
||||
setDialog(null)
|
||||
setOlderThan('')
|
||||
}
|
||||
const action = useEsbAction(close)
|
||||
const action = useEsbAction(() => {
|
||||
close()
|
||||
onDone?.()
|
||||
})
|
||||
const target = { providerName: queue.providerName, brokerQueueName: queue.brokerQueueName }
|
||||
const name = queue.brokerQueueName
|
||||
const inQueue =
|
||||
@@ -613,7 +644,12 @@ export function EsbConfirmDialog({
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
|
||||
<AlertDialogPrimitive.Root
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) onCancel()
|
||||
}}
|
||||
>
|
||||
<AlertDialogPrimitive.Portal>
|
||||
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
||||
<AlertDialogPrimitive.Content
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// Tests for browsing ESB messages and acting on selected ones
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -72,7 +72,7 @@ describe('EsbMessageList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists a queue\'s messages with their event type and subject', async () => {
|
||||
it("lists a queue's messages with their event type and subject", async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
|
||||
)
|
||||
@@ -80,12 +80,24 @@ describe('EsbMessageList', () => {
|
||||
const rows = within(table).getAllByRole('row').slice(1)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveTextContent('ID:msg-1')
|
||||
expect(rows[0]).toHaveTextContent('qqq.table.order.INSERT')
|
||||
expect(rows[0]).toHaveTextContent('qqq.table.order.inserted')
|
||||
expect(rows[0]).toHaveTextContent('1001')
|
||||
expect(rows[1]).toHaveTextContent('ID:msg-2')
|
||||
})
|
||||
|
||||
it('shows a dead letter\'s error and raw body when it is not a CloudEvent', async () => {
|
||||
it('browses CloudEvents with the backend event type and source in both parsed and raw bodies', async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
|
||||
)
|
||||
const table = await screen.findByRole('table', { name: 'Messages' })
|
||||
expect(table).toHaveTextContent('qqq.table.order.inserted')
|
||||
for (const message of orderFulfillmentMessages.messages) {
|
||||
expect(message.event?.source).toBe('qqq://demo/table/order')
|
||||
expect(JSON.parse(message.rawBody)).toEqual(message.event)
|
||||
}
|
||||
})
|
||||
|
||||
it("shows a dead letter's error and raw body when it is not a CloudEvent", async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={DEAD_LETTER_SOURCE}
|
||||
@@ -166,7 +178,11 @@ describe('EsbMessageList', () => {
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
const confirm = within(dialog).getByRole('button', { name: 'Move messages' })
|
||||
expect(confirm).toBeDisabled()
|
||||
await user.type(within(dialog).getByLabelText('Target queue'), 'orderFulfillment.hold')
|
||||
await user.type(within(dialog).getByLabelText('Target queue'), ' ')
|
||||
expect(confirm).toBeDisabled()
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
await user.clear(within(dialog).getByLabelText('Target queue'))
|
||||
await user.type(within(dialog).getByLabelText('Target queue'), ' orderFulfillment.hold ')
|
||||
await user.click(confirm)
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith('esbMoveMessages', {
|
||||
@@ -199,6 +215,78 @@ describe('EsbMessageList', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['purge', 'deleteOlder'])('clears the selection after %s succeeds', async (operation) => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={DEAD_LETTER_SOURCE}
|
||||
queue={deadLetterQueue(fulfillTrigger)}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' }))
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name:
|
||||
operation === 'purge'
|
||||
? 'Purge orderFulfillment.dlq'
|
||||
: 'Delete older messages in orderFulfillment.dlq',
|
||||
})
|
||||
)
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
if (operation === 'deleteOlder') {
|
||||
fireEvent.change(within(dialog).getByLabelText('Older than'), {
|
||||
target: { value: '2026-09-25T10:00' },
|
||||
})
|
||||
}
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', {
|
||||
name: operation === 'purge' ? 'Purge queue' : 'Delete messages',
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('checkbox', { name: 'Select ID:dlq-1' })).not.toBeChecked()
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Replay selected' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('offers no selection when move and delete are unsupported even with permissions', async () => {
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={QUEUE_SOURCE}
|
||||
queue={{
|
||||
...fulfillmentQueue(),
|
||||
capabilities: { ...fulfillmentQueue().capabilities, move: false, deleteSelected: false },
|
||||
}}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
await screen.findByRole('table', { name: 'Messages' })
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /selected/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retains selection and confirmation after a failed purge', async () => {
|
||||
vi.mocked(processInit).mockResolvedValue({ type: 'ERROR', error: 'Purge refused.' })
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
<EsbMessageList
|
||||
source={DEAD_LETTER_SOURCE}
|
||||
queue={deadLetterQueue(fulfillTrigger)}
|
||||
permissions={ALL}
|
||||
/>
|
||||
)
|
||||
const checkbox = await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' })
|
||||
await user.click(checkbox)
|
||||
await user.click(screen.getByRole('button', { name: 'Purge orderFulfillment.dlq' }))
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Purge queue' }))
|
||||
await waitFor(() => expect(processInit).toHaveBeenCalled())
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(checkbox).toBeChecked()
|
||||
})
|
||||
|
||||
it('opens the list in a dialog from the browse button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(
|
||||
|
||||
@@ -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,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
/** What to browse: the queue behind a destination, or a trigger's dead letters. */
|
||||
export type EsbMessageSource =
|
||||
| { kind: 'destination'; name: string }
|
||||
| { kind: 'subscription'; name: string; triggerName: string }
|
||||
| { kind: 'deadLetters'; triggerName: string }
|
||||
|
||||
/** Props for {@link EsbMessageList}. */
|
||||
@@ -70,11 +71,19 @@ export function EsbMessageList({ source, queue, permissions }: EsbMessageListPro
|
||||
const selectable = available.replay || available.move || available.delete
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.esbMessages(source.kind, name, offset),
|
||||
queryKey:
|
||||
source.kind === 'subscription'
|
||||
? [...queryKeys.esbMessages('destination', source.name, offset), source.triggerName]
|
||||
: queryKeys.esbMessages(source.kind, name, offset),
|
||||
queryFn: () =>
|
||||
source.kind === 'destination'
|
||||
? getEsbMessages(source.name, offset)
|
||||
: getEsbDeadLetters(source.triggerName, offset),
|
||||
source.kind === 'deadLetters'
|
||||
? getEsbDeadLetters(source.triggerName, offset)
|
||||
: getEsbMessages(
|
||||
source.name,
|
||||
offset,
|
||||
ESB_PAGE_SIZE,
|
||||
source.kind === 'subscription' ? source.triggerName : undefined
|
||||
),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
@@ -84,12 +93,16 @@ export function EsbMessageList({ source, queue, permissions }: EsbMessageListPro
|
||||
}
|
||||
const toggle = (messageId: string) =>
|
||||
setSelected((current) =>
|
||||
current.includes(messageId) ? current.filter((id) => id !== messageId) : [...current, messageId]
|
||||
current.includes(messageId)
|
||||
? current.filter((id) => id !== messageId)
|
||||
: [...current, messageId]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-qqq-id={`esb-message-list-${name}`}>
|
||||
{source.kind === 'deadLetters' && <EsbQueueActions queue={queue} permissions={permissions} />}
|
||||
{source.kind !== 'destination' && (
|
||||
<EsbQueueActions queue={queue} permissions={permissions} onDone={() => setSelected([])} />
|
||||
)}
|
||||
{selectable && (
|
||||
<EsbSelectionActions
|
||||
queue={queue}
|
||||
@@ -210,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">
|
||||
|
||||
@@ -17,12 +17,18 @@
|
||||
// Tests for the table Developer-view ESB section
|
||||
|
||||
import React from 'react'
|
||||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '@/mocks/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { axe } from 'jest-axe'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { processInit } from '@/lib/api/processes'
|
||||
|
||||
import { fulfillOrderEsb, orderEsb } from '@/mocks/fixtures/esb'
|
||||
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
|
||||
|
||||
import { fulfillOrderEsb, orderEsb, orderFulfillmentMessages } from '@/mocks/fixtures/esb'
|
||||
import { EsbSection } from './EsbSection'
|
||||
|
||||
/**
|
||||
@@ -47,6 +53,201 @@ function bodyRows(name: string): HTMLElement[] {
|
||||
}
|
||||
|
||||
describe('EsbSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(processInit).mockReset()
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'COMPLETE',
|
||||
processUUID: 'queue-run',
|
||||
values: { message: 'Done.' },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['subscription', false],
|
||||
['subscription', true],
|
||||
['subscription', null],
|
||||
['subscription', undefined],
|
||||
['deadLetter', false],
|
||||
['deadLetter', true],
|
||||
['deadLetter', null],
|
||||
['deadLetter', undefined],
|
||||
] as const)('uses %s paused=%s for broker queue controls', async (kind, paused) => {
|
||||
const user = userEvent.setup()
|
||||
const original = orderEsb.subscribers[kind === 'subscription' ? 1 : 0]
|
||||
const brokerName = 'serialized.custom.queue'
|
||||
const brokerQueue = { brokerName, messageCount: 9, ...(paused === undefined ? {} : { paused }) }
|
||||
const trigger = { ...original, [kind]: brokerQueue }
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<EsbSection
|
||||
data={{
|
||||
...orderEsb,
|
||||
publications: [],
|
||||
subscribers: [trigger],
|
||||
permissions: { canOperate: true, canDelete: true },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
if (kind === 'deadLetter') {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: `Browse dead letters for ${trigger.processLabel}` })
|
||||
)
|
||||
await screen.findByRole('dialog')
|
||||
}
|
||||
if (paused == null) {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `Pause queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `Resume queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
} else {
|
||||
const action = paused ? 'Resume' : 'Pause'
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `${paused ? 'Pause' : 'Resume'} queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: `${action} queue ${brokerName}` }))
|
||||
await waitFor(() =>
|
||||
expect(processInit).toHaveBeenCalledWith(paused ? 'esbResumeQueue' : 'esbPauseQueue', {
|
||||
values: { providerName: 'artemis', brokerQueueName: brokerName },
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['subscription', 'permission'],
|
||||
['subscription', 'capability'],
|
||||
['deadLetter', 'permission'],
|
||||
['deadLetter', 'capability'],
|
||||
] as const)(
|
||||
'hides %s pause/resume without %s even when state is known',
|
||||
async (kind, missing) => {
|
||||
const user = userEvent.setup()
|
||||
const original = orderEsb.subscribers[kind === 'subscription' ? 1 : 0]
|
||||
const brokerName = 'restricted.queue'
|
||||
const trigger = {
|
||||
...original,
|
||||
[kind]: { brokerName, messageCount: 9, paused: false },
|
||||
destination: {
|
||||
...original.destination,
|
||||
capabilities: {
|
||||
...original.destination.capabilities,
|
||||
pauseQueue: missing !== 'capability',
|
||||
},
|
||||
},
|
||||
}
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<EsbSection
|
||||
data={{
|
||||
...orderEsb,
|
||||
publications: [],
|
||||
subscribers: [trigger],
|
||||
permissions: { canOperate: missing !== 'permission', canDelete: true },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
if (kind === 'deadLetter') {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: `Browse dead letters for ${trigger.processLabel}` })
|
||||
)
|
||||
await screen.findByRole('dialog')
|
||||
}
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `Pause queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: `Resume queue ${brokerName}` })
|
||||
).not.toBeInTheDocument()
|
||||
expect(processInit).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('uses the serialized subscription name for controls and trigger for browsing', async () => {
|
||||
let requestedTrigger: string | null = null
|
||||
server.use(
|
||||
http.get('/qqq/v1/esb/messages/orderEvents', ({ request }) => {
|
||||
requestedTrigger = new URL(request.url).searchParams.get('trigger')
|
||||
return HttpResponse.json(orderFulfillmentMessages)
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<EsbSection
|
||||
data={{
|
||||
...orderEsb,
|
||||
subscribers: [
|
||||
{
|
||||
...orderEsb.subscribers[1],
|
||||
subscription: { brokerName: 'custom.subscription.queue', messageCount: 9 },
|
||||
},
|
||||
],
|
||||
permissions: { canOperate: true, canDelete: true },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.getByText('custom.subscription.queue')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Purge custom.subscription.queue' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Pause queue custom.subscription.queue' })
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Browse subscription for Cancel Order' }))
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'Messages in custom.subscription.queue',
|
||||
})
|
||||
await within(dialog).findByRole('table', { name: 'Messages' })
|
||||
await waitFor(() => expect(requestedTrigger).toBe('cancelOrder.orderEvents'))
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: 'Replay selected' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides browsing and broker controls when the trigger destination has no capabilities', () => {
|
||||
const trigger = orderEsb.subscribers[1]
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<EsbSection
|
||||
data={{
|
||||
...orderEsb,
|
||||
publications: [],
|
||||
subscribers: [
|
||||
{
|
||||
...trigger,
|
||||
destination: {
|
||||
...trigger.destination,
|
||||
capabilities: {
|
||||
browse: false,
|
||||
pauseQueue: false,
|
||||
purge: false,
|
||||
deleteSelected: false,
|
||||
deleteOlderThan: false,
|
||||
move: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
permissions: { canOperate: true, canDelete: true },
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /Browse/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Purge/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when the table has no ESB section', () => {
|
||||
const { container } = render(<EsbSection data={null} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
@@ -88,7 +289,7 @@ describe('EsbSection', () => {
|
||||
const fulfillCells = within(fulfill).getAllByRole('cell')
|
||||
expect(within(fulfillCells[0]).getByRole('link', { name: 'Fulfill Order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/fulfillOrder'
|
||||
'/app/fulfillOrder/dev'
|
||||
)
|
||||
expect(fulfillCells[0]).toHaveTextContent('Single, concurrency 2, 3 attempts')
|
||||
expect(fulfillCells[1]).toHaveTextContent('orderFulfillment')
|
||||
@@ -107,7 +308,7 @@ describe('EsbSection', () => {
|
||||
const cancelCells = within(cancel).getAllByRole('cell')
|
||||
expect(within(cancelCells[0]).getByRole('link', { name: 'Cancel Order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/cancelOrder'
|
||||
'/app/cancelOrder/dev'
|
||||
)
|
||||
expect(cancelCells[1]).toHaveTextContent('orderEvents')
|
||||
expect(cancelCells[2]).toHaveTextContent('Paused')
|
||||
@@ -124,11 +325,13 @@ describe('EsbSection', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers browsing a queue and a trigger\'s dead letters to anyone who sees them', () => {
|
||||
it("offers browsing a queue and a trigger's dead letters to anyone who sees them", () => {
|
||||
render(<EsbSection data={orderEsb} />)
|
||||
const [topic, queue] = bodyRows('Publications')
|
||||
expect(within(topic).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(within(queue).getByRole('button', { name: 'Browse orderFulfillment' })).toBeInTheDocument()
|
||||
expect(
|
||||
within(queue).getByRole('button', { name: 'Browse orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
expect(within(queue).queryByRole('button', { name: /^Purge/ })).not.toBeInTheDocument()
|
||||
const [fulfill] = bodyRows('Subscribers')
|
||||
expect(
|
||||
@@ -145,7 +348,9 @@ describe('EsbSection', () => {
|
||||
)
|
||||
const [topic, queue] = bodyRows('Publications')
|
||||
expect(within(topic).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(within(queue).getByRole('button', { name: 'Purge orderFulfillment' })).toBeInTheDocument()
|
||||
expect(
|
||||
within(queue).getByRole('button', { name: 'Purge orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(queue).getByRole('button', { name: 'Pause queue orderFulfillment' })
|
||||
).toBeInTheDocument()
|
||||
@@ -154,7 +359,7 @@ describe('EsbSection', () => {
|
||||
expect(within(cancel).getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a process\'s publications and the triggers that start it', () => {
|
||||
it("renders a process's publications and the triggers that start it", () => {
|
||||
render(<EsbSection data={fulfillOrderEsb} />)
|
||||
expect(bodyRows('Publications')).toHaveLength(1)
|
||||
expect(bodyRows('Triggers')).toHaveLength(1)
|
||||
@@ -163,7 +368,9 @@ describe('EsbSection', () => {
|
||||
|
||||
it('says so when a process has no publications or triggers', () => {
|
||||
render(<EsbSection data={{ ...fulfillOrderEsb, publications: [], triggers: [] }} />)
|
||||
expect(screen.getByText('This process does not publish to any destination.')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('This process does not publish to any destination.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('No destinations trigger this process.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import Link from 'next/link'
|
||||
import type { EsbPermissions, EsbTrigger, EsbTriggerMode, EsbTriggerState } from '@/types'
|
||||
import { CHIP_COLOR_CLASSES, type ChipColor } from '@/lib/utils/adornment-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { EsbTriggerActions, deadLetterQueue } from './EsbActions'
|
||||
import { EsbTriggerActions, EsbQueueActions, deadLetterQueue, type EsbQueueRef } from './EsbActions'
|
||||
import { EsbCounters } from './EsbCounters'
|
||||
import { EsbBrowseButton } from './EsbMessageList'
|
||||
|
||||
@@ -59,6 +59,15 @@ export function EsbTriggerRow({
|
||||
trigger: EsbTrigger
|
||||
permissions: EsbPermissions
|
||||
}) {
|
||||
const subscription: EsbQueueRef | null = trigger.subscription
|
||||
? {
|
||||
providerName: trigger.destination.provider,
|
||||
brokerQueueName: trigger.subscription.brokerName,
|
||||
capabilities: trigger.destination.capabilities,
|
||||
messageCount: trigger.subscription.messageCount,
|
||||
paused: trigger.subscription.paused ?? null,
|
||||
}
|
||||
: null
|
||||
const state = STATES[trigger.state] ?? { label: trigger.state, color: 'default' }
|
||||
const attempts = `${trigger.maxAttempts} ${trigger.maxAttempts === 1 ? 'attempt' : 'attempts'}`
|
||||
|
||||
@@ -69,7 +78,7 @@ export function EsbTriggerRow({
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
<Link
|
||||
href={`/app/${encodeURIComponent(trigger.processName)}`}
|
||||
href={`/app/${encodeURIComponent(trigger.processName)}/dev`}
|
||||
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{trigger.processLabel}
|
||||
@@ -78,7 +87,11 @@ export function EsbTriggerRow({
|
||||
{MODES[trigger.mode] ?? trigger.mode}, concurrency {trigger.concurrency}, {attempts}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-2 py-2 font-mono text-xs text-foreground">{trigger.destination.name}</td>
|
||||
<td className="px-2 py-2 font-mono text-xs text-foreground">
|
||||
{trigger.destination.name}
|
||||
{subscription && <p className="text-muted-foreground">{subscription.brokerQueueName}</p>}
|
||||
{subscription && <p>Subscription depth: {subscription.messageCount ?? '—'}</p>}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -97,14 +110,35 @@ export function EsbTriggerRow({
|
||||
</td>
|
||||
<td className="space-y-1 px-2 py-2">
|
||||
<EsbTriggerActions trigger={trigger} permissions={permissions} />
|
||||
<EsbBrowseButton
|
||||
label="Browse dead letters"
|
||||
ariaLabel={`Browse dead letters for ${trigger.processLabel}`}
|
||||
title={`Dead letters for ${trigger.processLabel}`}
|
||||
source={{ kind: 'deadLetters', triggerName: trigger.name }}
|
||||
queue={deadLetterQueue(trigger)}
|
||||
permissions={permissions}
|
||||
/>
|
||||
{subscription && (
|
||||
<>
|
||||
{subscription.capabilities.browse && (
|
||||
<EsbBrowseButton
|
||||
label="Browse subscription"
|
||||
ariaLabel={`Browse subscription for ${trigger.processLabel}`}
|
||||
title={`Messages in ${subscription.brokerQueueName}`}
|
||||
source={{
|
||||
kind: 'subscription',
|
||||
name: trigger.destination.name,
|
||||
triggerName: trigger.name,
|
||||
}}
|
||||
queue={subscription}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
<EsbQueueActions queue={subscription} permissions={permissions} />
|
||||
</>
|
||||
)}
|
||||
{trigger.destination.capabilities.browse && (
|
||||
<EsbBrowseButton
|
||||
label="Browse dead letters"
|
||||
ariaLabel={`Browse dead letters for ${trigger.processLabel}`}
|
||||
title={`Dead letters for ${trigger.processLabel}`}
|
||||
source={{ kind: 'deadLetters', triggerName: trigger.name }}
|
||||
queue={deadLetterQueue(trigger)}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
@@ -111,6 +111,27 @@ describe('SearchDialog keyboard access', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not steal focus after the user reaches another dialog control before autofocus', async () => {
|
||||
let autofocus: FrameRequestCallback | undefined
|
||||
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
autofocus = callback
|
||||
return 42
|
||||
})
|
||||
try {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open search' }))
|
||||
await user.type(screen.getByRole('combobox'), 'no matches')
|
||||
await user.tab()
|
||||
const clear = screen.getByRole('button', { name: 'Clear search' })
|
||||
expect(clear).toHaveFocus()
|
||||
act(() => autofocus?.(0))
|
||||
expect(clear).toHaveFocus()
|
||||
} finally {
|
||||
request.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns focus after Escape from the input and can reopen', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness />)
|
||||
|
||||
@@ -81,7 +81,9 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
|
||||
setRecentRecords(getRecentRecords().slice(0, 10))
|
||||
setSearchTerm('')
|
||||
setSelectedIndex(-1)
|
||||
const frame = requestAnimationFrame(() => inputRef.current?.focus())
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
@@ -88,6 +88,25 @@ beforeEach(() => {
|
||||
mockFetchStandalone.mockResolvedValue(OPTIONS)
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — pointer selection', () => {
|
||||
it('keeps search focus while pressing an option until its click selects the value', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('combobox', { name: 'Person' }))
|
||||
const search = screen.getByRole('textbox', { name: 'Search Person options' })
|
||||
await waitFor(() => expect(search).toHaveFocus())
|
||||
const option = await screen.findByRole('option', { name: 'Bob' })
|
||||
|
||||
await user.pointer({ target: option, keys: '[MouseLeft>]' })
|
||||
expect(search).toHaveFocus()
|
||||
await user.pointer({ target: option, keys: '[/MouseLeft]' })
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('Bob')
|
||||
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveFocus()
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — initial value label', () => {
|
||||
it('looks up and shows the label of a value it did not select (e.g. after process Back)', async () => {
|
||||
mockFetchProcess.mockResolvedValue([{ id: 2, label: 'Bob' }])
|
||||
@@ -397,6 +416,38 @@ describe('PossibleValueSelect — option selection', () => {
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — search', () => {
|
||||
it.each(['loaded', 'in flight'])('offers only the current search results when the prior request is %s', async (prior) => {
|
||||
const user = userEvent.setup()
|
||||
let finishPrior!: (values: QPossibleValue[]) => void
|
||||
let finishSearch!: (values: QPossibleValue[]) => void
|
||||
mockFetchTable.mockReset()
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { finishPrior = resolve }))
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { finishSearch = resolve }))
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('combobox', { name: 'Person' }))
|
||||
const search = screen.getByRole('textbox', { name: 'Search Person options' })
|
||||
if (prior === 'loaded') await act(async () => finishPrior(OPTIONS))
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
fireEvent.change(search, { target: { value: 'Bo' } })
|
||||
if (prior === 'in flight') await act(async () => finishPrior(OPTIONS))
|
||||
// Old options must not be clickable while a new search is debouncing:
|
||||
// starting its request would remove the option between pointer down/up.
|
||||
expect(screen.queryAllByRole('option')).toEqual([])
|
||||
fireEvent.keyDown(search, { key: 'Enter' })
|
||||
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('-- Select Person --')
|
||||
await act(async () => vi.advanceTimersByTimeAsync(300))
|
||||
expect(mockFetchTable).toHaveBeenLastCalledWith('person', 'person', { searchTerm: 'Bo', formValues: { testField: null } })
|
||||
await act(async () => finishSearch([OPTIONS[1]]))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Bob' }))
|
||||
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('Bob')
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a search input when the dropdown is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
@@ -204,6 +204,10 @@ export function PossibleValueSelect({
|
||||
const debouncedFetch = useCallback(
|
||||
(term: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
// Old results must not remain selectable until the debounce removes them mid-click.
|
||||
requestSequence.current += 1
|
||||
setOptions([])
|
||||
setIsLoading(true)
|
||||
debounceRef.current = setTimeout(() => fetchOptions(term), 300)
|
||||
},
|
||||
[fetchOptions]
|
||||
@@ -480,6 +484,9 @@ export function PossibleValueSelect({
|
||||
id={`${id}-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
// Keep search focus until click: WebKit can scroll on blur
|
||||
// between mousedown and mouseup, moving the option away.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-between px-3 py-2 text-sm',
|
||||
|
||||
@@ -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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ describe('BulkLoadMapping', () => {
|
||||
mapping.requiredFields[1].defaultValue = 'all@example.invalid'
|
||||
mapping.requiredFields[0].columnIndex = 0
|
||||
expect(mapping.toProfile().profile.fieldList).toEqual([
|
||||
{ fieldName: 'firstName', columnIndex: 0, headerName: null, doValueMapping: false, clearIfEmpty: false },
|
||||
{ fieldName: 'firstName', columnIndex: 0, doValueMapping: false, clearIfEmpty: false },
|
||||
{ fieldName: 'email', defaultValue: 'all@example.invalid' },
|
||||
])
|
||||
})
|
||||
@@ -133,6 +133,21 @@ describe('BulkLoadMapping header row, key fields and repeats (#726)', () => {
|
||||
expect(mapping.requiredFields[0]).toMatchObject({ columnIndex: 0, headerName: null })
|
||||
})
|
||||
|
||||
it('omits the header name from a header-off profile and keeps the column index (QRun-IO/qqq#660)', () => {
|
||||
const mapping = new BulkLoadMapping(person)
|
||||
mapping.changeHasHeaderRow(false, file)
|
||||
mapping.requiredFields[0].columnIndex = 0
|
||||
mapping.requiredFields[1].columnIndex = 1
|
||||
const { haveErrors, profile } = mapping.toProfile()
|
||||
expect(haveErrors).toBe(false)
|
||||
for (const profileField of profile.fieldList) expect(profileField).not.toHaveProperty('headerName')
|
||||
const sent: unknown = JSON.parse(String(profileSubmitValues(mapping, profile).fieldListJSON))
|
||||
expect(sent).toStrictEqual([
|
||||
{ fieldName: 'firstName', columnIndex: 0, doValueMapping: false, clearIfEmpty: false },
|
||||
{ fieldName: 'email', columnIndex: 1, doValueMapping: false, clearIfEmpty: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a saved profile by header name in a file whose columns moved', () => {
|
||||
const moved = new FileDescription(['Email', 'First Name'], ['A', 'B'], [[], []])
|
||||
const mapping = BulkLoadMapping.fromProfile(person, {
|
||||
|
||||
@@ -366,8 +366,9 @@ export class BulkLoadMapping {
|
||||
haveErrors = true
|
||||
field.error = 'You must select a column.'
|
||||
} else {
|
||||
// Header-off files have no header name; published backends throw on `headerName: null`.
|
||||
const profileField: BulkLoadProfileField = {
|
||||
fieldName, columnIndex: field.columnIndex, headerName: field.headerName, doValueMapping: field.doValueMapping, clearIfEmpty: field.clearIfEmpty,
|
||||
fieldName, columnIndex: field.columnIndex, ...(field.headerName === null ? {} : { headerName: field.headerName }), doValueMapping: field.doValueMapping, clearIfEmpty: field.clearIfEmpty,
|
||||
}
|
||||
if (this.valueMappings[fieldName]) profileField.valueMappings = this.valueMappings[fieldName]
|
||||
profile.fieldList.push(profileField)
|
||||
|
||||
@@ -72,12 +72,19 @@ const itemClass = cn(
|
||||
* @returns The menu.
|
||||
*/
|
||||
export function ColumnHeaderMenu({ column, sort, pinned, hasRows, onSort, onFilter, onHide, onPin, onCopyPageValues, onCopyFullQueryValues, onColumnStats }: ColumnHeaderMenuProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
||||
const noRows = hasRows ? undefined : 'There are no rows to copy from'
|
||||
const id = (action: string) => `column-menu-${action}`
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root modal={false}>
|
||||
<DropdownMenuPrimitive.Root modal={false} open={open} onOpenChange={(nextOpen) => {
|
||||
// Focus survives scrolling; restore the reference before Radix measures its menu.
|
||||
if (nextOpen) triggerRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
setOpen(nextOpen)
|
||||
}}>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-60 hover:text-foreground hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring group-hover:opacity-100 data-[state=open]:opacity-100 pointer-coarse:opacity-100"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -16,18 +16,19 @@
|
||||
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QRecord, QTableMetaData } from '@/types'
|
||||
import { DataGrid } from './DataGrid'
|
||||
|
||||
const cellRenders = vi.fn()
|
||||
vi.mock('./DataCell', () => ({
|
||||
DataCell: ({ value }: { value: unknown }) => {
|
||||
vi.mock('./DataCell', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./DataCell')>()
|
||||
return { DataCell: (props: React.ComponentProps<typeof actual.DataCell>) => {
|
||||
cellRenders()
|
||||
return <span>{String(value ?? '')}</span>
|
||||
},
|
||||
}))
|
||||
return <actual.DataCell {...props} />
|
||||
} }
|
||||
})
|
||||
const router = { push: vi.fn() }
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => router }))
|
||||
|
||||
@@ -137,3 +138,89 @@ describe('DataGrid compact headers (QRun-IO/qqq#728)', () => {
|
||||
expect(screen.getByRole('button', { name: 'Column statistics for Name' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('DataGrid stable headers (QRun-IO/qqq#994)', () => {
|
||||
const scrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
|
||||
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
|
||||
afterAll(() => {
|
||||
if (scrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', scrollIntoView)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
||||
})
|
||||
const menu = () => ({ onHide: vi.fn(), onPin: vi.fn(), onCopyPageValues: vi.fn(), onCopyFullQueryValues: vi.fn() })
|
||||
|
||||
it('retains a focused menu trigger across nonstructural preview updates', () => {
|
||||
const { rerender } = render(grid({ columnMenu: menu() }))
|
||||
const trigger = screen.getByRole('button', { name: 'Name column menu' })
|
||||
trigger.focus()
|
||||
rerender(grid({ columnMenu: menu(), isFetching: true }))
|
||||
expect(screen.getByRole('button', { name: 'Name column menu' })).toBe(trigger)
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps the same header while updating metadata, sort and menu callbacks', () => {
|
||||
const initialMenu = menu()
|
||||
const updatedMenu = menu()
|
||||
const onSortChange = vi.fn()
|
||||
const { rerender } = render(grid({ columnMenu: initialMenu }))
|
||||
const trigger = screen.getByRole('button', { name: 'Name column menu' })
|
||||
const tableMetaData = { ...TABLE, fields: { ...TABLE.fields, name: { ...TABLE.fields.name, label: 'Current name' } } }
|
||||
rerender(grid({ tableMetaData, columnMenu: updatedMenu, sortOrder: [{ fieldName: 'name', isAscending: true }], onSortChange }))
|
||||
expect(screen.getByRole('button', { name: 'Current name column menu' })).toBe(trigger)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort by Current name' }))
|
||||
expect(onSortChange).toHaveBeenCalledWith([{ fieldName: 'name', isAscending: false }])
|
||||
fireEvent.keyDown(trigger, { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy page values' }))
|
||||
expect(updatedMenu.onCopyPageValues).toHaveBeenCalledWith('name')
|
||||
expect(initialMenu.onCopyPageValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the selection header and updates its current selection handler', () => {
|
||||
const onRowSelectionChange = vi.fn()
|
||||
const { rerender } = render(grid())
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'Select all rows on this page' })
|
||||
checkbox.focus()
|
||||
rerender(grid({ rowSelection: { 1: true, 2: true, 3: true }, onRowSelectionChange }))
|
||||
expect(screen.getByRole('checkbox', { name: 'Select all rows on this page' })).toBe(checkbox)
|
||||
expect(checkbox).toHaveFocus()
|
||||
expect(checkbox).toBeChecked()
|
||||
fireEvent.click(checkbox)
|
||||
expect(onRowSelectionChange).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('DataGrid stable cells (QRun-IO/qqq#995)', () => {
|
||||
const menu = () => ({ onHide: vi.fn(), onPin: vi.fn(), onCopyPageValues: vi.fn(), onCopyFullQueryValues: vi.fn() })
|
||||
|
||||
it('retains the focused selection checkbox and uses current selection callbacks', () => {
|
||||
const initialHandler = vi.fn()
|
||||
const currentHandler = vi.fn()
|
||||
const { rerender } = render(grid({ columnMenu: menu(), onRowSelectionChange: initialHandler }))
|
||||
const checkbox = screen.getAllByRole('checkbox', { name: 'Select record' })[0]
|
||||
checkbox.focus()
|
||||
rerender(grid({ columnMenu: menu(), rowSelection: { 1: true }, onRowSelectionChange: currentHandler }))
|
||||
expect(screen.getAllByRole('checkbox', { name: 'Select record' })[0]).toBe(checkbox)
|
||||
expect(checkbox).toHaveFocus()
|
||||
expect(checkbox).toBeChecked()
|
||||
fireEvent.click(checkbox)
|
||||
expect(currentHandler).toHaveBeenCalledWith({})
|
||||
expect(initialHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains revealed cell state and focus while using updated field values', () => {
|
||||
const tableMetaData = { ...TABLE, fields: { ...TABLE.fields, name: { ...TABLE.fields.name, adornments: [{ type: 'REVEAL' }] } } } as QTableMetaData
|
||||
const { rerender } = render(grid({ tableMetaData, columnMenu: menu() }))
|
||||
const reveal = screen.getAllByRole('button', { name: 'Reveal name' })[0]
|
||||
reveal.focus()
|
||||
fireEvent.click(reveal)
|
||||
expect(reveal).toHaveAccessibleName('Hide name')
|
||||
expect(reveal).toHaveTextContent('Ada')
|
||||
rerender(grid({ tableMetaData, columnMenu: menu(), isFetching: true }))
|
||||
expect(screen.getAllByRole('button', { name: 'Hide name' })[0]).toBe(reveal)
|
||||
expect(reveal).toHaveFocus()
|
||||
rerender(grid({ tableMetaData, columnMenu: menu(), records: [record(1, 'Current value'), ...RECORDS.slice(1)] }))
|
||||
expect(reveal).toHaveTextContent('Current value')
|
||||
expect(reveal).toHaveAccessibleName('Hide name')
|
||||
})
|
||||
})
|
||||
+132
-115
@@ -26,7 +26,6 @@ import React, { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useSta
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type SortingState,
|
||||
@@ -44,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.
|
||||
@@ -233,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])
|
||||
|
||||
@@ -352,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)
|
||||
@@ -425,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}
|
||||
@@ -753,125 +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}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -998,7 +1011,11 @@ const GridRow = React.memo(function GridRow({ row, isSelected, rowClass, cellCla
|
||||
onCellKeyDown(e)
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{/* Local pure render callbacks must not become new component types when
|
||||
current grid props rebuild columns: retain focused and stateful children. */}
|
||||
{typeof cell.column.columnDef.cell === 'function'
|
||||
? cell.column.columnDef.cell(cell.getContext())
|
||||
: cell.column.columnDef.cell}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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'
|
||||
@@ -414,7 +417,6 @@ function RecordViewContent({
|
||||
}) {
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { preferences } = useUserPreferences()
|
||||
|
||||
// Persist tab and view mode in URL so back navigation restores state
|
||||
@@ -441,15 +443,16 @@ function RecordViewContent({
|
||||
* @param defaultValue - The "default" value; when matched the key is removed.
|
||||
*/
|
||||
const updateUrlParam = useCallback((key: string, value: string, defaultValue: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
const url = new URL(window.location.href)
|
||||
const params = url.searchParams
|
||||
if (value === defaultValue) {
|
||||
params.delete(key)
|
||||
} else {
|
||||
params.set(key, value)
|
||||
}
|
||||
const qs = params.toString()
|
||||
router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false })
|
||||
}, [searchParams, pathname, router])
|
||||
// Make URL state durable before another click, reload, or departure, without an RSC navigation.
|
||||
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Switches the active tab by updating the `tab` URL parameter.
|
||||
@@ -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'
|
||||
@@ -242,9 +243,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 +299,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 +342,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>
|
||||
))}
|
||||
@@ -437,7 +439,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 +463,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}
|
||||
|
||||
@@ -99,6 +99,42 @@ describe('script code completions', () => {
|
||||
expect(editor).toHaveValue('api.qu')
|
||||
})
|
||||
|
||||
it('keeps current suggestions when a delayed scroll event reports their existing position', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Editor initial={'const result = api.query("person");'} />)
|
||||
const editor = screen.getByRole('textbox', { name: 'Code' }) as HTMLTextAreaElement
|
||||
await user.click(editor)
|
||||
editor.scrollLeft = 4
|
||||
fireEvent.scroll(editor)
|
||||
editor.scrollLeft = 0
|
||||
fireEvent.change(editor, { target: { value: 'api.bu' } })
|
||||
editor.setSelectionRange(6, 6)
|
||||
await user.keyboard('{Control>} {/Control}{ArrowDown}')
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||
|
||||
fireEvent.scroll(editor)
|
||||
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||
expect(screen.getByRole('option', { name: /api.bulkUpdate/ })).toHaveAttribute('aria-selected', 'true')
|
||||
await user.keyboard('{Tab}')
|
||||
expect(editor).toHaveValue('api.bulkUpdate(')
|
||||
expect(editor).toHaveFocus()
|
||||
})
|
||||
|
||||
it.each(['scrollLeft', 'scrollTop'] as const)('dismisses suggestions when %s changes after they open', async (direction) => {
|
||||
const user = userEvent.setup()
|
||||
render(<Editor />)
|
||||
const editor = screen.getByRole('textbox', { name: 'Code' }) as HTMLTextAreaElement
|
||||
await user.type(editor, 'api.bu')
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||
|
||||
editor[direction] = 4
|
||||
fireEvent.scroll(editor)
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(editor).toHaveValue('api.bu')
|
||||
})
|
||||
|
||||
it.each([[0, 0], [0, 6]])('rejects a stale suggestion after selection changes to %s–%s', async (start, end) => {
|
||||
const user = userEvent.setup()
|
||||
render(<Editor />)
|
||||
|
||||
@@ -174,7 +174,7 @@ export function CodeEditor({
|
||||
}: CodeEditorProps) {
|
||||
const movesFocus = useSyncExternalStore(subscribeTabMode, readTabMode, readServerTabMode)
|
||||
const [released, setReleased] = useState(false)
|
||||
const [completion, setCompletion] = useState<(CodeCompletionMatch & { selected: number; top: number; left: number; height: number; caret: number; source: string }) | null>(null)
|
||||
const [completion, setCompletion] = useState<(CodeCompletionMatch & { selected: number; top: number; left: number; height: number; caret: number; source: string; scrollTop: number; scrollLeft: number }) | null>(null)
|
||||
const composing = useRef(false)
|
||||
const metricsRef = useRef<HTMLSpanElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
@@ -211,7 +211,7 @@ export function CodeEditor({
|
||||
const top = caretTop + lineHeight + height <= textarea.clientHeight ? caretTop + lineHeight : Math.max(0, caretTop - height)
|
||||
const column = before.at(-1)!.replace(/\t/g, ' ').length
|
||||
const left = Math.min(Math.max(0, 12 + column * characterWidth - textarea.scrollLeft), Math.max(0, textarea.clientWidth - 320))
|
||||
setCompletion({ ...match, selected: 0, top, left, height, caret: textarea.selectionStart, source: textarea.value })
|
||||
setCompletion({ ...match, selected: 0, top, left, height, caret: textarea.selectionStart, source: textarea.value, scrollTop: textarea.scrollTop, scrollLeft: textarea.scrollLeft })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,7 +382,11 @@ export function CodeEditor({
|
||||
value={value}
|
||||
onChange={(event) => { onChange?.(event.target.value); suggest(event.currentTarget) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={() => { syncScroll(); setCompletion(null) }}
|
||||
onScroll={(event) => {
|
||||
syncScroll()
|
||||
// A deferred browser event can report the position already used to place suggestions.
|
||||
if (shown && (shown.scrollTop !== event.currentTarget.scrollTop || shown.scrollLeft !== event.currentTarget.scrollLeft)) setCompletion(null)
|
||||
}}
|
||||
onClick={() => setCompletion(null)}
|
||||
onCompositionStart={() => { composing.current = true; setCompletion(null) }}
|
||||
onCompositionEnd={(event) => { composing.current = false; suggest(event.currentTarget) }}
|
||||
|
||||
@@ -32,6 +32,7 @@ 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'
|
||||
|
||||
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',
|
||||
@@ -202,7 +203,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,151 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file MarkdownView — renders a markdown cell's text as React elements (never as HTML):
|
||||
* headings, paragraphs, bullet and numbered lists, fenced code, and inline bold, italic, code
|
||||
* and http(s) links. Anything else is shown as text, so a notebook can never inject markup.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Renders inline markdown (bold, italic, code, links) as React nodes.
|
||||
*
|
||||
* @param text - One line or paragraph.
|
||||
* @param keyPrefix - Key prefix for the produced nodes.
|
||||
* @returns The nodes.
|
||||
*/
|
||||
export function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = []
|
||||
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\((https?:\/\/[^\s)]+)\))/g
|
||||
let last = 0
|
||||
let match: RegExpExecArray | null
|
||||
let index = 0
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > last) nodes.push(text.slice(last, match.index))
|
||||
const token = match[0]
|
||||
const key = `${keyPrefix}-${index++}`
|
||||
if (token.startsWith('`')) {
|
||||
nodes.push(
|
||||
<code key={key} className="rounded bg-muted px-1 font-mono text-[0.9em]">
|
||||
{token.slice(1, -1)}
|
||||
</code>
|
||||
)
|
||||
} else if (token.startsWith('**')) {
|
||||
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>)
|
||||
} else if (token.startsWith('*')) {
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>)
|
||||
} else {
|
||||
const label = token.slice(1, token.indexOf(']'))
|
||||
nodes.push(
|
||||
<a
|
||||
key={key}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
last = match.index + token.length
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last))
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a markdown document.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.source - The markdown text.
|
||||
* @returns The rendered blocks.
|
||||
*/
|
||||
export function MarkdownView({ source }: { source: string }) {
|
||||
const blocks: React.ReactNode[] = []
|
||||
const lines = source.split('\n')
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const key = `b${i}`
|
||||
if (line.trim().startsWith('```')) {
|
||||
const code: string[] = []
|
||||
i++
|
||||
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">
|
||||
{code.join('\n')}
|
||||
</pre>
|
||||
)
|
||||
continue
|
||||
}
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line)
|
||||
if (heading) {
|
||||
const level = heading[1].length
|
||||
const sizes = ['text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm', 'text-sm']
|
||||
const Tag = `h${Math.min(level + 1, 6)}` as 'h2'
|
||||
blocks.push(
|
||||
<Tag key={key} className={`${sizes[level - 1]} font-semibold`}>
|
||||
{renderInline(heading[2], key)}
|
||||
</Tag>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
|
||||
const ordered = /^\s*\d+\./.test(line)
|
||||
const items: React.ReactNode[] = []
|
||||
while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) {
|
||||
items.push(
|
||||
<li key={`${key}-${items.length}`}>
|
||||
{renderInline(lines[i].replace(/^\s*([-*]|\d+\.)\s+/, ''), `${key}-${items.length}`)}
|
||||
</li>
|
||||
)
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
ordered ? (
|
||||
<ol key={key} className="list-decimal pl-5">
|
||||
{items}
|
||||
</ol>
|
||||
) : (
|
||||
<ul key={key} className="list-disc pl-5">
|
||||
{items}
|
||||
</ul>
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const paragraph: string[] = []
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== '' &&
|
||||
!/^(#{1,6}\s|```|\s*([-*]|\d+\.)\s+)/.test(lines[i])
|
||||
)
|
||||
paragraph.push(lines[i++])
|
||||
blocks.push(<p key={key}>{renderInline(paragraph.join(' '), key)}</p>)
|
||||
}
|
||||
return <div className="space-y-2 text-sm">{blocks}</div>
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
/*
|
||||
* 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 NotebookEditor — one notebook: title, default backend, :parameters, ordered SQL and
|
||||
* markdown cells, save (each save is a version), publish to the shared library, duplicate,
|
||||
* delete, and the version history with restore. With no notebook id it edits an unsaved
|
||||
* scratch notebook that Save creates.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
SqlNotebook,
|
||||
SqlNotebookCell,
|
||||
SqlNotebookInput,
|
||||
SqlNotebookParameter,
|
||||
SqlWorkbenchCapabilities,
|
||||
} from '@/types'
|
||||
import {
|
||||
createSqlNotebook,
|
||||
deleteSqlNotebook,
|
||||
duplicateSqlNotebook,
|
||||
getSqlNotebook,
|
||||
listSqlNotebookVersions,
|
||||
listSqlSnapshots,
|
||||
publishSqlNotebook,
|
||||
restoreSqlNotebookVersion,
|
||||
saveSqlNotebook,
|
||||
} from '@/lib/api/sql-workbench'
|
||||
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 { MarkdownView } from './MarkdownView'
|
||||
import { SqlCell } from './SqlCell'
|
||||
|
||||
/** A request to add a cell (e.g. from history). */
|
||||
export interface InsertCellRequest {
|
||||
sql: string
|
||||
backendName: string
|
||||
nonce: number
|
||||
}
|
||||
|
||||
/** Props for {@link NotebookEditor}. */
|
||||
export interface NotebookEditorProps {
|
||||
notebookId: number | null
|
||||
capabilities: SqlWorkbenchCapabilities
|
||||
insertRequest: InsertCellRequest | null
|
||||
/** Called once the pending insert request has been applied, so the owner can clear it. */
|
||||
onInsertConsumed: () => void
|
||||
onSelect: (id: number | null) => void
|
||||
}
|
||||
|
||||
const PARAMETER_TYPES: NonNullable<SqlNotebookParameter['type']>[] = [
|
||||
'STRING',
|
||||
'INTEGER',
|
||||
'DECIMAL',
|
||||
'BOOLEAN',
|
||||
'DATE',
|
||||
'DATE_TIME',
|
||||
]
|
||||
|
||||
/**
|
||||
* Loads the notebook (when saved) and renders its editor.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The editor, or a loading / error state.
|
||||
*/
|
||||
export function NotebookEditor(props: NotebookEditorProps) {
|
||||
const { notebookId, capabilities } = props
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.sqlNotebook(notebookId ?? -1),
|
||||
queryFn: () => getSqlNotebook(notebookId!),
|
||||
enabled: notebookId !== null,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
if (notebookId !== null && isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
Loading notebook…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (notebookId !== null && (error || !data)) {
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
Could not open the notebook: {getErrorMessage(error, 'not found')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const notebook: SqlNotebook = data ?? {
|
||||
id: -1,
|
||||
title: 'Untitled notebook',
|
||||
description: null,
|
||||
visibility: 'PERSONAL',
|
||||
ownerUserId: '',
|
||||
ownerName: null,
|
||||
defaultBackendName: capabilities.backends[0]?.name ?? null,
|
||||
currentVersion: 0,
|
||||
canEdit: true,
|
||||
createDate: null,
|
||||
modifyDate: null,
|
||||
parameters: [],
|
||||
cells: [
|
||||
{ cellKey: newCellKey(), cellType: 'SQL', content: '', title: null, backendName: null },
|
||||
],
|
||||
}
|
||||
return (
|
||||
<NotebookDraft
|
||||
key={`${notebookId ?? 'new'}-${notebook.currentVersion}`}
|
||||
{...props}
|
||||
notebook={notebook}
|
||||
saved={notebookId !== null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The editable draft of a notebook.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.notebook - The loaded (or blank) notebook.
|
||||
* @param props.saved - Whether it exists on the server.
|
||||
* @returns The editor.
|
||||
*/
|
||||
function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; saved: boolean }) {
|
||||
const { notebook, saved, capabilities, insertRequest, onInsertConsumed, onSelect } = props
|
||||
const queryClient = useQueryClient()
|
||||
const [title, setTitle] = useState(notebook.title)
|
||||
const [defaultBackendName, setDefaultBackendName] = useState(
|
||||
notebook.defaultBackendName ?? capabilities.backends[0]?.name ?? ''
|
||||
)
|
||||
const [cells, setCells] = useState<SqlNotebookCell[]>(notebook.cells)
|
||||
const [parameters, setParameters] = useState<SqlNotebookParameter[]>(notebook.parameters ?? [])
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries((notebook.parameters ?? []).map((p) => [p.name, p.defaultValue ?? '']))
|
||||
)
|
||||
const [dirty, setDirty] = useState(!saved)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
const [previewing, setPreviewing] = useState<Record<string, boolean>>({})
|
||||
const canEdit = notebook.canEdit
|
||||
|
||||
// The request is parent state that outlives this draft (it remounts on save, restore,
|
||||
// duplicate and notebook switch), so apply each nonce once and hand it back to be cleared.
|
||||
const handledInsertNonce = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!insertRequest || handledInsertNonce.current === insertRequest.nonce) return
|
||||
handledInsertNonce.current = insertRequest.nonce
|
||||
setCells((current) => [
|
||||
...current,
|
||||
{
|
||||
cellKey: newCellKey(),
|
||||
cellType: 'SQL',
|
||||
content: insertRequest.sql,
|
||||
backendName: insertRequest.backendName,
|
||||
title: null,
|
||||
},
|
||||
])
|
||||
setDirty(true)
|
||||
onInsertConsumed()
|
||||
}, [insertRequest, onInsertConsumed])
|
||||
|
||||
const { data: snapshots = [] } = useQuery({
|
||||
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
|
||||
queryFn: () => listSqlSnapshots(notebook.id),
|
||||
enabled: saved,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const { data: versions = [] } = useQuery({
|
||||
queryKey: queryKeys.sqlNotebookVersions(notebook.id),
|
||||
queryFn: () => listSqlNotebookVersions(notebook.id),
|
||||
enabled: saved && showVersions,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const parameterNames = useMemo(() => {
|
||||
const names = parameters.map((p) => p.name)
|
||||
for (const cell of cells) {
|
||||
if (cell.cellType === 'SQL')
|
||||
for (const name of extractSqlParameters(cell.content))
|
||||
if (!names.includes(name)) names.push(name)
|
||||
}
|
||||
return names
|
||||
}, [cells, parameters])
|
||||
|
||||
const paramsFor = (sql: string) =>
|
||||
Object.fromEntries(
|
||||
extractSqlParameters(sql).map((name) => [
|
||||
name,
|
||||
coerceParameterValue(values[name], parameters.find((p) => p.name === name)?.type),
|
||||
])
|
||||
)
|
||||
|
||||
const updateCells = (next: SqlNotebookCell[]) => {
|
||||
setCells(next)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const setParameterType = (name: string, type: SqlNotebookParameter['type']) => {
|
||||
const existing = parameters.find((p) => p.name === name)
|
||||
setParameters(
|
||||
existing
|
||||
? parameters.map((p) => (p.name === name ? { ...p, type } : p))
|
||||
: [...parameters, { name, type, defaultValue: values[name] ?? null }]
|
||||
)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const input = (): SqlNotebookInput => ({
|
||||
title: title.trim() || 'Untitled notebook',
|
||||
defaultBackendName,
|
||||
parameters: parameterNames.map((name) => ({
|
||||
name,
|
||||
type: parameters.find((p) => p.name === name)?.type ?? 'STRING',
|
||||
defaultValue: values[name] ?? null,
|
||||
})),
|
||||
cells: cells.map((c, i) => ({ ...c, sortOrder: i })),
|
||||
})
|
||||
|
||||
const refreshLists = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: [...queryKeys.sqlWorkbench(), 'notebooks'] })
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = saved
|
||||
? await saveSqlNotebook(notebook.id, input())
|
||||
: await createSqlNotebook(input())
|
||||
queryClient.setQueryData(queryKeys.sqlNotebook(result.id), result)
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.sqlNotebookVersions(result.id) })
|
||||
refreshLists()
|
||||
setDirty(false)
|
||||
toast.success(`Saved version ${result.currentVersion}`)
|
||||
if (!saved) onSelect(result.id)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const act = async (
|
||||
action: () => Promise<SqlNotebook | void>,
|
||||
message: string,
|
||||
select?: (n: SqlNotebook | void) => number | null
|
||||
) => {
|
||||
try {
|
||||
const result = await action()
|
||||
if (result) queryClient.setQueryData(queryKeys.sqlNotebook(result.id), result)
|
||||
refreshLists()
|
||||
toast.success(message)
|
||||
if (select) onSelect(select(result))
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const move = (index: number, delta: number) => {
|
||||
const next = [...cells]
|
||||
const [cell] = next.splice(index, 1)
|
||||
next.splice(index + delta, 0, cell)
|
||||
updateCells(next)
|
||||
}
|
||||
|
||||
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded border px-2 py-1 text-base font-semibold"
|
||||
value={title}
|
||||
aria-label="Notebook title"
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
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>
|
||||
{!canEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shared by {notebook.ownerName ?? notebook.ownerUserId}. You can run it; duplicate it to
|
||||
keep changes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</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] })
|
||||
}
|
||||
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>
|
||||
))}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* 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 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.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import type { SqlExplainResponse, SqlPlanNode } from '@/types'
|
||||
import { planMax } from '@/lib/utils/sql-workbench'
|
||||
|
||||
/**
|
||||
* Formats a plan number.
|
||||
*
|
||||
* @param value - The number.
|
||||
* @returns Up to two decimals, or an empty string.
|
||||
*/
|
||||
function n(value: number | null): string {
|
||||
return value === null || value === undefined ? '' : Number(value.toFixed(2)).toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one node and its children.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.node - The node.
|
||||
* @param props.max - The plan's largest cost or time.
|
||||
* @param props.useActual - Scale bars by actual time.
|
||||
* @returns The node as a list item.
|
||||
*/
|
||||
function PlanNodeItem({
|
||||
node,
|
||||
max,
|
||||
useActual,
|
||||
}: {
|
||||
node: SqlPlanNode
|
||||
max: number
|
||||
useActual: boolean
|
||||
}) {
|
||||
const value = (useActual ? node.actualTimeMs : node.totalCost) ?? 0
|
||||
const width = max > 0 ? Math.max(2, Math.round((value / max) * 100)) : 0
|
||||
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>
|
||||
{node.detail && <span className="text-muted-foreground">{node.detail}</span>}
|
||||
{node.totalCost !== null && (
|
||||
<span className="font-mono text-xs">
|
||||
cost {n(node.startupCost)}..{n(node.totalCost)}
|
||||
</span>
|
||||
)}
|
||||
{node.planRows !== null && <span className="font-mono text-xs">rows {n(node.planRows)}</span>}
|
||||
{node.actualTimeMs !== null && (
|
||||
<span className="font-mono text-xs">
|
||||
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>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<li className="my-1">
|
||||
<details open>
|
||||
<summary className="cursor-pointer">{summary}</summary>
|
||||
{extras.length > 0 && (
|
||||
<dl className="ml-4 grid grid-cols-[auto_1fr] gap-x-2 text-xs text-muted-foreground">
|
||||
{extras.map(([k, v]) => (
|
||||
<React.Fragment key={k}>
|
||||
<dt>{k}</dt>
|
||||
<dd className="truncate font-mono">
|
||||
{typeof v === 'string' ? v : JSON.stringify(v)}
|
||||
</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
<ul className="ml-4 border-l pl-3">
|
||||
{node.children.map((child, i) => (
|
||||
<PlanNodeItem key={i} node={child} max={max} useActual={useActual} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</details>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an EXPLAIN response.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.explain - The response.
|
||||
* @returns The tree, the raw text, or the error.
|
||||
*/
|
||||
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">
|
||||
{explain.error ?? 'The plan could not be produced.'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const useActual = explain.analyze === true
|
||||
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">
|
||||
<span>
|
||||
{explain.analyze ? 'EXPLAIN ANALYZE (executed, rolled back)' : 'EXPLAIN (estimated)'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto rounded border px-2 py-0.5"
|
||||
onClick={() => setRaw(!raw)}
|
||||
data-qqq-id="button-sql-plan-raw"
|
||||
>
|
||||
{raw ? 'Tree' : 'Raw'}
|
||||
</button>
|
||||
</div>
|
||||
{raw ? (
|
||||
<pre className="max-h-96 overflow-auto rounded bg-muted p-2 font-mono text-xs">
|
||||
{explain.raw}
|
||||
</pre>
|
||||
) : (
|
||||
<ul aria-label="Query plan">
|
||||
<PlanNodeItem node={explain.plan} max={max} useActual={useActual} />
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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 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.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
import type { SqlColumn, SqlValue } from '@/types'
|
||||
import { numericColumnIndexes, toChartRows } from '@/lib/utils/sql-workbench'
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#06b6d4',
|
||||
'#8b5cf6',
|
||||
'#84cc16',
|
||||
'#ec4899',
|
||||
]
|
||||
|
||||
/**
|
||||
* Renders the chart builder.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.columns - Result columns.
|
||||
* @param props.rows - Rows to plot (the current page).
|
||||
* @returns The controls and the chart, or a hint when nothing is plottable.
|
||||
*/
|
||||
export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlValue[][] }) {
|
||||
const numeric = useMemo(() => numericColumnIndexes(columns, rows), [columns, rows])
|
||||
const firstCategory = columns.findIndex((_, i) => !numeric.includes(i))
|
||||
const [type, setType] = useState<'bar' | 'line' | 'pie'>('bar')
|
||||
const [xIndex, setXIndex] = useState(firstCategory >= 0 ? firstCategory : 0)
|
||||
const [yIndexes, setYIndexes] = useState<number[]>(
|
||||
numeric.filter((i) => i !== xIndex).slice(0, 1)
|
||||
)
|
||||
|
||||
if (numeric.length === 0 || rows.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Charts need at least one numeric column and one row.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const data = toChartRows(columns, rows, xIndex, yIndexes)
|
||||
const series = yIndexes.map((i) => columns[i].label)
|
||||
|
||||
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
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'bar' | 'line' | 'pie')}
|
||||
data-qqq-id="select-sql-chart-type"
|
||||
>
|
||||
<option value="bar">Bar</option>
|
||||
<option value="line">Line</option>
|
||||
<option value="pie">Pie</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
Category
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={xIndex}
|
||||
onChange={(e) => setXIndex(Number(e.target.value))}
|
||||
data-qqq-id="select-sql-chart-x"
|
||||
>
|
||||
{columns.map((c, i) => (
|
||||
<option key={i} value={i}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="inline-flex flex-wrap items-center gap-2">
|
||||
<legend className="sr-only">Values</legend>
|
||||
<span>Values</span>
|
||||
{numeric.map((i) => (
|
||||
<label key={i} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type={type === 'pie' ? 'radio' : 'checkbox'}
|
||||
name="sql-chart-y"
|
||||
checked={yIndexes.includes(i)}
|
||||
onChange={(e) =>
|
||||
setYIndexes(
|
||||
type === 'pie'
|
||||
? [i]
|
||||
: e.target.checked
|
||||
? [...yIndexes, i]
|
||||
: yIndexes.filter((y) => y !== i)
|
||||
)
|
||||
}
|
||||
data-qqq-id={`checkbox-sql-chart-y-${i}`}
|
||||
/>
|
||||
{columns[i].label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
className="h-72 w-full"
|
||||
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>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
) : type === 'line' ? (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="x" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s}
|
||||
type="monotone"
|
||||
dataKey={s}
|
||||
stroke={COLORS[i % COLORS.length]}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="x" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{series.map((s, i) => (
|
||||
<Bar key={s} dataKey={s} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* 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 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.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { SqlColumn, 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'
|
||||
|
||||
/** Props for {@link ResultGrid}. */
|
||||
export interface ResultGridProps {
|
||||
executionUuid: string
|
||||
columns: SqlColumn[]
|
||||
firstPage: SqlValue[][]
|
||||
rowCount: number
|
||||
pageSize: number
|
||||
truncated?: boolean
|
||||
truncatedReason?: 'ROWS' | 'BYTES' | null
|
||||
/** Hide paging/sort/export (pinned snapshots, which have no cached result). */
|
||||
staticRows?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one cell value by its column kind.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.value - The value.
|
||||
* @param props.column - Its column.
|
||||
* @returns The cell content.
|
||||
*/
|
||||
export function SqlValueCell({ value, column }: { value: SqlValue; column: SqlColumn }) {
|
||||
if (value === null) return <span className="italic text-muted-foreground">NULL</span>
|
||||
if (isBinaryValue(value)) {
|
||||
return (
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
title={`0x${value.hexPreview}${value.length > value.hexPreview.length / 2 ? '…' : ''}`}
|
||||
>
|
||||
{formatSqlValue(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
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">
|
||||
{prettyJson(value)}
|
||||
</pre>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
if (column.kind === 'boolean') return <span className="font-mono">{String(value)}</span>
|
||||
return (
|
||||
<span className={cn(column.kind === 'number' && 'font-mono tabular-nums')}>
|
||||
{formatSqlValue(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the grid.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The table with paging and export controls.
|
||||
*/
|
||||
export function ResultGrid(props: ResultGridProps) {
|
||||
const {
|
||||
executionUuid,
|
||||
columns,
|
||||
firstPage,
|
||||
rowCount,
|
||||
pageSize,
|
||||
truncated,
|
||||
truncatedReason,
|
||||
staticRows = false,
|
||||
} = props
|
||||
const [page, setPage] = useState(0)
|
||||
const [sort, setSort] = useState<{ column: number; dir: 'asc' | 'desc' } | null>(null)
|
||||
const pages = Math.max(1, Math.ceil(rowCount / pageSize))
|
||||
const needsFetch = !staticRows && (page > 0 || sort !== null)
|
||||
|
||||
const { data, isFetching, error } = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.sqlWorkbench(),
|
||||
'rows',
|
||||
executionUuid,
|
||||
page,
|
||||
pageSize,
|
||||
sort?.column ?? null,
|
||||
sort?.dir ?? null,
|
||||
],
|
||||
queryFn: () => getSqlRows(executionUuid, page * pageSize, pageSize, sort?.column, sort?.dir),
|
||||
enabled: needsFetch,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const rows = needsFetch ? (data?.rows ?? []) : firstPage
|
||||
|
||||
const toggleSort = (index: number) => {
|
||||
setPage(0)
|
||||
setSort((current) =>
|
||||
current?.column !== index
|
||||
? { column: index, dir: 'asc' }
|
||||
: current.dir === 'asc'
|
||||
? { column: index, dir: 'desc' }
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rowsToTsv(columns, rows))
|
||||
toast.success(`Copied ${rows.length} rows`)
|
||||
} catch {
|
||||
toast.error('Could not copy to the clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">The statement returned no rows.</p>
|
||||
}
|
||||
|
||||
const from = rowCount === 0 ? 0 : page * pageSize + 1
|
||||
const to = Math.min(rowCount, (page + 1) * pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[28rem] overflow-auto rounded-md border">
|
||||
<table
|
||||
className="w-full border-collapse text-sm"
|
||||
aria-label="Query result"
|
||||
aria-busy={isFetching}
|
||||
>
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
scope="col"
|
||||
className="whitespace-nowrap border-b px-2 py-1 text-left font-medium"
|
||||
aria-sort={
|
||||
sort?.column === index
|
||||
? sort.dir === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
>
|
||||
{staticRows ? (
|
||||
<span title={column.typeName}>{column.label}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
onClick={() => toggleSort(index)}
|
||||
title={`${column.typeName} — sort`}
|
||||
data-qqq-id={`button-sql-sort-${index}`}
|
||||
>
|
||||
{column.label}
|
||||
{sort?.column === index &&
|
||||
(sort.dir === 'asc' ? (
|
||||
<span aria-hidden>▲</span>
|
||||
) : (
|
||||
<span aria-hidden>▼</span>
|
||||
))}
|
||||
</button>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, r) => (
|
||||
<tr key={r} className="odd:bg-background even:bg-muted/30">
|
||||
{columns.map((column, c) => (
|
||||
<td
|
||||
key={c}
|
||||
className={cn(
|
||||
'border-b px-2 py-1 align-top',
|
||||
column.kind === 'number' && 'text-right'
|
||||
)}
|
||||
>
|
||||
<SqlValueCell value={row[c] ?? null} column={column} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" 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">
|
||||
<span>
|
||||
Rows {from}–{to} of {rowCount}
|
||||
{truncated && (
|
||||
<span className="ml-1 text-amber-600">
|
||||
(capped by the {truncatedReason === 'BYTES' ? 'size' : 'row'} limit)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!staticRows && pages > 1 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-0.5 disabled:opacity-50"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
data-qqq-id="button-sql-page-prev"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page + 1} of {pages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-0.5 disabled:opacity-50"
|
||||
disabled={page + 1 >= pages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
data-qqq-id="button-sql-page-next"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
|
||||
onClick={copy}
|
||||
data-qqq-id="button-sql-copy"
|
||||
>
|
||||
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>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/*
|
||||
* 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 SqlCell — one SQL cell of a notebook: backend and mode, the editor, Run / Cancel /
|
||||
* Explain / Format, and the result as a grid, a quick chart or a plan. Write mode runs the
|
||||
* two-step protocol: a preview (rolled-back dry run) opens {@link WriteConfirmDialog}, and only
|
||||
* its confirmation commits.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
SqlExecuteResponse,
|
||||
SqlExplainResponse,
|
||||
SqlNotebookCell,
|
||||
SqlResultSnapshot,
|
||||
SqlWorkbenchCapabilities,
|
||||
} from '@/types'
|
||||
import {
|
||||
cancelSqlExecution,
|
||||
createSqlSnapshot,
|
||||
executeSql,
|
||||
explainSql,
|
||||
getSqlSchema,
|
||||
} from '@/lib/api/sql-workbench'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { formatDuration, newCellKey } from '@/lib/utils/sql-workbench'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { PlanTree } from './PlanTree'
|
||||
import { QuickChart } from './QuickChart'
|
||||
import { ResultGrid } from './ResultGrid'
|
||||
import { SqlEditor } from './SqlEditor'
|
||||
import { WriteConfirmDialog } from './WriteConfirmDialog'
|
||||
|
||||
/** Props for {@link SqlCell}. */
|
||||
export interface SqlCellProps {
|
||||
cell: SqlNotebookCell
|
||||
index: number
|
||||
capabilities: SqlWorkbenchCapabilities
|
||||
defaultBackendName: string
|
||||
/** Parameter values for this run, by name (already typed). */
|
||||
paramsFor: (sql: string) => Record<string, string | number | boolean | null>
|
||||
notebookId: number | null
|
||||
canEdit: boolean
|
||||
snapshots: SqlResultSnapshot[]
|
||||
onChange: (cell: SqlNotebookCell) => void
|
||||
onSnapshotsChanged: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a SQL cell.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The cell.
|
||||
*/
|
||||
export function SqlCell(props: SqlCellProps) {
|
||||
const {
|
||||
cell,
|
||||
index,
|
||||
capabilities,
|
||||
defaultBackendName,
|
||||
paramsFor,
|
||||
notebookId,
|
||||
canEdit,
|
||||
snapshots,
|
||||
onChange,
|
||||
onSnapshotsChanged,
|
||||
} = props
|
||||
const queryClient = useQueryClient()
|
||||
const backendName = cell.backendName || defaultBackendName
|
||||
const backend = capabilities.backends.find((b) => b.name === backendName)
|
||||
const [writeMode, setWriteMode] = useState(false)
|
||||
const [runningUuid, setRunningUuid] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<SqlExecuteResponse | null>(null)
|
||||
const [explain, setExplain] = useState<SqlExplainResponse | null>(null)
|
||||
const [preview, setPreview] = useState<SqlExecuteResponse | null>(null)
|
||||
const [committing, setCommitting] = useState(false)
|
||||
const [requestError, setRequestError] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<'grid' | 'chart' | 'plan'>('grid')
|
||||
|
||||
const { data: schema } = useQuery({
|
||||
queryKey: queryKeys.sqlWorkbenchSchema(backendName),
|
||||
queryFn: () => getSqlSchema(backendName),
|
||||
enabled: !!backend,
|
||||
staleTime: 5 * 60_000,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const afterRun = () => queryClient.invalidateQueries({ queryKey: queryKeys.sqlHistory() })
|
||||
|
||||
const run = async () => {
|
||||
if (!backend || runningUuid || !cell.content.trim()) return
|
||||
const executionUuid = newCellKey()
|
||||
setRunningUuid(executionUuid)
|
||||
setRequestError(null)
|
||||
try {
|
||||
const response = await executeSql({
|
||||
backendName,
|
||||
sql: cell.content,
|
||||
params: paramsFor(cell.content),
|
||||
mode: writeMode ? 'WRITE' : 'READ_ONLY',
|
||||
notebookId: notebookId ?? undefined,
|
||||
cellKey: cell.cellKey,
|
||||
executionUuid,
|
||||
})
|
||||
if (response.status === 'PREVIEW') {
|
||||
setPreview(response)
|
||||
} else {
|
||||
setResult(response)
|
||||
setTab('grid')
|
||||
}
|
||||
} catch (error) {
|
||||
setRequestError(getErrorMessage(error))
|
||||
} finally {
|
||||
setRunningUuid(null)
|
||||
void afterRun()
|
||||
}
|
||||
}
|
||||
|
||||
const commit = async (acknowledgeRowGuard: boolean) => {
|
||||
if (!preview?.confirmToken) return
|
||||
setCommitting(true)
|
||||
try {
|
||||
const response = await executeSql({
|
||||
backendName,
|
||||
sql: cell.content,
|
||||
params: paramsFor(cell.content),
|
||||
mode: 'WRITE',
|
||||
notebookId: notebookId ?? undefined,
|
||||
cellKey: cell.cellKey,
|
||||
confirmToken: preview.confirmToken,
|
||||
acknowledgeRowGuard,
|
||||
})
|
||||
setResult(response)
|
||||
setTab('grid')
|
||||
if (response.status === 'SUCCEEDED')
|
||||
toast.success(`Committed: ${response.rowsAffected ?? 0} rows affected`)
|
||||
} catch (error) {
|
||||
setRequestError(getErrorMessage(error))
|
||||
} finally {
|
||||
setCommitting(false)
|
||||
setPreview(null)
|
||||
void afterRun()
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = async () => {
|
||||
if (!runningUuid) return
|
||||
try {
|
||||
await cancelSqlExecution(runningUuid)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const runExplain = async (analyze: boolean) => {
|
||||
if (!backend || !cell.content.trim()) return
|
||||
setRequestError(null)
|
||||
try {
|
||||
setExplain(
|
||||
await explainSql({
|
||||
backendName,
|
||||
sql: cell.content,
|
||||
params: paramsFor(cell.content),
|
||||
analyze,
|
||||
})
|
||||
)
|
||||
setTab('plan')
|
||||
} catch (error) {
|
||||
setRequestError(getErrorMessage(error))
|
||||
} finally {
|
||||
void afterRun()
|
||||
}
|
||||
}
|
||||
|
||||
const format = async () => {
|
||||
try {
|
||||
const { formatWorkbenchSql } = await import('@/lib/utils/sql-format')
|
||||
onChange({ ...cell, content: formatWorkbenchSql(cell.content, backend?.dialect) })
|
||||
} catch (error) {
|
||||
toast.error(`Could not format: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const pin = async () => {
|
||||
if (!notebookId || !result) return
|
||||
try {
|
||||
await createSqlSnapshot(notebookId, {
|
||||
executionUuid: result.executionUuid,
|
||||
cellKey: cell.cellKey,
|
||||
})
|
||||
toast.success('Result pinned')
|
||||
onSnapshotsChanged()
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-2 rounded-md border p-3"
|
||||
aria-label={`SQL cell ${index + 1}`}
|
||||
data-qqq-id={`section-sql-cell-${index}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded border-transparent bg-transparent px-1 py-0.5 text-sm font-medium hover:border-border focus:border-border"
|
||||
value={cell.title ?? ''}
|
||||
placeholder={`SQL cell ${index + 1}`}
|
||||
aria-label={`Title of SQL cell ${index + 1}`}
|
||||
onChange={(e) => onChange({ ...cell, title: e.target.value })}
|
||||
data-qqq-id={`input-sql-cell-title-${index}`}
|
||||
/>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
Backend
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={backendName}
|
||||
onChange={(e) => onChange({ ...cell, backendName: e.target.value })}
|
||||
data-qqq-id={`select-sql-backend-${index}`}
|
||||
>
|
||||
{capabilities.backends.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.label} ({b.dialect})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{capabilities.canWrite ? (
|
||||
<label
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded px-1.5 py-0.5',
|
||||
writeMode ? 'bg-destructive/15 text-destructive' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={writeMode}
|
||||
onChange={(e) => setWriteMode(e.target.checked)}
|
||||
data-qqq-id={`checkbox-sql-write-mode-${index}`}
|
||||
/>
|
||||
Write mode
|
||||
</label>
|
||||
) : (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5">Read-only</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SqlEditor
|
||||
value={cell.content}
|
||||
onChange={(content) => onChange({ ...cell, content })}
|
||||
dialect={backend?.dialect}
|
||||
schema={schema}
|
||||
onRun={run}
|
||||
label={`SQL for cell ${index + 1}`}
|
||||
qqqId={`editor-sql-cell-${index}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{runningUuid ? (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={cancel}
|
||||
data-qqq-id={`button-sql-cancel-${index}`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(button, 'bg-primary text-primary-foreground')}
|
||||
onClick={run}
|
||||
disabled={!backend}
|
||||
data-qqq-id={`button-sql-run-${index}`}
|
||||
>
|
||||
{writeMode ? 'Preview write' : 'Run'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => runExplain(false)}
|
||||
disabled={!backend || !!runningUuid}
|
||||
data-qqq-id={`button-sql-explain-${index}`}
|
||||
>
|
||||
Explain
|
||||
</button>
|
||||
{capabilities.canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => runExplain(true)}
|
||||
disabled={!backend || !!runningUuid}
|
||||
title="Executes the statement in a transaction that is rolled back"
|
||||
data-qqq-id={`button-sql-explain-analyze-${index}`}
|
||||
>
|
||||
Explain analyze
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={format}
|
||||
data-qqq-id={`button-sql-format-${index}`}
|
||||
>
|
||||
Format
|
||||
</button>
|
||||
{runningUuid && (
|
||||
<span className="text-xs text-muted-foreground" role="status">
|
||||
Running…
|
||||
</span>
|
||||
)}
|
||||
{backend && !backend.readOnlyEnforced && !writeMode && (
|
||||
<span className="text-xs text-amber-600">
|
||||
This backend does not enforce read-only sessions; only the statement check applies.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requestError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{requestError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<WriteConfirmDialog
|
||||
preview={preview}
|
||||
backendName={backendName}
|
||||
pending={committing}
|
||||
onConfirm={commit}
|
||||
onCancel={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(result || explain) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs" role="tablist" aria-label="Result views">
|
||||
{(['grid', 'chart', 'plan'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5',
|
||||
tab === t ? 'bg-muted font-medium' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => setTab(t)}
|
||||
data-qqq-id={`tab-sql-${t}-${index}`}
|
||||
>
|
||||
{t === 'grid' ? 'Grid' : t === 'chart' ? 'Chart' : 'Plan'}
|
||||
</button>
|
||||
))}
|
||||
{result && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{result.status}
|
||||
{result.durationMs != null && ` · ${formatDuration(result.durationMs)}`}
|
||||
{result.rowsAffected != null && ` · ${result.rowsAffected} rows affected`}
|
||||
</span>
|
||||
)}
|
||||
{result?.status === 'SUCCEEDED' &&
|
||||
notebookId &&
|
||||
canEdit &&
|
||||
(result.columns?.length ?? 0) > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(button, 'ml-auto')}
|
||||
onClick={pin}
|
||||
data-qqq-id={`button-sql-pin-${index}`}
|
||||
>
|
||||
Pin result
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{tab === 'grid' && result && (
|
||||
<ResultView
|
||||
key={result.executionUuid}
|
||||
result={result}
|
||||
pageSize={capabilities.limits.pageSize}
|
||||
/>
|
||||
)}
|
||||
{tab === 'chart' && result?.status === 'SUCCEEDED' && (
|
||||
<QuickChart
|
||||
key={result.executionUuid}
|
||||
columns={result.columns ?? []}
|
||||
rows={result.rows ?? []}
|
||||
/>
|
||||
)}
|
||||
{tab === 'plan' &&
|
||||
(explain ? (
|
||||
<PlanTree explain={explain} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Use Explain to see the plan.</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshots.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||
Pinned results ({snapshots.length})
|
||||
</summary>
|
||||
{snapshots.map((s) => (
|
||||
<div key={s.id} className="mt-2 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.label ?? 'Snapshot'} · {s.createDate ?? ''} · {s.rowCount} rows
|
||||
{s.truncated ? ' (truncated)' : ''}
|
||||
</p>
|
||||
<ResultGrid
|
||||
executionUuid={s.executionUuid ?? String(s.id)}
|
||||
columns={s.result.columns ?? []}
|
||||
firstPage={s.result.rows ?? []}
|
||||
rowCount={s.rowCount}
|
||||
pageSize={s.rowCount || 1}
|
||||
staticRows
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a run's outcome: the grid, or the failure, drift or rejection message.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.result - The run.
|
||||
* @param props.pageSize - Rows per page.
|
||||
* @returns The outcome.
|
||||
*/
|
||||
function ResultView({ result, pageSize }: { result: SqlExecuteResponse; pageSize: number }) {
|
||||
if (result.status === 'DRIFTED') {
|
||||
return (
|
||||
<p className="text-sm text-amber-700" role="alert">
|
||||
Nothing was committed: the affected rows changed since the preview (
|
||||
{result.previewAffectedRows} → {result.actualAffectedRows}). Preview again.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (result.status !== 'SUCCEEDED') {
|
||||
return (
|
||||
<p className="whitespace-pre-wrap text-sm text-destructive" role="alert">
|
||||
{result.status === 'TIMED_OUT'
|
||||
? 'Timed out: '
|
||||
: result.status === 'CANCELLED'
|
||||
? 'Cancelled: '
|
||||
: ''}
|
||||
{result.error ?? result.status}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ResultGrid
|
||||
executionUuid={result.executionUuid}
|
||||
columns={result.columns ?? []}
|
||||
firstPage={result.rows ?? []}
|
||||
rowCount={result.rowCount ?? 0}
|
||||
pageSize={pageSize}
|
||||
truncated={result.truncated}
|
||||
truncatedReason={result.truncatedReason}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 SqlEditor — CodeMirror 6 SQL editor for workbench cells: dialect-aware highlighting,
|
||||
* schema autocomplete (tables, columns, keywords) plus QQQ table/field names that insert their
|
||||
* SQL names, and Ctrl/Cmd+Enter to run.
|
||||
*
|
||||
* Keyboard (WCAG 2.1.2 No Keyboard Trap): Tab is not bound, so Tab and Shift+Tab always move
|
||||
* focus out of the editor; indentation uses the editor's indent commands (Ctrl+] / Ctrl+[).
|
||||
* Loaded only inside the lazily imported workbench widget, so CodeMirror stays out of the
|
||||
* initial bundle.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
type CompletionSource,
|
||||
} from '@codemirror/autocomplete'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { MySQL, PostgreSQL, SQLite, StandardSQL, sql, type SQLDialect } from '@codemirror/lang-sql'
|
||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { Compartment, EditorState } from '@codemirror/state'
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder } from '@codemirror/view'
|
||||
|
||||
import type { SqlSchema, SqlWorkbenchDialect } from '@/types'
|
||||
import { editorDialectName, editorSchema, qqqNameCompletions } from '@/lib/utils/sql-workbench'
|
||||
|
||||
const DIALECTS: Record<ReturnType<typeof editorDialectName>, SQLDialect> = {
|
||||
PostgreSQL,
|
||||
MySQL,
|
||||
SQLite,
|
||||
StandardSQL,
|
||||
}
|
||||
|
||||
/** Props for {@link SqlEditor}. */
|
||||
export interface SqlEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
dialect?: SqlWorkbenchDialect
|
||||
schema?: SqlSchema
|
||||
readOnly?: boolean
|
||||
/** Called on Ctrl/Cmd+Enter. */
|
||||
onRun?: () => void
|
||||
/** Accessible name of the editor. */
|
||||
label: string
|
||||
/** `data-qqq-id` of the editable content. */
|
||||
qqqId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A completion source offering QQQ table and field names that insert SQL names.
|
||||
*
|
||||
* @param schema - The backend schema, with its QQQ tables.
|
||||
* @returns The completion source.
|
||||
*/
|
||||
function qqqCompletionSource(schema: SqlSchema | undefined): CompletionSource {
|
||||
const options = qqqNameCompletions(schema)
|
||||
return (context) => {
|
||||
const word = context.matchBefore(/[\w.]+/)
|
||||
if (!word || (word.from === word.to && !context.explicit)) return null
|
||||
return { from: word.from, options, validFor: /^[\w.]*$/ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the language extension for a dialect and schema.
|
||||
*
|
||||
* @param dialect - Backend dialect.
|
||||
* @param schema - Backend schema.
|
||||
* @returns The extensions for the language compartment.
|
||||
*/
|
||||
function languageExtensions(
|
||||
dialect: SqlWorkbenchDialect | undefined,
|
||||
schema: SqlSchema | undefined
|
||||
) {
|
||||
const sqlDialect = DIALECTS[editorDialectName(dialect)]
|
||||
return [
|
||||
sql({ dialect: sqlDialect, schema: editorSchema(schema), upperCaseKeywords: true }),
|
||||
sqlDialect.language.data.of({ autocomplete: qqqCompletionSource(schema) }),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the editor.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The editor container.
|
||||
*/
|
||||
export function SqlEditor(props: SqlEditorProps) {
|
||||
const { value, onChange, dialect, schema, readOnly = false, onRun, label, qqqId } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const languageRef = useRef(new Compartment())
|
||||
const readOnlyRef = useRef(new Compartment())
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onRunRef = useRef(onRun)
|
||||
onChangeRef.current = onChange
|
||||
onRunRef.current = onRun
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion({ activateOnTyping: true }),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
placeholder('SELECT … (Ctrl/Cmd+Enter to run)'),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
run: () => {
|
||||
onRunRef.current?.()
|
||||
return true
|
||||
},
|
||||
},
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
]),
|
||||
languageRef.current.of(languageExtensions(dialect, schema)),
|
||||
readOnlyRef.current.of(EditorState.readOnly.of(readOnly)),
|
||||
EditorView.contentAttributes.of({ 'aria-label': label, 'data-qqq-id': qqqId }),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onChangeRef.current(update.state.doc.toString())
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
border: '1px solid var(--border, #d4d4d8)',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
'&.cm-focused': { outline: '2px solid var(--ring, #6366f1)', outlineOffset: '1px' },
|
||||
'.cm-content': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
minHeight: '5rem',
|
||||
},
|
||||
'.cm-gutters': { backgroundColor: 'transparent', border: 'none' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
viewRef.current = view
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
// The view is created once; later prop changes are applied by the effects below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: languageRef.current.reconfigure(languageExtensions(dialect, schema)),
|
||||
})
|
||||
}, [dialect, schema])
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: readOnlyRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
|
||||
})
|
||||
}, [readOnly])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return <div ref={hostRef} className="qqq-sql-workbench-editor min-w-0" />
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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 SQL Workbench building blocks: editor, markdown, plan tree, result grid, chart
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { axe } from 'jest-axe'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { sqlExplain, sqlSchema, sqlSelectResult } from '@/mocks/fixtures/sql-workbench'
|
||||
import { MarkdownView } from './MarkdownView'
|
||||
import { PlanTree } from './PlanTree'
|
||||
import { QuickChart } from './QuickChart'
|
||||
import { ResultGrid } from './ResultGrid'
|
||||
import { SqlEditor } from './SqlEditor'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
beforeAll(() => {
|
||||
// Recharts' ResponsiveContainer observes its size; jsdom has no ResizeObserver.
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders an element inside a fresh query client.
|
||||
*
|
||||
* @param ui - The element.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderWithClient(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('SqlEditor', () => {
|
||||
it('shows the value, reports edits, follows prop changes and runs on Mod-Enter', async () => {
|
||||
const onChange = vi.fn()
|
||||
const onRun = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SqlEditor
|
||||
value="SELECT 1"
|
||||
onChange={onChange}
|
||||
onRun={onRun}
|
||||
dialect="postgresql"
|
||||
schema={sqlSchema}
|
||||
label="SQL"
|
||||
qqqId="editor-test"
|
||||
/>
|
||||
)
|
||||
const content = screen.getByLabelText('SQL')
|
||||
expect(content).toHaveAttribute('data-qqq-id', 'editor-test')
|
||||
expect(content.textContent).toContain('SELECT 1')
|
||||
|
||||
rerender(
|
||||
<SqlEditor
|
||||
value="SELECT 2"
|
||||
onChange={onChange}
|
||||
onRun={onRun}
|
||||
dialect="mysql"
|
||||
label="SQL"
|
||||
qqqId="editor-test"
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(screen.getByLabelText('SQL').textContent).toContain('SELECT 2'))
|
||||
expect(onChange).toHaveBeenCalledWith('SELECT 2')
|
||||
|
||||
const user = userEvent.setup()
|
||||
rerender(
|
||||
<SqlEditor
|
||||
value="SELECT 2"
|
||||
onChange={onChange}
|
||||
onRun={onRun}
|
||||
label="SQL"
|
||||
qqqId="editor-test"
|
||||
/>
|
||||
)
|
||||
screen.getByLabelText('SQL').focus()
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(onRun).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownView', () => {
|
||||
it('renders blocks and inline markup as elements, never as HTML', () => {
|
||||
const { container } = render(
|
||||
<MarkdownView
|
||||
source={
|
||||
'# Title\n\nSome **bold**, *em*, `code` and [a link](https://example.com) <b>raw</b>\n\n- one\n- two\n\n1. first\n\n```\nSELECT 1\n```\n###### small'
|
||||
}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('bold').tagName).toBe('STRONG')
|
||||
expect(screen.getByText('em').tagName).toBe('EM')
|
||||
expect(screen.getByText('code').tagName).toBe('CODE')
|
||||
expect(screen.getByRole('link', { name: 'a link' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com'
|
||||
)
|
||||
expect(container.querySelector('b')).toBeNull()
|
||||
expect(screen.getByText(/<b>raw<\/b>/)).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('ul li')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('ol li')).toHaveLength(1)
|
||||
expect(container.querySelector('pre')?.textContent).toBe('SELECT 1')
|
||||
expect(screen.getByRole('heading', { name: 'small' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not link javascript: URLs', () => {
|
||||
const { container } = render(<MarkdownView source="[x](javascript:alert(1))" />)
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlanTree', () => {
|
||||
it('renders nodes with costs and toggles the raw plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<PlanTree explain={sqlExplain} />)
|
||||
const tree = screen.getByRole('list', { name: 'Query plan' })
|
||||
expect(within(tree).getByText('Sort')).toBeInTheDocument()
|
||||
expect(within(tree).getByText('on person')).toBeInTheDocument()
|
||||
expect(within(tree).getByText('cost 10..12.5')).toBeInTheDocument()
|
||||
expect(within(tree).getByText('Sort Key')).toBeInTheDocument()
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
await user.click(screen.getByRole('button', { name: 'Raw' }))
|
||||
expect(screen.queryByRole('list', { name: 'Query plan' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows analyze times and errors', () => {
|
||||
const plan = { ...sqlExplain.plan!, actualTimeMs: 1.5, actualRows: 10, loops: 1 }
|
||||
const { rerender } = render(<PlanTree explain={{ ...sqlExplain, analyze: true, plan }} />)
|
||||
expect(screen.getByText(/actual 1.5 ms/)).toBeInTheDocument()
|
||||
rerender(<PlanTree explain={{ executionUuid: 'x', status: 'FAILED', error: 'syntax error' }} />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('syntax error')
|
||||
rerender(<PlanTree explain={{ executionUuid: 'x', status: 'FAILED' }} />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('could not be produced')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ResultGrid', () => {
|
||||
it('sorts through the server cache and copies the page', async () => {
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
const { container } = renderWithClient(
|
||||
<ResultGrid
|
||||
executionUuid="exec-1"
|
||||
columns={sqlSelectResult.columns!}
|
||||
firstPage={sqlSelectResult.rows!}
|
||||
rowCount={2}
|
||||
pageSize={10}
|
||||
truncated
|
||||
truncatedReason="BYTES"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/capped by the size limit/)).toBeInTheDocument()
|
||||
expect(screen.getByText('{"k":1}')).toBeInTheDocument()
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Copy page/ }))
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('id\tfirst_name'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^id/ }))
|
||||
expect(await screen.findByText('Tim')).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute(
|
||||
'aria-sort',
|
||||
'ascending'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /^id/ }))
|
||||
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute(
|
||||
'aria-sort',
|
||||
'descending'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /^id/ }))
|
||||
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute('aria-sort', 'none')
|
||||
})
|
||||
|
||||
it('handles statements without rows, static snapshots and copy failures', async () => {
|
||||
const user = userEvent.setup()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('no')) },
|
||||
configurable: true,
|
||||
})
|
||||
const { rerender } = renderWithClient(
|
||||
<ResultGrid executionUuid="x" columns={[]} firstPage={[]} rowCount={0} pageSize={10} />
|
||||
)
|
||||
expect(screen.getByText(/returned no rows/)).toBeInTheDocument()
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ResultGrid
|
||||
executionUuid="x"
|
||||
columns={sqlSelectResult.columns!}
|
||||
firstPage={sqlSelectResult.rows!}
|
||||
rowCount={2}
|
||||
pageSize={2}
|
||||
staticRows
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: /CSV/ })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: /Copy page/ }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('QuickChart', () => {
|
||||
it('offers chart types and value columns, or explains why it cannot chart', async () => {
|
||||
const user = userEvent.setup()
|
||||
const columns = [
|
||||
{ name: 'm', label: 'month', typeName: 'text', jdbcType: 12, kind: 'string' as const },
|
||||
{ name: 'n', label: 'signups', typeName: 'int4', jdbcType: 4, kind: 'number' as const },
|
||||
]
|
||||
const { rerender } = render(
|
||||
<QuickChart
|
||||
columns={columns}
|
||||
rows={[
|
||||
['jan', 3],
|
||||
['feb', 5],
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('img', { name: /bar chart of signups by month/ })).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Chart'), 'line')
|
||||
expect(screen.getByRole('img', { name: /line chart/ })).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Chart'), 'pie')
|
||||
await user.click(screen.getByLabelText('signups'))
|
||||
expect(screen.getByRole('img', { name: /pie chart of signups/ })).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Category'), '1')
|
||||
rerender(<QuickChart columns={[columns[0]]} rows={[['jan']]} />)
|
||||
expect(screen.getByText(/need at least one numeric column/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* 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 SQL Workbench widget: notebooks, running, write confirmation, explain
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { axe } from 'jest-axe'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QWidgetMetaData, SqlExecuteRequest } from '@/types'
|
||||
import { server } from '@/mocks/node'
|
||||
import { sqlReadOnlyCapabilities } from '@/mocks/fixtures/sql-workbench'
|
||||
import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
|
||||
import SqlWorkbenchWidget from './SqlWorkbenchWidget'
|
||||
|
||||
vi.mock('./SqlEditor', () => ({
|
||||
SqlEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
onRun,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
label: string
|
||||
onRun?: () => void
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) onRun?.()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
beforeAll(() => {
|
||||
// Recharts' ResponsiveContainer observes its size; jsdom has no ResizeObserver.
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const BASE = '/qqq/v1/sql-workbench'
|
||||
const widgetMetaData = {
|
||||
name: 'sqlWorkbenchWidget',
|
||||
label: 'SQL Workbench',
|
||||
type: 'SQL_WORKBENCH',
|
||||
} as QWidgetMetaData
|
||||
|
||||
/**
|
||||
* Renders an element inside a fresh query client.
|
||||
*
|
||||
* @param ui - The element.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderWithClient(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/')
|
||||
})
|
||||
|
||||
describe('SqlWorkbenchWidget', () => {
|
||||
it('lists notebooks and opens one into the URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await user.click(await screen.findByRole('button', { name: /Monthly signups/ }))
|
||||
expect(await screen.findByDisplayValue('Monthly signups')).toBeInTheDocument()
|
||||
expect(window.location.search).toBe('?notebook=1')
|
||||
expect(screen.getByText('month')).toBeInTheDocument()
|
||||
expect(screen.getByText(':since')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'My notebooks' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Shared library' }))
|
||||
expect(screen.getByRole('button', { name: 'Shared library' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: /Orphan media/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a history entry as one cell, and not again on save or notebook switch', async () => {
|
||||
const user = userEvent.setup()
|
||||
window.history.replaceState(null, '', '/?notebook=1')
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await screen.findByDisplayValue('Monthly signups')
|
||||
const before = screen.getAllByLabelText(/^SQL for cell/).length
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /SELECT \* FROM person/ }))
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(before + 1))
|
||||
|
||||
// Save bumps the version, which remounts the draft: the request must not re-apply.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('button', { name: 'Saved' })).toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(before + 1)
|
||||
|
||||
// Switching notebooks must not inject the old request into the other notebook.
|
||||
await user.click(screen.getByRole('button', { name: '+ New notebook' }))
|
||||
expect(await screen.findByDisplayValue('Untitled notebook')).toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(1)
|
||||
expect(screen.getByLabelText('SQL for cell 1')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('shows an error when the notebook list fails to load', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/notebooks`, () =>
|
||||
HttpResponse.json({ error: 'Database unavailable.' }, { status: 500 })
|
||||
)
|
||||
)
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
expect(await screen.findByText(/Could not load notebooks/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('No notebooks yet.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locks the default backend and cell controls on a notebook the user cannot edit', async () => {
|
||||
const { sqlNotebook } = await import('@/mocks/fixtures/sql-workbench')
|
||||
server.use(
|
||||
http.get(`${BASE}/notebooks/:id`, () => HttpResponse.json({ ...sqlNotebook, canEdit: false }))
|
||||
)
|
||||
window.history.replaceState(null, '', '/?notebook=1')
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await screen.findByDisplayValue('Monthly signups')
|
||||
expect(screen.getByLabelText('Default backend')).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: /^Delete cell/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Move cell/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs read-only SQL and shows typed results', async () => {
|
||||
const user = userEvent.setup()
|
||||
let sent: SqlExecuteRequest | null = null
|
||||
server.use(
|
||||
http.post(`${BASE}/execute`, async ({ request }) => {
|
||||
sent = (await request.json()) as SqlExecuteRequest
|
||||
const { sqlSelectResult } = await import('@/mocks/fixtures/sql-workbench')
|
||||
return HttpResponse.json(sqlSelectResult)
|
||||
}),
|
||||
http.get(`${BASE}/capabilities`, async () => {
|
||||
const { sqlCapabilities } = await import('@/mocks/fixtures/sql-workbench')
|
||||
return HttpResponse.json({
|
||||
...sqlCapabilities,
|
||||
limits: { ...sqlCapabilities.limits, pageSize: 2 },
|
||||
})
|
||||
})
|
||||
)
|
||||
window.history.replaceState(null, '', '/?notebook=1')
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Run' }))
|
||||
|
||||
const table = await screen.findByRole('table', { name: 'Query result' })
|
||||
expect(within(table).getByText('Darin')).toBeInTheDocument()
|
||||
expect(within(table).getAllByText('NULL').length).toBeGreaterThan(0)
|
||||
expect(within(table).getByText('<binary 4 bytes>')).toBeInTheDocument()
|
||||
expect(sent!.mode).toBe('READ_ONLY')
|
||||
expect(sent!.params).toEqual({ since: '2026-01-01' })
|
||||
expect(sent!.cellKey).toBe('c1')
|
||||
expect(sent!.executionUuid).toMatch(/[0-9a-f-]{36}/)
|
||||
expect(screen.getByRole('link', { name: /CSV/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/qqq/v1/sql-workbench/executions/exec-1/export?format=csv'
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
expect(await within(table).findByText('Tim')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Chart' }))
|
||||
expect(screen.getByLabelText('Chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows SQL errors as results', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
const editor = await screen.findByLabelText('SQL for cell 1')
|
||||
await user.type(editor, 'select broken')
|
||||
await user.click(screen.getByRole('button', { name: 'Run' }))
|
||||
expect(await screen.findByText(/column "broken" does not exist/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('previews a write and commits only after confirmation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const bodies: SqlExecuteRequest[] = []
|
||||
server.use(
|
||||
http.post(`${BASE}/execute`, async ({ request }) => {
|
||||
const body = (await request.json()) as SqlExecuteRequest
|
||||
bodies.push(body)
|
||||
const { sqlWritePreview, sqlWriteCommit } = await import('@/mocks/fixtures/sql-workbench')
|
||||
return HttpResponse.json(body.confirmToken ? sqlWriteCommit : sqlWritePreview)
|
||||
})
|
||||
)
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await user.type(
|
||||
await screen.findByLabelText('SQL for cell 1'),
|
||||
'UPDATE person SET active = false'
|
||||
)
|
||||
await user.click(screen.getByLabelText('Write mode'))
|
||||
await user.click(screen.getByRole('button', { name: 'Preview write' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText(/1204 rows affected/)).toBeInTheDocument()
|
||||
const commit = within(dialog).getByRole('button', { name: 'Commit write' })
|
||||
expect(commit).toBeDisabled()
|
||||
await user.type(within(dialog).getByLabelText(/Type postgres to confirm/), 'postgres')
|
||||
expect(commit).toBeDisabled()
|
||||
await user.click(within(dialog).getByLabelText(/I understand/))
|
||||
await user.click(commit)
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(bodies[0].confirmToken).toBeUndefined()
|
||||
expect(bodies[1]).toMatchObject({
|
||||
mode: 'WRITE',
|
||||
confirmToken: 'token-1',
|
||||
acknowledgeRowGuard: true,
|
||||
})
|
||||
expect(await screen.findByText(/1204 rows affected/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains a statement as a plan tree', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await user.type(await screen.findByLabelText('SQL for cell 1'), 'SELECT 1')
|
||||
await user.click(screen.getByRole('button', { name: 'Explain' }))
|
||||
const plan = await screen.findByRole('list', { name: 'Query plan' })
|
||||
expect(within(plan).getByText('Seq Scan')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Raw' }))
|
||||
expect(screen.getByText('[{"Plan": {}}]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves a new notebook', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
const title = await screen.findByLabelText('Notebook title')
|
||||
await user.clear(title)
|
||||
await user.type(title, 'Fresh')
|
||||
await user.click(screen.getByRole('button', { name: '+ Markdown cell' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(window.location.search).toBe('?notebook=9'))
|
||||
})
|
||||
|
||||
it('hides write controls for read-only users', async () => {
|
||||
server.use(http.get(`${BASE}/capabilities`, () => HttpResponse.json(sqlReadOnlyCapabilities)))
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
expect(await screen.findByText('Read-only')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Write mode')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Explain analyze' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when the workbench is unavailable', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/capabilities`, () =>
|
||||
HttpResponse.json({ error: 'Permission denied.' }, { status: 403 })
|
||||
)
|
||||
)
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
expect(await screen.findByText(/not available to you/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is rendered by WidgetRenderer for SQL_WORKBENCH and has no axe violations', async () => {
|
||||
const { container } = renderWithClient(
|
||||
<WidgetRenderer widgetMetaData={widgetMetaData} data={{}} />
|
||||
)
|
||||
await screen.findByRole('button', { name: '+ New notebook' })
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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 SqlWorkbenchWidget — the qbit-sql-workbench app's `SQL_WORKBENCH` widget: notebook
|
||||
* library (mine / shared), execution history, and the notebook editor. Its data comes from the
|
||||
* permission-checked `/qqq/v1/sql-workbench` endpoints (as the ESB overview widget reads the ESB
|
||||
* endpoints); the open notebook is kept in the URL (`?notebook=<id>`). Loaded lazily by
|
||||
* WidgetRenderer, so CodeMirror and the formatter are only downloaded here.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { QWidgetMetaData, SqlHistoryEntry } from '@/types'
|
||||
import {
|
||||
getSqlHistory,
|
||||
getSqlWorkbenchCapabilities,
|
||||
listSqlNotebooks,
|
||||
} from '@/lib/api/sql-workbench'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { formatDuration } from '@/lib/utils/sql-workbench'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { NotebookEditor, type InsertCellRequest } from './NotebookEditor'
|
||||
|
||||
/**
|
||||
* The notebook id in the page URL.
|
||||
*
|
||||
* @returns The id, or null.
|
||||
*/
|
||||
function notebookFromUrl(): number | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const id = Number(new URLSearchParams(window.location.search).get('notebook'))
|
||||
return Number.isInteger(id) && id > 0 ? id : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the workbench.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.widgetMetaData - The widget's metadata, for `data-qqq-id` scoping.
|
||||
* @returns The workbench, or a loading / unavailable state.
|
||||
*/
|
||||
export default function SqlWorkbenchWidget({
|
||||
widgetMetaData,
|
||||
}: {
|
||||
widgetMetaData: QWidgetMetaData
|
||||
}) {
|
||||
const [notebookId, setNotebookId] = useState<number | null>(notebookFromUrl)
|
||||
const [scope, setScope] = useState<'mine' | 'shared'>('mine')
|
||||
const [insertRequest, setInsertRequest] = useState<InsertCellRequest | null>(null)
|
||||
const clearInsertRequest = useCallback(() => setInsertRequest(null), [])
|
||||
|
||||
const {
|
||||
data: capabilities,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.sqlWorkbenchCapabilities(),
|
||||
queryFn: getSqlWorkbenchCapabilities,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const {
|
||||
data: notebooks = [],
|
||||
isLoading: notebooksLoading,
|
||||
error: notebooksError,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.sqlNotebooks(scope),
|
||||
queryFn: () => listSqlNotebooks(scope),
|
||||
enabled: !!capabilities,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: queryKeys.sqlHistory(),
|
||||
queryFn: () => getSqlHistory(20),
|
||||
enabled: !!capabilities,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const select = (id: number | null) => {
|
||||
setNotebookId(id)
|
||||
const url = new URL(window.location.href)
|
||||
if (id === null) url.searchParams.delete('notebook')
|
||||
else url.searchParams.set('notebook', String(id))
|
||||
window.history.replaceState(window.history.state, '', url)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
Loading SQL Workbench…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
Failed to load the SQL Workbench: {getErrorMessage(error)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!capabilities) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The SQL Workbench is not available to you on this server.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (capabilities.backends.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No SQL backends are configured for the workbench.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const openFromHistory = (entry: SqlHistoryEntry) =>
|
||||
setInsertRequest({ sql: entry.sqlText, backendName: entry.backendName, nonce: Date.now() })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-4 lg:grid-cols-[16rem_1fr]"
|
||||
data-qqq-id={`widget-${widgetMetaData.name}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<nav aria-label="Notebooks" className="space-y-2">
|
||||
<div className="flex gap-1 text-xs" role="group" aria-label="Notebook library">
|
||||
{(['mine', 'shared'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
aria-pressed={scope === s}
|
||||
className={cn(
|
||||
'rounded px-2 py-1',
|
||||
scope === s ? 'bg-muted font-medium' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => setScope(s)}
|
||||
data-qqq-id={`tab-sql-notebooks-${s}`}
|
||||
>
|
||||
{s === 'mine' ? 'My notebooks' : 'Shared library'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border px-2 py-1 text-left text-sm"
|
||||
onClick={() => select(null)}
|
||||
data-qqq-id="button-sql-new-notebook"
|
||||
>
|
||||
+ New notebook
|
||||
</button>
|
||||
{notebooksLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading…</p>
|
||||
) : notebooksError ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
Could not load notebooks: {getErrorMessage(notebooksError)}
|
||||
</p>
|
||||
) : notebooks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{scope === 'mine' ? 'No notebooks yet.' : 'Nothing has been shared yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{notebooks.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full truncate rounded px-2 py-1 text-left hover:bg-muted',
|
||||
n.id === notebookId && 'bg-muted font-medium'
|
||||
)}
|
||||
onClick={() => select(n.id)}
|
||||
aria-current={n.id === notebookId ? 'page' : undefined}
|
||||
title={n.description ?? n.title}
|
||||
data-qqq-id={`button-sql-notebook-${n.id}`}
|
||||
>
|
||||
{n.title}
|
||||
{n.visibility === 'SHARED' && scope === 'mine' && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">(shared)</span>
|
||||
)}
|
||||
{scope === 'shared' && (
|
||||
<span className="block text-xs text-muted-foreground">{n.ownerName}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
<section aria-label="History" className="space-y-1">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Recent runs</h3>
|
||||
<ul className="space-y-0.5 text-xs">
|
||||
{history.map((h) => (
|
||||
<li key={h.executionUuid}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded px-1 py-0.5 text-left hover:bg-muted"
|
||||
onClick={() => openFromHistory(h)}
|
||||
title={`${h.mode} ${h.status} — add as a new cell`}
|
||||
data-qqq-id={`button-sql-history-${h.executionUuid}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-1',
|
||||
h.status === 'SUCCEEDED' ? 'text-emerald-600' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{h.status === 'SUCCEEDED' ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="font-mono">{h.sqlText.slice(0, 60)}</span>
|
||||
<span className="ml-1 text-muted-foreground">{formatDuration(h.durationMs)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<NotebookEditor
|
||||
notebookId={notebookId}
|
||||
capabilities={capabilities}
|
||||
insertRequest={insertRequest}
|
||||
onInsertConsumed={clearInsertRequest}
|
||||
onSelect={select}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 WriteConfirmDialog — the second step of write mode. Shows every statement with its kind
|
||||
* and the affected rows from the rolled-back dry run, requires acknowledging the row guard when
|
||||
* it is exceeded, and requires typing the backend name before the commit is sent.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
|
||||
import type { SqlExecuteResponse } from '@/types'
|
||||
|
||||
/** Props for {@link WriteConfirmDialog}. */
|
||||
export interface WriteConfirmDialogProps {
|
||||
preview: SqlExecuteResponse
|
||||
backendName: string
|
||||
pending: boolean
|
||||
onConfirm: (acknowledgeRowGuard: boolean) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the confirmation dialog (open while mounted).
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The dialog.
|
||||
*/
|
||||
export function WriteConfirmDialog({
|
||||
preview,
|
||||
backendName,
|
||||
pending,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: WriteConfirmDialogProps) {
|
||||
const [typed, setTyped] = useState('')
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const guard = preview.rowGuardExceeded === true
|
||||
const canCommit = typed === backendName && (!guard || acknowledged) && !pending
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(open) => !open && onCancel()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-1/2 z-50 w-[min(42rem,95vw)] -translate-x-1/2 -translate-y-1/2 space-y-3 rounded-lg border bg-background p-4 shadow-lg"
|
||||
data-qqq-id="dialog-sql-write-confirm"
|
||||
>
|
||||
<Dialog.Title className="text-base font-semibold">
|
||||
Confirm write on “{backendName}”
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="flex gap-2 rounded bg-amber-50 p-2 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
<span aria-hidden>⚠</span>
|
||||
Raw SQL bypasses QQQ record security, audits, automations and search indexing. It runs
|
||||
in one transaction and is audited.
|
||||
</Dialog.Description>
|
||||
<table className="w-full text-sm" aria-label="Statements to commit">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Kind</th>
|
||||
<th scope="col">Statement</th>
|
||||
<th scope="col" className="text-right">
|
||||
Rows
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(preview.statements ?? []).map((s) => (
|
||||
<tr key={s.index} className="align-top">
|
||||
<td>{s.index + 1}</td>
|
||||
<td className="font-mono text-xs">{s.kind}</td>
|
||||
<td className="max-w-md truncate font-mono text-xs" title={s.text}>
|
||||
{s.text}
|
||||
</td>
|
||||
<td className="text-right tabular-nums">{s.affectedRows ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-sm">
|
||||
{preview.dryRunSupported
|
||||
? `Dry run (rolled back): ${preview.totalAffectedRows ?? 0} rows affected.`
|
||||
: (preview.dryRunNote ?? 'This write could not be dry-run.')}
|
||||
</p>
|
||||
{guard && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
data-qqq-id="checkbox-sql-row-guard"
|
||||
/>
|
||||
I understand this affects more than {preview.rowGuardLimit} rows.
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-sm">
|
||||
Type <span className="font-mono">{backendName}</span> to confirm
|
||||
<input
|
||||
className="mt-1 block w-full rounded border px-2 py-1 font-mono"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
data-qqq-id="input-sql-confirm-backend"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-3 py-1 text-sm"
|
||||
data-qqq-id="button-sql-write-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-destructive px-3 py-1 text-sm text-destructive-foreground disabled:opacity-50"
|
||||
disabled={!canCommit}
|
||||
onClick={() => onConfirm(acknowledged)}
|
||||
data-qqq-id="button-sql-write-commit"
|
||||
>
|
||||
{pending ? 'Committing…' : 'Commit write'}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import { DateTimeText } from './DateTimeText'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('DateTimeText', () => {
|
||||
it('renders one compact, unwrapped line with the full timestamp as its tooltip', () => {
|
||||
render(<DateTimeText type="DATE_TIME" value="2024-03-10T08:30:00Z" dataQqqId="when" />)
|
||||
const time = screen.getByText(/2024/)
|
||||
expect(time.tagName).toBe('TIME')
|
||||
expect(time.textContent).toMatch(/^Mar (9|10), 2024$/)
|
||||
expect(time).toHaveAttribute('dateTime', '2024-03-10T08:30:00.000Z')
|
||||
expect(time.getAttribute('title')).toMatch(/^2024-03-(09|10) \d{2}:30:00 [AP]M \S+$/)
|
||||
expect(time).toHaveClass('whitespace-nowrap')
|
||||
expect(time).toHaveAttribute('data-qqq-id', 'when')
|
||||
})
|
||||
|
||||
it('shows recent activity as relative time when asked', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-11T16:20:00Z'))
|
||||
render(<DateTimeText type="DATE_TIME" value="2026-10-11T13:20:00Z" relative />)
|
||||
expect(screen.getByText('3h ago')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('never shows a time for a DATE and shows a dash for an empty value', () => {
|
||||
const { container } = render(<><DateTimeText type="DATE" value="2024-06-15" /><DateTimeText type="DATE" value={null} /></>)
|
||||
expect(screen.getByText('Jun 15, 2024')).toHaveAttribute('dateTime', '2024-06-15')
|
||||
expect(container).toHaveTextContent('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 DateTimeText — the one way a DATE or DATE_TIME value is shown on screen: compact,
|
||||
* on one line, with the full value in a tooltip (see `compactDateTime` / `compactDate`).
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { compactDate, compactDateTime, relativeDateTime } from '@/lib/utils/datetime-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/** Props for {@link DateTimeText}. */
|
||||
export interface DateTimeTextProps {
|
||||
/** Raw value: an ISO-8601 instant (DATE_TIME) or `yyyy-MM-dd` (DATE). */
|
||||
value: unknown
|
||||
/** Backend display value, used for zoned display values and unreadable raw values. */
|
||||
displayValue?: unknown
|
||||
/** The field type; DATE never shows a time. */
|
||||
type: 'DATE' | 'DATE_TIME'
|
||||
/** Show recent DATE_TIME values as relative time (`3h ago`), e.g. in activity lists. */
|
||||
relative?: boolean
|
||||
/** `data-qqq-id` of the element. */
|
||||
dataQqqId?: string
|
||||
/** Extra classes. */
|
||||
className?: string
|
||||
/** Shown when the value is empty (default: an em dash). */
|
||||
empty?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a date or timestamp compactly and without wrapping: `6:33 PM` today,
|
||||
* `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before (DATE: `Jul 5` / `Jul 5, 2025`), in the
|
||||
* viewer's zone and locale, with the full value as the tooltip and a machine-readable
|
||||
* `<time dateTime>`.
|
||||
*
|
||||
* @param props - See {@link DateTimeTextProps}.
|
||||
* @returns The `<time>` element, or the empty placeholder.
|
||||
*/
|
||||
export function DateTimeText({ value, displayValue, type, relative = false, dataQqqId, className, empty }: DateTimeTextProps) {
|
||||
const display = type === 'DATE' ? compactDate(value, displayValue) : compactDateTime(value, displayValue)
|
||||
if (!display) {
|
||||
return empty !== undefined ? <>{empty}</> : <span className={cn('text-sm text-muted-foreground', className)} data-qqq-id={dataQqqId}>—</span>
|
||||
}
|
||||
const text = (relative && type === 'DATE_TIME' && relativeDateTime(value)) || display.text
|
||||
return (
|
||||
<time
|
||||
dateTime={display.dateTime}
|
||||
title={display.title}
|
||||
className={cn('whitespace-nowrap text-sm tabular-nums text-foreground', className)}
|
||||
data-qqq-id={dataQqqId}
|
||||
>
|
||||
{text}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
@@ -246,9 +246,9 @@ export function AppHome({ appMetaData, instance, widgetRegistry }: AppHomeProps)
|
||||
|
||||
{/* Widget grid: QQQ sizes widgets in twelfths (gridColumns), full width on small screens */}
|
||||
{widgetItems.length > 0 && (
|
||||
<section aria-label="Dashboard widgets" className="grid grid-cols-12 gap-5" data-qqq-id="widget-grid">
|
||||
<section aria-label="Dashboard widgets" className="@container grid grid-cols-12 gap-5" data-qqq-id="widget-grid">
|
||||
{widgetItems.map((meta) => (
|
||||
<div key={meta.name} id={meta.name} className={`${widgetColumnClasses(meta.gridColumns, meta.defaultValues)} ${WIDGET_ANCHOR}`} data-qqq-id={`widget-grid-item-${meta.name}`}>
|
||||
<div key={meta.name} id={meta.name} className={`${widgetColumnClasses(meta.gridColumns, meta.defaultValues, meta.type)} ${WIDGET_ANCHOR}`} data-qqq-id={`widget-grid-item-${meta.name}`}>
|
||||
<ConnectedWidget widgetMetaData={meta} widgetRegistry={widgetRegistry} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock('./widget-utils', async (importOriginal) => ({
|
||||
downloadText: (fileName: string, text: string) => { downloads.calls.push([fileName, text]) },
|
||||
}))
|
||||
|
||||
import type { QRecord, QWidgetMetaData } from '@/types'
|
||||
import type { FieldAdornment, QRecord, QWidgetMetaData } from '@/types'
|
||||
import {
|
||||
ChildRecordListWidget, addChildHref, childColumns, childExportTitle, childRecordsCsv, listColumns, nextViewAllHref, shownColumns,
|
||||
} from './ChildRecordListWidget'
|
||||
@@ -114,6 +114,44 @@ describe('ChildRecordListWidget', () => {
|
||||
expect(screen.queryByText(/Showing/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes the child table variant to the preview gallery, as the thumbnails get it', async () => {
|
||||
const variant = { type: 'store', id: '2' }
|
||||
localStorage.setItem('qqq.tableVariant.media', JSON.stringify(variant))
|
||||
try {
|
||||
instance.value = { tables: { media: { name: 'media', label: 'Media', usesVariants: true, readPermission: true, fields: {} } }, widgets: {} }
|
||||
const adornments: FieldAdornment[] = [
|
||||
{ type: 'FILE_PREVIEW', values: { fileNameField: 'name', mimeTypeField: 'type' } },
|
||||
{ type: 'FILE_DOWNLOAD', values: {} },
|
||||
]
|
||||
const mediaTable: ChildTableMetaData = {
|
||||
name: 'media',
|
||||
label: 'Media',
|
||||
primaryKeyField: 'id',
|
||||
fields: {
|
||||
id: { name: 'id', label: 'Id' },
|
||||
// adornments ride along on the payload's field metadata
|
||||
file: { name: 'file', label: 'File', type: 'BLOB', ...{ adornments } },
|
||||
},
|
||||
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'file'], isHidden: false }],
|
||||
}
|
||||
const data: ChildRecordListPayload = {
|
||||
type: 'childRecordList',
|
||||
childFrontendTableMetaData: mediaTable,
|
||||
queryOutput: { records: [{ tableName: 'media', values: { id: 1, file: '/data/media/1/file/a.png', name: 'a.png', type: 'image/png' } }] },
|
||||
}
|
||||
render(<ChildRecordListWidget widgetMetaData={meta} data={data} />)
|
||||
const json = encodeURIComponent(JSON.stringify(variant))
|
||||
const url = `/qqq/v1/table/media/1/file/a.png?tableVariant=${json}`
|
||||
// the cell thumbnail resolves the variant from the instance metadata once it has loaded
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Preview a.png' }).querySelector('img')).toHaveAttribute('src', url))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview a.png' }))
|
||||
await waitFor(() => expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toHaveAttribute('href', url))
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', expect.stringContaining(`tableVariant=${json}`))
|
||||
} finally {
|
||||
localStorage.removeItem('qqq.tableVariant.media')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the empty message when the backend returned no records', () => {
|
||||
render(<ChildRecordListWidget widgetMetaData={meta} data={{ ...payload, queryOutput: {}, totalRows: 0, viewAllLink: undefined }} />)
|
||||
expect(screen.getByText('No Widget Host Child records found')).toBeInTheDocument()
|
||||
@@ -274,3 +312,91 @@ describe('ChildRecordListWidget Material parity (WID-072)', () => {
|
||||
expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChildRecordListWidget in a narrow panel', () => {
|
||||
const longText = 'Notes from the work of building systems, making decisions and understanding what changes along the way.'
|
||||
const wideTable: ChildTableMetaData = {
|
||||
name: 'blog',
|
||||
label: 'Blogs',
|
||||
primaryKeyField: 'id',
|
||||
fields: {
|
||||
id: { name: 'id', label: 'Id', type: 'INTEGER' },
|
||||
siteId: { name: 'siteId', label: 'Site', type: 'INTEGER' },
|
||||
name: { name: 'name', label: 'Name', type: 'STRING' },
|
||||
isLive: { name: 'isLive', label: 'Is Live', type: 'BOOLEAN' },
|
||||
description: { name: 'description', label: 'Description', type: 'STRING', maxLength: 1000 },
|
||||
metaDescription: { name: 'metaDescription', label: 'Meta Description', type: 'STRING', maxLength: 1000 },
|
||||
createDate: { name: 'createDate', label: 'Create Date', type: 'DATE_TIME' },
|
||||
modifyDate: { name: 'modifyDate', label: 'Modify Date', type: 'DATE_TIME' },
|
||||
},
|
||||
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'siteId', 'name', 'isLive', 'description', 'metaDescription', 'createDate', 'modifyDate'] }],
|
||||
}
|
||||
const widePayload: ChildRecordListPayload = {
|
||||
type: 'childRecordList',
|
||||
childFrontendTableMetaData: wideTable,
|
||||
defaultValuesForNewChildRecords: { siteId: 1 },
|
||||
queryOutput: {
|
||||
records: [{
|
||||
tableName: 'blog',
|
||||
values: { id: 7, siteId: 1, name: 'Field Notes', isLive: true, description: longText, metaDescription: longText, createDate: '2025-07-05T23:33:29Z', modifyDate: '2026-10-10T11:01:39Z' },
|
||||
displayValues: { id: '7', name: 'Field Notes' },
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every observed element the given content width, as a browser would report it.
|
||||
*
|
||||
* @param width - Width in CSS pixels.
|
||||
*/
|
||||
function stubResizeObserver(width: number) {
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
private readonly callback: ResizeObserverCallback
|
||||
constructor(callback: ResizeObserverCallback) { this.callback = callback }
|
||||
observe(target: Element) { this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver) }
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
})
|
||||
}
|
||||
|
||||
/** @returns The headers of the rendered list. */
|
||||
function headers(): string[] {
|
||||
return screen.getAllByRole('columnheader').map((header) => header.textContent ?? '')
|
||||
}
|
||||
|
||||
it('shows every column when its width is unknown', () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
render(<ChildRecordListWidget widgetMetaData={meta} data={widePayload} />)
|
||||
expect(headers()).toEqual(['Id', 'Name', 'Is Live', 'Description', 'Meta Description', 'Create Date', 'Modify Date'])
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps the name, flags and latest date that fit, and reveals the rest on request', async () => {
|
||||
stubResizeObserver(400)
|
||||
render(<ChildRecordListWidget widgetMetaData={meta} data={widePayload} />)
|
||||
await waitFor(() => expect(headers()).toEqual(['Name', 'Is Live', 'Modify Date']))
|
||||
// the first shown column links the row; cells align to the top; dates stay on one line
|
||||
expect(screen.getByRole('link', { name: 'Field Notes' })).toHaveAttribute('href', '/app/blog/7')
|
||||
expect(screen.getAllByRole('cell')[0]).toHaveClass('align-top')
|
||||
expect(screen.getAllByRole('cell')[2].querySelector('time')).toHaveClass('whitespace-nowrap')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '+4 more columns' }))
|
||||
expect(headers()).toEqual(['Id', 'Name', 'Is Live', 'Description', 'Meta Description', 'Create Date', 'Modify Date'])
|
||||
// long text is clamped, with the full text on hover
|
||||
const description = screen.getAllByRole('cell')[3].firstElementChild
|
||||
expect(description).toHaveClass('line-clamp-3')
|
||||
expect(description).toHaveAttribute('title', longText)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fewer columns' }))
|
||||
expect(headers()).toEqual(['Name', 'Is Live', 'Modify Date'])
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('follows the application column order when onlyIncludeFieldNames is set', async () => {
|
||||
stubResizeObserver(300)
|
||||
render(<ChildRecordListWidget widgetMetaData={meta} data={{ ...widePayload, onlyIncludeFieldNames: ['description', 'name'] }} />)
|
||||
await waitFor(() => expect(headers()).toEqual(['Description']))
|
||||
expect(screen.getByRole('button', { name: '+1 more column' })).toBeInTheDocument()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
@@ -24,16 +24,26 @@
|
||||
* click anywhere on a row to open the child record, a CSV export of the rows shown,
|
||||
* and a View All link when the backend limited the rows (Material RecordGridWidget).
|
||||
* Association editing is handled by the record view, not here.
|
||||
*
|
||||
* The list adapts to the width it is given (a half-width panel, a phone): it shows the
|
||||
* columns that matter most and fit (see `compact-columns`), with a toggle to show every
|
||||
* column; cells align to the top, long text is clamped with its full text on hover, and
|
||||
* dates are compact.
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Download } from 'lucide-react'
|
||||
|
||||
import type { QRecord, QTableMetaData } from '@/types'
|
||||
import type { FieldAdornment, QRecord, QTableMetaData } from '@/types'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { useContainerWidth } from '@/lib/hooks/use-container-width'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { columnKind, pickCompactColumns } from '@/lib/utils/compact-columns'
|
||||
import { hasFilePreview, joinedPreviewField } from '@/lib/utils/file-preview'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
import { displayText } from './record-lookup-utils'
|
||||
import { asList, isPlainObject, payloadProblem } from './widget-types'
|
||||
@@ -51,6 +61,10 @@ interface ChildFieldMetaData {
|
||||
isHidden?: boolean
|
||||
isHeavy?: boolean
|
||||
possibleValueSourceName?: string
|
||||
/** Declared maximum length (long text when there are no rows to measure). */
|
||||
maxLength?: number
|
||||
/** Field adornments (a CHIP marks a status column). */
|
||||
adornments?: FieldAdornment[]
|
||||
}
|
||||
|
||||
/** An exposed join of the child table (`QFrontendExposedJoin`), with its join table's metadata. */
|
||||
@@ -309,6 +323,50 @@ export function nextViewAllHref(viewAllLink: string, tableName: string): string
|
||||
return `/app/${encodeURIComponent(tableName)}${queryIndex >= 0 ? viewAllLink.slice(queryIndex) : ''}`
|
||||
}
|
||||
|
||||
/** The columns an embedded list shows in its width, and which of them hold long text. */
|
||||
export interface CompactChildColumns {
|
||||
/** Columns to show, in metadata order. */
|
||||
compact: ChildColumn[]
|
||||
/** How many columns did not fit. */
|
||||
hiddenCount: number
|
||||
/** Names of the columns holding long text (clamped in their cells). */
|
||||
longText: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the child columns that matter most and fit the list's width (see `compact-columns`):
|
||||
* the T1 identity, status and boolean columns and the most recent date before long text, the
|
||||
* key only when nothing else names the row, and the widget's `onlyIncludeFieldNames` order
|
||||
* when the application set one.
|
||||
*
|
||||
* @param columns - Candidate columns (parent key already removed).
|
||||
* @param table - Child table metadata.
|
||||
* @param data - Widget payload.
|
||||
* @param records - Rows shown.
|
||||
* @param width - The list's width in CSS pixels; 0 shows every column.
|
||||
* @returns The choice.
|
||||
*/
|
||||
export function compactChildColumns(columns: ChildColumn[], table: ChildTableMetaData | undefined, data: ChildRecordListPayload | undefined, records: QRecord[], width: number): CompactChildColumns {
|
||||
const valuesOf = (record: QRecord) => (isPlainObject(record?.values) ? record.values : {})
|
||||
const shownText = (name: string) => records.map((record) => {
|
||||
const display = isPlainObject(record?.displayValues) ? record.displayValues[name] : undefined
|
||||
return displayText(display !== undefined && display !== null ? display : valuesOf(record)[name])
|
||||
})
|
||||
const rawText = (name: string) => records.map((record) => displayText(valuesOf(record)[name]))
|
||||
const t1FieldNames = (asList<ChildSectionMetaData>(table?.sections) ?? [])
|
||||
.filter((section) => isPlainObject(section) && section.tier === 'T1')
|
||||
.flatMap((section) => asList<string>(section.fieldNames) ?? [])
|
||||
const context = {
|
||||
primaryKeyField: table?.primaryKeyField,
|
||||
t1FieldNames,
|
||||
explicitOrder: asList<string>(data?.onlyIncludeFieldNames) ?? [],
|
||||
samples: shownText,
|
||||
}
|
||||
const longText = new Set(columns.filter((column) => columnKind(column, context) === 'longText').map((column) => column.name))
|
||||
const { shown, hidden } = pickCompactColumns(columns, width, context, rawText)
|
||||
return { compact: shown, hiddenCount: hidden.length, longText }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the child records returned for the hosting record: each value formatted by its
|
||||
* field's type and adornments (possible values link to their record), the exposed join
|
||||
@@ -325,7 +383,7 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
|
||||
const { data: instance } = useMetaData()
|
||||
const table = data?.childFrontendTableMetaData ?? data?.childTableMetaData
|
||||
const queryOutput = data?.queryOutput
|
||||
const records = queryOutput === undefined || queryOutput === null ? [] : isPlainObject(queryOutput) ? asList<QRecord>(queryOutput.records) : undefined
|
||||
const records = useMemo(() => queryOutput === undefined || queryOutput === null ? [] : isPlainObject(queryOutput) ? asList<QRecord>(queryOutput.records) : undefined, [queryOutput])
|
||||
const valid = Boolean(table) && isPlainObject(table) && typeof table?.name === 'string' && records !== undefined
|
||||
|
||||
// Join columns need the user's table permissions, which the instance metadata carries.
|
||||
@@ -334,8 +392,24 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
|
||||
const tables = instance?.tables
|
||||
return listColumns(table, data, (name) => Boolean(tables?.[name]) && tables?.[name]?.readPermission !== false)
|
||||
}, [valid, table, data, instance?.tables])
|
||||
const columns = useMemo(() => shownColumns(allColumns, data), [allColumns, data])
|
||||
const fields = useMemo(() => Object.fromEntries(columns.map((column) => [column.name, widgetField(column.field, column.name, column.label)])), [columns])
|
||||
const candidateColumns = useMemo(() => shownColumns(allColumns, data), [allColumns, data])
|
||||
const [measureRef, availableWidth] = useContainerWidth()
|
||||
const [showAllColumns, setShowAllColumns] = useState(false)
|
||||
const { compact: columns, hiddenCount, longText } = useMemo(() => {
|
||||
const rows = isPlainObject(data?.queryOutput) ? asList<QRecord>(data.queryOutput.records) ?? [] : []
|
||||
return compactChildColumns(candidateColumns, table, data, rows, showAllColumns ? 0 : availableWidth)
|
||||
}, [candidateColumns, table, data, showAllColumns, availableWidth])
|
||||
// a join column's values are keyed `joinTable.field`, and so are its FILE_PREVIEW field references
|
||||
const fields = useMemo(() => Object.fromEntries(candidateColumns.map((column) => [column.name, widgetField(
|
||||
column.name.includes('.') ? joinedPreviewField(column.field, column.name.slice(0, column.name.indexOf('.'))) : column.field, column.name, column.label)])), [candidateColumns])
|
||||
// one record per row, shared by its cells and the preview gallery (which matches thumbnails by record)
|
||||
const cellRecords = useMemo<QRecord[]>(() => (records ?? []).map((record) => ({
|
||||
tableName: typeof record?.tableName === 'string' ? record.tableName : table?.name ?? '',
|
||||
values: isPlainObject(record?.values) ? record.values : {},
|
||||
displayValues: (isPlainObject(record?.displayValues) ? record.displayValues : {}) as Record<string, string>,
|
||||
})), [records, table?.name])
|
||||
// only the columns on screen contribute previews, so lightbox navigation never steps into a hidden column
|
||||
const galleryFields = useMemo(() => columns.map((column) => fields[column.name]).filter(Boolean), [columns, fields])
|
||||
|
||||
if (!valid || !table || records === undefined) {
|
||||
return <WidgetPayloadNotice widgetName={widgetName} message={payloadProblem('child record list', 'queryOutput.records and child table metadata')} />
|
||||
@@ -355,8 +429,20 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
|
||||
const allTables = instance?.tables as Record<string, QTableMetaData> | undefined
|
||||
const showToolbar = data.canAddChildRecord === true || widgetMetaData.showExportButton === true
|
||||
|
||||
const columnToggle = hiddenCount > 0 || showAllColumns ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllColumns((current) => !current)}
|
||||
aria-expanded={showAllColumns}
|
||||
className="rounded text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id={`child-record-columns-toggle-${widgetName}`}
|
||||
>
|
||||
{showAllColumns ? 'Fewer columns' : `+${hiddenCount} more column${hiddenCount === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-qqq-id={`widget-childRecordList-${widgetName}`}>
|
||||
<div ref={measureRef} className="min-w-0 space-y-2" data-qqq-id={`widget-childRecordList-${widgetName}`} data-hidden-columns={hiddenCount}>
|
||||
{showToolbar && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
{widgetMetaData.showExportButton === true && (
|
||||
@@ -382,56 +468,59 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
{columns.map((column) => (
|
||||
<th key={column.name} scope="col" className="px-2 py-1.5 font-medium text-muted-foreground" data-qqq-id={`child-record-column-${widgetName}-${column.name}`}>
|
||||
<th key={column.name} scope="col" className="whitespace-nowrap px-2 py-1.5 align-bottom font-medium text-muted-foreground" data-qqq-id={`child-record-column-${widgetName}-${column.name}`}>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record, rowIndex) => {
|
||||
const values = isPlainObject(record?.values) ? record.values : {}
|
||||
const displayValues = isPlainObject(record?.displayValues) ? record.displayValues : {}
|
||||
const pk = primaryKeyField ? values[primaryKeyField] : undefined
|
||||
const pkText = displayText(pk)
|
||||
const href = !data.disableRowClick && pkText !== '' ? `/app/${encodeURIComponent(table.name)}/${encodeURIComponent(pkText)}` : undefined
|
||||
const cellRecord: QRecord = {
|
||||
tableName: typeof record?.tableName === 'string' ? record.tableName : table.name,
|
||||
values,
|
||||
displayValues: displayValues as Record<string, string>,
|
||||
}
|
||||
return (
|
||||
<tr
|
||||
key={pkText || rowIndex}
|
||||
className={cn('border-b border-border/50 last:border-0', href && 'cursor-pointer hover:bg-accent/50')}
|
||||
onClick={href ? openRowRecord : undefined}
|
||||
data-qqq-id={`child-record-row-${widgetName}-${pkText || rowIndex}`}
|
||||
>
|
||||
{columns.map((column, columnIndex) => {
|
||||
const display = displayValues[column.name]
|
||||
const text = displayText(display !== undefined && display !== null ? display : values[column.name])
|
||||
return (
|
||||
<td key={column.name} className="px-2 py-1.5 text-foreground" data-qqq-id={`child-record-cell-${widgetName}-${column.name}`}>
|
||||
{columnIndex === 0 && href ? (
|
||||
<Link href={href} data-row-link="" className="text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{text || pkText}
|
||||
</Link>
|
||||
) : (
|
||||
<FieldValue field={fields[column.name]} record={cellRecord} allTables={allTables} widgetMetaDataMap={instance?.widgets} />
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<FilePreviewGallery records={cellRecords} fields={galleryFields} tableVariant={storedRecordVariantJson(allTables?.[table.name])}>
|
||||
{cellRecords.map((cellRecord, rowIndex) => {
|
||||
const { values } = cellRecord
|
||||
const displayValues = cellRecord.displayValues ?? {}
|
||||
const pk = primaryKeyField ? values[primaryKeyField] : undefined
|
||||
const pkText = displayText(pk)
|
||||
const href = !data.disableRowClick && pkText !== '' ? `/app/${encodeURIComponent(table.name)}/${encodeURIComponent(pkText)}` : undefined
|
||||
return (
|
||||
<tr
|
||||
key={pkText || rowIndex}
|
||||
className={cn('border-b border-border/50 last:border-0', href && 'cursor-pointer hover:bg-accent/50')}
|
||||
onClick={href ? openRowRecord : undefined}
|
||||
data-qqq-id={`child-record-row-${widgetName}-${pkText || rowIndex}`}
|
||||
>
|
||||
{columns.map((column, columnIndex) => {
|
||||
const display = displayValues[column.name]
|
||||
const text = displayText(display !== undefined && display !== null ? display : values[column.name])
|
||||
return (
|
||||
<td key={column.name} className="px-2 py-1.5 align-top text-foreground" data-qqq-id={`child-record-cell-${widgetName}-${column.name}`}>
|
||||
{columnIndex === 0 && href && !hasFilePreview(fields[column.name]) ? (
|
||||
<Link href={href} data-row-link="" className="text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{text || pkText}
|
||||
</Link>
|
||||
) : (
|
||||
// long text is clamped to three lines; the full value is its tooltip
|
||||
<div className={cn('break-words', longText.has(column.name) ? 'line-clamp-3 min-w-[12rem] max-w-[28rem]' : 'max-w-[20rem]')} title={longText.has(column.name) ? text : undefined}>
|
||||
<FieldValue field={fields[column.name]} record={cellRecord} allTables={allTables} widgetMetaDataMap={instance?.widgets} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</FilePreviewGallery>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{(totalRows !== undefined && totalRows > records.length) || viewAllHref ? (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{totalRows !== undefined && totalRows > records.length ? `Showing ${records.length} of ${totalRows}` : ''}</span>
|
||||
{(totalRows !== undefined && totalRows > records.length) || viewAllHref || columnToggle ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex flex-wrap items-center gap-x-3">
|
||||
{totalRows !== undefined && totalRows > records.length ? <span>{`Showing ${records.length} of ${totalRows}`}</span> : null}
|
||||
{columnToggle}
|
||||
</span>
|
||||
{viewAllHref && (
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock('@/lib/api/widgets', async (importOriginal) => {
|
||||
import type { QWidgetMetaData, WidgetData } from '@/types'
|
||||
import { fetchWidgetData, WidgetRequestError } from '@/lib/api/widgets'
|
||||
import { ConnectedWidget, SeededWidget } from './ConnectedWidget'
|
||||
import * as widgetUtils from './widget-utils'
|
||||
|
||||
const fetchMock = vi.mocked(fetchWidgetData)
|
||||
|
||||
@@ -69,6 +70,106 @@ describe('ConnectedWidget', () => {
|
||||
fetchMock.mockImplementation(async (_name, params) => controlsPayload(params))
|
||||
})
|
||||
|
||||
it('hydrates backend date and choice defaults without persisting caller defaults', async () => {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
const day = await screen.findByLabelText('Select Day')
|
||||
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
|
||||
expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha')
|
||||
const sent = new Date(2026, 0, 15).toLocaleDateString()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: sent }))
|
||||
expect(await screen.findByText(`choice=alpha; day=${sent}`)).toBeInTheDocument()
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the configured date wire normalization for a backend default', async () => {
|
||||
const normalize = vi.spyOn(widgetUtils, 'normalizeDropdownDate').mockImplementation((value) => value)
|
||||
try {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
await waitFor(() => expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-15'))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: '2026-01-15' }))
|
||||
expect(await screen.findByText('choice=alpha; day=2026-01-15')).toBeInTheDocument()
|
||||
expect(localStorage.length).toBe(0)
|
||||
} finally {
|
||||
normalize.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'not-a-date'])('leaves a missing or invalid date default blank (%s)', async (value) => {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', value],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
await waitFor(() => expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha'))
|
||||
expect(screen.getByLabelText('Select Day')).toHaveValue('')
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha' }))
|
||||
})
|
||||
|
||||
it('keeps a user date when a late reload supplies a different default', async () => {
|
||||
const user = userEvent.setup()
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
const day = await screen.findByLabelText('Select Day')
|
||||
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
|
||||
fireEvent.change(day, { target: { value: '2026-01-20' } })
|
||||
const sent = new Date(2026, 0, 20).toLocaleDateString()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: sent }))
|
||||
expect(await screen.findByText(`choice=alpha; day=${sent}`)).toBeInTheDocument()
|
||||
let finishReload!: (value: WidgetData) => void
|
||||
fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finishReload = resolve }))
|
||||
await user.click(screen.getByLabelText('Reload Owned Controls'))
|
||||
await waitFor(() => expect(finishReload).toBeDefined())
|
||||
finishReload({ ...controlsPayload({ accChoice: 'alpha', accDate: sent }), dropdownDefaultValueList: ['alpha', '2026-01-25'] })
|
||||
await waitFor(() => expect(screen.getByLabelText('Reload Owned Controls')).not.toBeDisabled())
|
||||
expect(day).toHaveValue('2026-01-20')
|
||||
})
|
||||
|
||||
it('preserves an explicit clear when the backend still supplies a date default', async () => {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
const day = await screen.findByLabelText('Select Day')
|
||||
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
|
||||
fireEvent.change(day, { target: { value: '' } })
|
||||
await waitFor(() => expect(screen.getByText('Please select a Day')).toBeInTheDocument())
|
||||
expect(day).toHaveValue('')
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves a stored date instead of replacing it with a server default', async () => {
|
||||
localStorage.setItem('qqq.widgets.dropdownData.accControls.accDate', JSON.stringify({ id: '2026-01-10' }))
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget()
|
||||
await waitFor(() => expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha'))
|
||||
expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-10')
|
||||
expect(JSON.parse(localStorage.getItem('qqq.widgets.dropdownData.accControls.accDate')!)).toEqual({ id: '2026-01-10' })
|
||||
expect(localStorage.getItem('qqq.widgets.dropdownData.accControls.accChoice')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not carry one caller default into a fresh caller widget without saved dates', async () => {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
const first = renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
await waitFor(() => expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-15'))
|
||||
first.unmount()
|
||||
fetchMock.mockImplementation(async (_name, params) => controlsPayload(params))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
expect(await screen.findByText('Please select a Choice and Day')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Select Day')).toHaveValue('')
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders payload dropdowns, sends selections under their parameter names and persists them', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWidget()
|
||||
|
||||
@@ -39,7 +39,7 @@ import { WidgetRenderer } from './WidgetRenderer'
|
||||
import { ChildRecordExportButton } from './ChildRecordListWidget'
|
||||
import type { ChildRecordListPayload } from './ChildRecordListWidget'
|
||||
import {
|
||||
CUSTOM_TIMEFRAME, TIMEFRAME_DROPDOWN, downloadText, dropdownStorageKey, storedDropdownParams, widgetCsvToString,
|
||||
CUSTOM_TIMEFRAME, TIMEFRAME_DROPDOWN, downloadText, dropdownStorageKey, normalizeDropdownDate, parseDropdownDate, storedDropdownParams, widgetCsvToString,
|
||||
widgetExportFileName, writeStoredSelection,
|
||||
} from './widget-utils'
|
||||
import { tableExportCsv } from './table-widget-utils'
|
||||
@@ -174,9 +174,15 @@ export function ConnectedWidget({
|
||||
names.forEach((name, index) => {
|
||||
if (typeof name !== 'string') return
|
||||
const meta = widgetMetaData.dropdowns?.[index]
|
||||
if (meta?.type === 'DATE_PICKER') return
|
||||
const ids = (Array.isArray(lists[index]) ? lists[index] : []).map((option: { id?: unknown }) => String(option?.id))
|
||||
const selected = current[name]
|
||||
if (meta?.type === 'DATE_PICKER') {
|
||||
const value = defaults[index]
|
||||
if (selected === undefined && typeof value === 'string' && parseDropdownDate(value)) {
|
||||
next = { ...next, [name]: normalizeDropdownDate(value) }
|
||||
}
|
||||
return
|
||||
}
|
||||
const ids = (Array.isArray(lists[index]) ? lists[index] : []).map((option: { id?: unknown }) => String(option?.id))
|
||||
// a custom timeframe range (custom,<start>,<end>) is offered by the timeframe's custom option
|
||||
const offered = selected && (ids.includes(selected) || (name === TIMEFRAME_DROPDOWN && ids.includes(CUSTOM_TIMEFRAME) && selected.startsWith(`${CUSTOM_TIMEFRAME},`)))
|
||||
if (selected && !offered) {
|
||||
|
||||
@@ -29,7 +29,11 @@ import { server } from '@/mocks/node'
|
||||
import { EsbOverviewWidget } from './EsbOverviewWidget'
|
||||
import { WidgetRenderer } from './WidgetRenderer'
|
||||
|
||||
const widgetMetaData = { name: 'esbOverview', label: 'ESB', type: 'ESB_OVERVIEW' } as QWidgetMetaData
|
||||
const widgetMetaData = {
|
||||
name: 'esbOverview',
|
||||
label: 'ESB',
|
||||
type: 'ESB_OVERVIEW',
|
||||
} as QWidgetMetaData
|
||||
|
||||
/**
|
||||
* Renders an element inside a fresh query client.
|
||||
@@ -73,13 +77,16 @@ describe('EsbOverviewWidget', () => {
|
||||
expect(bodyRows('Destinations')).toHaveLength(3)
|
||||
expect(events).toHaveTextContent('orderEvents')
|
||||
expect(events).toHaveTextContent('Topic')
|
||||
expect(within(events).getByRole('link', { name: 'order' })).toHaveAttribute('href', '/app/order')
|
||||
expect(within(events).getByRole('link', { name: 'order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/order'
|
||||
)
|
||||
expect(events).toHaveTextContent('INSERT, UPDATE, DELETE')
|
||||
expect(fulfillment).toHaveTextContent('Queue')
|
||||
expect(within(fulfillment).getAllByRole('cell')[3]).toHaveTextContent(/^7$/)
|
||||
expect(within(results).getByRole('link', { name: 'fulfillOrder' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/fulfillOrder'
|
||||
'/app/fulfillOrder/dev'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -105,7 +112,9 @@ describe('EsbOverviewWidget', () => {
|
||||
)
|
||||
renderWithClient(<EsbOverviewWidget widgetMetaData={widgetMetaData} />)
|
||||
await screen.findByRole('table', { name: 'Triggers' })
|
||||
expect(screen.queryByRole('button', { name: /^(Pause|Resume|Restart|Purge)/ })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^(Pause|Resume|Restart|Purge)/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says so when the ESB status is not available', async () => {
|
||||
@@ -119,7 +128,9 @@ describe('EsbOverviewWidget', () => {
|
||||
})
|
||||
|
||||
it('is rendered for the ESB_OVERVIEW widget type', async () => {
|
||||
renderWithClient(<WidgetRenderer widgetMetaData={widgetMetaData} data={{ type: 'ESB_OVERVIEW' }} />)
|
||||
renderWithClient(
|
||||
<WidgetRenderer widgetMetaData={widgetMetaData} data={{ type: 'ESB_OVERVIEW' }} />
|
||||
)
|
||||
expect(await screen.findByRole('table', { name: 'Destinations' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -25,7 +25,12 @@ import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { EsbOverviewDestination, EsbPermissions, EsbProviderType, QWidgetMetaData } from '@/types'
|
||||
import type {
|
||||
EsbOverviewDestination,
|
||||
EsbPermissions,
|
||||
EsbProviderType,
|
||||
QWidgetMetaData,
|
||||
} from '@/types'
|
||||
import { getEsbOverview } from '@/lib/api/esb'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { CHIP_COLOR_CLASSES } from '@/lib/utils/adornment-utils'
|
||||
@@ -126,7 +131,11 @@ export function EsbOverviewWidget({ widgetMetaData }: { widgetMetaData: QWidgetM
|
||||
))}
|
||||
</EsbList>
|
||||
|
||||
<EsbList title="Triggers" columns={TRIGGER_COLUMNS} empty="No triggers on processes you can access.">
|
||||
<EsbList
|
||||
title="Triggers"
|
||||
columns={TRIGGER_COLUMNS}
|
||||
empty="No triggers on processes you can access."
|
||||
>
|
||||
{triggers.map((trigger) => (
|
||||
<EsbTriggerRow key={trigger.name} trigger={trigger} permissions={data.permissions} />
|
||||
))}
|
||||
@@ -174,12 +183,14 @@ function DestinationRow({
|
||||
{publisher.kind === 'TABLE' ? 'Table' : 'Process'}{' '}
|
||||
</span>
|
||||
<Link
|
||||
href={`/app/${encodeURIComponent(publisher.name)}`}
|
||||
href={`/app/${encodeURIComponent(publisher.name)}${publisher.kind === 'PROCESS' ? '/dev' : ''}`}
|
||||
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{publisher.name}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">: {publisher.events.join(', ')}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
: {publisher.events.join(', ')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -15,10 +15,18 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi, beforeAll, afterAll } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
// jsdom does not implement native viewport scrolling; real menu geometry is covered in acceptance.
|
||||
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
|
||||
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
|
||||
afterAll(() => {
|
||||
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
import type { QTableMetaData, QWidgetMetaData } from '@/types'
|
||||
import type { WidgetFormContext } from './widget-types'
|
||||
|
||||
|
||||
@@ -15,10 +15,18 @@
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
// jsdom does not implement native viewport scrolling; real menu geometry is covered in acceptance.
|
||||
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
|
||||
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
|
||||
afterAll(() => {
|
||||
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
const { previewRecords } = vi.hoisted(() => ({ previewRecords: [] as QRecord[] }))
|
||||
|
||||
vi.mock('@/lib/hooks/use-metadata', () => ({ useTableMetaData: vi.fn(), useMetaData: () => ({ data: undefined }) }))
|
||||
|
||||
@@ -152,9 +152,9 @@ export function QqqParentWidget({ widgetMetaData, data, widgetRegistry, childPar
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 gap-4" data-qqq-id={`parent-widget-${widgetMetaData.name}`} data-layout="GRID">
|
||||
<div className="@container grid grid-cols-12 gap-4" data-qqq-id={`parent-widget-${widgetMetaData.name}`} data-layout="GRID">
|
||||
{children.map((child) => (
|
||||
<div key={child.name} id={child.name} className={`${widgetColumnClasses(child.gridColumns, child.defaultValues)} ${WIDGET_ANCHOR}`}>
|
||||
<div key={child.name} id={child.name} className={`${widgetColumnClasses(child.gridColumns, child.defaultValues, child.type)} ${WIDGET_ANCHOR}`}>
|
||||
<ConnectedWidget
|
||||
widgetMetaData={child}
|
||||
params={childParams}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Inbox } from 'lucide-react'
|
||||
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import { DataCell } from '@/components/query/DataCell'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
|
||||
/** Wire-format payload for a record-grid widget returned by the backend API. */
|
||||
export interface RecordGridWidgetPayload {
|
||||
@@ -126,74 +127,76 @@ export function RecordGridWidget({ data, widgetName }: RecordGridWidgetProps) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-x-auto"
|
||||
data-qqq-id={`record-grid-${widgetName}`}
|
||||
>
|
||||
<table
|
||||
className="w-full border-collapse text-sm"
|
||||
aria-label={`${widgetName} record grid`}
|
||||
<FilePreviewGallery records={qRecords} fields={resolvedFields}>
|
||||
<div
|
||||
className="w-full overflow-x-auto"
|
||||
data-qqq-id={`record-grid-${widgetName}`}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted">
|
||||
{resolvedFields.map((f) => (
|
||||
<th
|
||||
key={f.name}
|
||||
className="px-3 py-2 text-left text-xs font-semibold text-muted-foreground"
|
||||
data-qqq-id={`widget-grid-header-${f.name}`}
|
||||
>
|
||||
{f.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qRecords.map((record, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className="border-b border-border transition-colors hover:bg-accent"
|
||||
data-qqq-id={`widget-grid-row-${widgetName}-${rowIdx}`}
|
||||
>
|
||||
{resolvedFields.map((field) => (
|
||||
<td
|
||||
key={field.name}
|
||||
className="px-3 py-2"
|
||||
data-qqq-id={`widget-grid-cell-${widgetName}-${field.name}`}
|
||||
<table
|
||||
className="w-full border-collapse text-sm"
|
||||
aria-label={`${widgetName} record grid`}
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted">
|
||||
{resolvedFields.map((f) => (
|
||||
<th
|
||||
key={f.name}
|
||||
className="px-3 py-2 text-left text-xs font-semibold text-muted-foreground"
|
||||
data-qqq-id={`widget-grid-header-${f.name}`}
|
||||
>
|
||||
<DataCell
|
||||
field={field}
|
||||
value={record.values[field.name]}
|
||||
displayValue={record.displayValues?.[field.name]}
|
||||
record={record}
|
||||
/>
|
||||
</td>
|
||||
{f.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qRecords.map((record, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className="border-b border-border transition-colors hover:bg-accent"
|
||||
data-qqq-id={`widget-grid-row-${widgetName}-${rowIdx}`}
|
||||
>
|
||||
{resolvedFields.map((field) => (
|
||||
<td
|
||||
key={field.name}
|
||||
className="px-3 py-2"
|
||||
data-qqq-id={`widget-grid-cell-${widgetName}-${field.name}`}
|
||||
>
|
||||
<DataCell
|
||||
field={field}
|
||||
value={record.values[field.name]}
|
||||
displayValue={record.displayValues?.[field.name]}
|
||||
record={record}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* "View all" link when rows are capped at MAX_DISPLAY_ROWS */}
|
||||
{hasMore && tableName && (
|
||||
<div className="mt-2 text-center" data-qqq-id={`widget-grid-view-all-${widgetName}`}>
|
||||
<a
|
||||
href={`/app/${tableName}`}
|
||||
className="text-sm text-primary underline hover:text-primary/80"
|
||||
{/* "View all" link when rows are capped at MAX_DISPLAY_ROWS */}
|
||||
{hasMore && tableName && (
|
||||
<div className="mt-2 text-center" data-qqq-id={`widget-grid-view-all-${widgetName}`}>
|
||||
<a
|
||||
href={`/app/${tableName}`}
|
||||
className="text-sm text-primary underline hover:text-primary/80"
|
||||
>
|
||||
View all {records.length} records →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server-side count footer when backend reports more records than were sent */}
|
||||
{data.totalCount !== undefined && data.totalCount > records.length && (
|
||||
<p
|
||||
className="mt-2 text-center text-xs text-muted-foreground"
|
||||
data-qqq-id={`widget-grid-more-${widgetName}`}
|
||||
>
|
||||
View all {records.length} records →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server-side count footer when backend reports more records than were sent */}
|
||||
{data.totalCount !== undefined && data.totalCount > records.length && (
|
||||
<p
|
||||
className="mt-2 text-center text-xs text-muted-foreground"
|
||||
data-qqq-id={`widget-grid-more-${widgetName}`}
|
||||
>
|
||||
Showing {Math.min(displayedRecords.length, records.length)} of {data.totalCount.toLocaleString()} records
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
Showing {Math.min(displayedRecords.length, records.length)} of {data.totalCount.toLocaleString()} records
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
@@ -25,10 +25,10 @@ import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { QFieldMetaData } from '@/types'
|
||||
import { fetchPossibleValues } from '@/lib/api/possible-values'
|
||||
import { formatDateTime } from '@/lib/utils/datetime-utils'
|
||||
|
||||
import { formatPlainValue } from './record-widget-utils'
|
||||
import type { BuilderRow } from './row-builder-model'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
|
||||
/** Props for {@link RowBuilderTable}. */
|
||||
export interface RowBuilderTableProps {
|
||||
@@ -76,7 +76,7 @@ function cellContent(field: QFieldMetaData, row: BuilderRow): React.ReactNode {
|
||||
const inline = field.inlinePossibleValueSource?.enumValues?.find((option) => String(option.id) === String(value))
|
||||
if (inline) return inline.label
|
||||
if (field.possibleValueSourceName) return <PossibleValueLabel sourceName={field.possibleValueSourceName} value={value} />
|
||||
if (field.type === 'DATE_TIME') return formatDateTime(value) ?? formatPlainValue(value, field.type)
|
||||
if (field.type === 'DATE_TIME' || field.type === 'DATE') return <DateTimeText type={field.type} value={value} />
|
||||
return formatPlainValue(value, field.type)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* pieChart, quickSightChart, statistics, stackedBarChart, stepper, table, usaMap,
|
||||
* process, parentWidget, composite, childRecordList, customComponent, cronUI,
|
||||
* dynamicForm, dataBagViewer, pivotTableSetup, filterAndColumnsSetup, rowBuilder,
|
||||
* scriptViewer, and the ESB module's ESB_OVERVIEW — plus the demo shapes (recordGrid, quickLinks, processSummary,
|
||||
* scriptViewer, the ESB module's ESB_OVERVIEW and the SQL Workbench QBit's SQL_WORKBENCH — plus the demo shapes (recordGrid, quickLinks, processSummary,
|
||||
* block, parent) used by the mocked development API.
|
||||
*
|
||||
* Chart components are loaded lazily so Recharts is split out of the initial bundle.
|
||||
@@ -77,6 +77,7 @@ const LineChartWidget = lazy(() => import('./LineChartWidget').then((m) => ({ de
|
||||
const PieChartWidget = lazy(() => import('./PieChartWidget').then((m) => ({ default: m.PieChartWidget })))
|
||||
const FilterAndColumnsSetupWidget = lazy(() => import('./FilterAndColumnsSetupWidget').then((m) => ({ default: m.FilterAndColumnsSetupWidget })))
|
||||
const PivotTableSetupWidget = lazy(() => import('./PivotTableSetupWidget').then((m) => ({ default: m.PivotTableSetupWidget })))
|
||||
const SqlWorkbenchWidget = lazy(() => import('@/components/sql-workbench/SqlWorkbenchWidget'))
|
||||
const RowBuilderWidget = lazy(() => import('./RowBuilderWidget').then((m) => ({ default: m.RowBuilderWidget })))
|
||||
|
||||
/** Fallback while a lazy chart chunk loads; matches the default chart height. */
|
||||
@@ -215,6 +216,8 @@ export function WidgetRenderer({ widgetMetaData, data, recordContext, formContex
|
||||
return <ScriptViewerWidget {...common} data={data} />
|
||||
case 'ESB_OVERVIEW':
|
||||
return <EsbOverviewWidget widgetMetaData={widgetMetaData} />
|
||||
case 'SQL_WORKBENCH':
|
||||
return <Suspense fallback={SetupFallback}><SqlWorkbenchWidget widgetMetaData={widgetMetaData} /></Suspense>
|
||||
case 'recordGrid':
|
||||
return <RecordGridWidget data={data as unknown as RecordGridWidgetPayload} widgetName={name} />
|
||||
case 'quickLinks':
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
|
||||
describe('widgetColumnClasses with Material per-breakpoint sizes', () => {
|
||||
it('keeps the gridColumns sizing when no sizeClass is declared', () => {
|
||||
expect(widgetColumnClasses(6, { other: 1 })).toBe('col-span-12 min-w-0 lg:col-span-6')
|
||||
expect(widgetColumnClasses(6, { other: 1 })).toBe('col-span-12 @min-[40rem]:col-span-6 min-w-0')
|
||||
})
|
||||
|
||||
it('sizes from the declared breakpoints at Material widths, xs defaulting to 12 and xxl to gridColumns', () => {
|
||||
@@ -38,7 +38,7 @@ describe('widgetColumnClasses with Material per-breakpoint sizes', () => {
|
||||
})
|
||||
|
||||
it('ignores unusable sizes', () => {
|
||||
expect(widgetColumnClasses(5, { 'gridCols:sizeClass:md': 40, 'gridCols:sizeClass:lg': 'wide' })).toBe('col-span-12 min-w-0 lg:col-span-5')
|
||||
expect(widgetColumnClasses(5, { 'gridCols:sizeClass:md': 40, 'gridCols:sizeClass:lg': 'wide' })).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-5 min-w-0')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -73,11 +73,18 @@ describe('dropdown selections', () => {
|
||||
})
|
||||
|
||||
describe('widgetColumnClasses', () => {
|
||||
it('maps gridColumns (twelfths) to spans, defaulting to full width', () => {
|
||||
expect(widgetColumnClasses(4)).toBe('col-span-12 min-w-0 lg:col-span-4')
|
||||
expect(widgetColumnClasses(12)).toBe('col-span-12 min-w-0 lg:col-span-12')
|
||||
expect(widgetColumnClasses(undefined)).toBe('col-span-12 min-w-0 lg:col-span-12')
|
||||
expect(widgetColumnClasses(40)).toBe('col-span-12 min-w-0 lg:col-span-12')
|
||||
it('maps gridColumns (twelfths) to spans by the grid width, defaulting to full width', () => {
|
||||
expect(widgetColumnClasses(4)).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 min-w-0')
|
||||
expect(widgetColumnClasses(3)).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-3 min-w-0')
|
||||
expect(widgetColumnClasses(12)).toBe('col-span-12 min-w-0')
|
||||
expect(widgetColumnClasses(undefined)).toBe('col-span-12 min-w-0')
|
||||
expect(widgetColumnClasses(40)).toBe('col-span-12 min-w-0')
|
||||
})
|
||||
|
||||
it('gives table widgets card-sized room before they sit side by side', () => {
|
||||
expect(widgetColumnClasses(6, undefined, 'childRecordList')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
|
||||
expect(widgetColumnClasses(6, undefined, 'table')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
|
||||
expect(widgetColumnClasses(6, undefined, 'statistics')).toBe('col-span-12 @min-[40rem]:col-span-6 min-w-0')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import type { QHelpContent, QWidgetDropdown, QWidgetHelpContent, QWidgetMetaData } from '@/types'
|
||||
import { WIDGET_HELP_ROLES, selectSlotHelpContent } from '@/lib/utils/help-utils'
|
||||
import { containerSpanClasses } from '@/lib/utils/responsive-grid'
|
||||
|
||||
/** Root of the local-storage keys that persist widget dropdown selections (shared with Material). */
|
||||
export const WIDGET_DROPDOWN_STORAGE_ROOT = 'qqq.widgets.dropdownData'
|
||||
@@ -289,11 +290,8 @@ export function downloadText(fileName: string, text: string, mimeType = 'text/cs
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
/** Tailwind classes for QQQ's 12-column widget sizing (static strings for the JIT). */
|
||||
const COLUMN_SPANS: Record<number, string> = {
|
||||
1: 'lg:col-span-1', 2: 'lg:col-span-2', 3: 'lg:col-span-3', 4: 'lg:col-span-4', 5: 'lg:col-span-5', 6: 'lg:col-span-6',
|
||||
7: 'lg:col-span-7', 8: 'lg:col-span-8', 9: 'lg:col-span-9', 10: 'lg:col-span-10', 11: 'lg:col-span-11', 12: 'lg:col-span-12',
|
||||
}
|
||||
/** Widget types that render tables, which need card-sized room before they sit side by side. */
|
||||
const TABLE_WIDGET_TYPES = new Set(['childRecordList', 'table', 'multiTable', 'recordGrid', 'rowBuilder', 'dataBagViewer'])
|
||||
|
||||
/** Material Dashboard's grid breakpoints (xs 0, sm 576, md 768, lg 992, xl 1200, xxl 1400 px). */
|
||||
const SIZE_CLASSES = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const
|
||||
@@ -320,8 +318,10 @@ function columnSpan(value: unknown): number | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Column-span classes for a widget in a 12-column grid: full width on small
|
||||
* screens, `gridColumns` of 12 (default 12, as in Material) on large screens.
|
||||
* Column-span classes for a widget in a 12-column `@container` grid: full width in a narrow
|
||||
* grid, `gridColumns` of 12 (default 12, as in Material) once the grid is wide enough for
|
||||
* widgets of that width to sit in a row; table widgets need more room than charts and
|
||||
* statistics before they do (see `responsive-grid`).
|
||||
*
|
||||
* A widget that declares per-breakpoint sizes (`gridCols:sizeClass:{xs,sm,md,lg,xl,xxl}` in its
|
||||
* `defaultValues`) is sized as Material sizes it: each declared breakpoint (Material's widths)
|
||||
@@ -329,9 +329,10 @@ function columnSpan(value: unknown): number | undefined {
|
||||
*
|
||||
* @param gridColumns - Widget metadata grid columns.
|
||||
* @param defaultValues - Widget metadata default values (per-breakpoint overrides).
|
||||
* @param widgetType - Widget type, so table widgets get card-sized room.
|
||||
* @returns Tailwind classes.
|
||||
*/
|
||||
export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record<string, unknown>): string {
|
||||
export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record<string, unknown>, widgetType?: string): string {
|
||||
const sizes = Object.fromEntries(SIZE_CLASSES.map((size) => [size, columnSpan(defaultValues?.[`gridCols:sizeClass:${size}`])]))
|
||||
if (Object.values(sizes).some((span) => span !== undefined)) {
|
||||
sizes.xs ??= 12
|
||||
@@ -339,8 +340,7 @@ export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record
|
||||
const classes = SIZE_CLASSES.flatMap((size) => (sizes[size] ? [SIZE_CLASS_SPANS[size][sizes[size]]] : []))
|
||||
return `${classes.join(' ')} min-w-0`
|
||||
}
|
||||
const span = columnSpan(gridColumns) ?? 12
|
||||
return `col-span-12 min-w-0 ${COLUMN_SPANS[span]}`
|
||||
return containerSpanClasses(columnSpan(gridColumns) ?? 12, widgetType && TABLE_WIDGET_TYPES.has(widgetType) ? 'cards' : 'widgets')
|
||||
}
|
||||
|
||||
/** Classes for a dashboard grid item: an in-page anchor target (`#widgetName`) clear of the header. */
|
||||
|
||||
+111
-10
@@ -27,7 +27,7 @@ import {
|
||||
orderFulfillmentMessages,
|
||||
} from '@/mocks/fixtures/esb'
|
||||
import { server } from '@/mocks/node'
|
||||
import { processInit } from './processes'
|
||||
import { processInit, processStatus } from './processes'
|
||||
import {
|
||||
getEsbDeadLetters,
|
||||
getEsbForProcess,
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
runEsbAction,
|
||||
} from './esb'
|
||||
|
||||
vi.mock('./processes', () => ({ processInit: vi.fn() }))
|
||||
vi.mock('./processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
|
||||
|
||||
describe('getEsbForTable', () => {
|
||||
it('returns the table ESB section from GET /qqq/v1/esb/table/{table}', async () => {
|
||||
@@ -166,13 +166,15 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
|
||||
return HttpResponse.json(orderFulfillmentMessages)
|
||||
})
|
||||
)
|
||||
await expect(getEsbMessages('orderFulfillment', 50, 25)).resolves.toEqual(orderFulfillmentMessages)
|
||||
await expect(getEsbMessages('orderFulfillment', 50, 25)).resolves.toEqual(
|
||||
orderFulfillmentMessages
|
||||
)
|
||||
expect(url.pathname).toBe('/qqq/v1/esb/messages/orderFulfillment')
|
||||
expect(url.searchParams.get('offset')).toBe('50')
|
||||
expect(url.searchParams.get('limit')).toBe('25')
|
||||
})
|
||||
|
||||
it('browse a trigger\'s dead letters, 50 at a time from the start by default', async () => {
|
||||
it("browse a trigger's dead letters, 50 at a time from the start by default", async () => {
|
||||
let url = new URL('http://localhost')
|
||||
server.use(
|
||||
http.get('/qqq/v1/esb/deadLetters/:trigger', ({ request }) => {
|
||||
@@ -188,6 +190,33 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
|
||||
expect(url.searchParams.get('limit')).toBe('50')
|
||||
})
|
||||
|
||||
it('browses the named topic subscription with the backend trigger parameter', async () => {
|
||||
let url = new URL('http://localhost')
|
||||
server.use(
|
||||
http.get('/qqq/v1/esb/messages/:destination', ({ request }) => {
|
||||
url = new URL(request.url)
|
||||
return HttpResponse.json(orderFulfillmentMessages)
|
||||
})
|
||||
)
|
||||
await expect(getEsbMessages('orderEvents', 50, 25, 'cancelOrder.orderEvents')).resolves.toEqual(
|
||||
orderFulfillmentMessages
|
||||
)
|
||||
expect(url.pathname).toBe('/qqq/v1/esb/messages/orderEvents')
|
||||
expect(url.searchParams.get('trigger')).toBe('cancelOrder.orderEvents')
|
||||
expect(url.searchParams.get('offset')).toBe('50')
|
||||
expect(url.searchParams.get('limit')).toBe('25')
|
||||
})
|
||||
|
||||
it('serves the demo topic subscription and rejects missing or unrelated triggers', async () => {
|
||||
await expect(getEsbMessages('orderEvents', 0, 50, 'cancelOrder.orderEvents')).resolves.toEqual(
|
||||
orderFulfillmentMessages
|
||||
)
|
||||
await expect(getEsbMessages('orderEvents')).rejects.toThrow()
|
||||
await expect(
|
||||
getEsbMessages('orderEvents', 0, 50, 'fulfillOrder.orderFulfillment')
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a body that is not a message page', async () => {
|
||||
server.use(http.get('/qqq/v1/esb/messages/orderFulfillment', () => HttpResponse.json({})))
|
||||
await expect(getEsbMessages('orderFulfillment')).rejects.toThrow('Invalid ESB message page')
|
||||
@@ -206,6 +235,7 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
|
||||
describe('runEsbAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(processInit).mockReset()
|
||||
vi.mocked(processStatus).mockReset()
|
||||
})
|
||||
|
||||
it('runs the operate process with the values and returns its count and message', async () => {
|
||||
@@ -215,7 +245,10 @@ describe('runEsbAction', () => {
|
||||
values: { count: 7, message: 'Purged 7 messages.' },
|
||||
})
|
||||
await expect(
|
||||
runEsbAction('esbPurgeQueue', { providerName: 'artemis', brokerQueueName: 'orderFulfillment' })
|
||||
runEsbAction('esbPurgeQueue', {
|
||||
providerName: 'artemis',
|
||||
brokerQueueName: 'orderFulfillment',
|
||||
})
|
||||
).resolves.toEqual({ count: 7, message: 'Purged 7 messages.' })
|
||||
expect(processInit).toHaveBeenCalledWith('esbPurgeQueue', {
|
||||
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
|
||||
@@ -243,11 +276,79 @@ describe('runEsbAction', () => {
|
||||
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('The broker refused the purge.')
|
||||
})
|
||||
|
||||
it('says the action is still running when it continues as a job', async () => {
|
||||
vi.mocked(processInit).mockResolvedValue({ type: 'JOB_STARTED', processUUID: 'uuid', jobUUID: 'job' })
|
||||
await expect(runEsbAction('esbRestartTrigger', {})).resolves.toEqual({
|
||||
count: null,
|
||||
message: 'The action is still running.',
|
||||
it.each(['process error', 'request failure'])(
|
||||
'rejects an async %s instead of reporting success',
|
||||
async (failure) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'JOB_STARTED',
|
||||
processUUID: 'uuid',
|
||||
jobUUID: 'job',
|
||||
})
|
||||
if (failure === 'process error') {
|
||||
vi.mocked(processStatus).mockResolvedValue({
|
||||
type: 'ERROR',
|
||||
error: 'Technical failure',
|
||||
userFacingError: 'Replay failed; messages remain.',
|
||||
})
|
||||
} else {
|
||||
vi.mocked(processStatus).mockRejectedValue(new Error('Status unavailable'))
|
||||
}
|
||||
const assertion = expect(
|
||||
runEsbAction('esbReplayDeadLetters', { all: true })
|
||||
).rejects.toThrow(
|
||||
failure === 'process error' ? 'Replay failed; messages remain.' : 'Status unavailable'
|
||||
)
|
||||
await vi.runAllTimersAsync()
|
||||
await assertion
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('does not report success for a running response with no job identifier', async () => {
|
||||
vi.mocked(processInit).mockResolvedValue({ type: 'RUNNING', processUUID: 'uuid' })
|
||||
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('no job identifier')
|
||||
})
|
||||
|
||||
it('does not report a completed intermediate step as the final action outcome', async () => {
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'COMPLETE',
|
||||
processUUID: 'uuid',
|
||||
nextStep: 'confirm',
|
||||
values: {},
|
||||
})
|
||||
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('additional step')
|
||||
})
|
||||
|
||||
it('waits for the final outcome through running job statuses', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(processInit).mockResolvedValue({
|
||||
type: 'JOB_STARTED',
|
||||
processUUID: 'uuid',
|
||||
jobUUID: 'job',
|
||||
})
|
||||
vi.mocked(processStatus)
|
||||
.mockResolvedValueOnce({ type: 'RUNNING', processUUID: 'uuid' })
|
||||
.mockResolvedValueOnce({
|
||||
type: 'COMPLETE',
|
||||
processUUID: 'uuid',
|
||||
values: { count: 3, message: 'Replayed 3 messages.' },
|
||||
})
|
||||
const settled = vi.fn()
|
||||
const result = runEsbAction('esbReplayDeadLetters', { all: true }).then(settled)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
await vi.runAllTimersAsync()
|
||||
await result
|
||||
expect(processStatus).toHaveBeenCalledWith('esbReplayDeadLetters', 'uuid', 'job')
|
||||
expect(settled).toHaveBeenCalledWith({ count: 3, message: 'Replayed 3 messages.' })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
+31
-9
@@ -26,7 +26,7 @@ import type {
|
||||
} from '@/types'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import apiClient from './client'
|
||||
import { processInit } from './processes'
|
||||
import { processInit, processStatus } from './processes'
|
||||
|
||||
/** Messages per page when browsing, the backend's default. */
|
||||
export const ESB_PAGE_SIZE = 50
|
||||
@@ -87,15 +87,22 @@ export async function getEsbOverview(): Promise<EsbOverviewResponse | null> {
|
||||
* @param destinationName - QQQ destination name.
|
||||
* @param offset - Messages to skip.
|
||||
* @param limit - Messages to return.
|
||||
* @param triggerName - Required for a topic: the trigger whose subscription to browse.
|
||||
* @returns The page of messages.
|
||||
* @throws The request error, or an error when the body is not a message page.
|
||||
*/
|
||||
export async function getEsbMessages(
|
||||
destinationName: string,
|
||||
offset = 0,
|
||||
limit = ESB_PAGE_SIZE
|
||||
limit = ESB_PAGE_SIZE,
|
||||
triggerName?: string
|
||||
): Promise<EsbMessagePage> {
|
||||
return getMessagePage(`/esb/messages/${encodeURIComponent(destinationName)}`, offset, limit)
|
||||
return getMessagePage(
|
||||
`/esb/messages/${encodeURIComponent(destinationName)}`,
|
||||
offset,
|
||||
limit,
|
||||
triggerName
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,19 +127,26 @@ export async function getEsbDeadLetters(
|
||||
*
|
||||
* @param processName - The operate process, e.g. `esbPurgeQueue`.
|
||||
* @param values - Its input values, e.g. `providerName` and `brokerQueueName`.
|
||||
* @returns The process's `count` and `message`; a generic message while it continues as a job.
|
||||
* @returns The completed process's `count` and `message`.
|
||||
* @throws An error with the process's user-facing message when it fails or is refused.
|
||||
*/
|
||||
export async function runEsbAction(
|
||||
processName: string,
|
||||
values: Record<string, string | boolean>
|
||||
): Promise<EsbActionResult> {
|
||||
const response = await processInit(processName, { values })
|
||||
let response = await processInit(processName, { values })
|
||||
let jobUUID: string | undefined
|
||||
while (response.type === 'JOB_STARTED' || response.type === 'RUNNING') {
|
||||
if (response.type === 'JOB_STARTED') jobUUID = response.jobUUID
|
||||
if (!jobUUID) throw new Error('The action returned no job identifier to check its outcome.')
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
response = await processStatus(processName, response.processUUID, jobUUID)
|
||||
}
|
||||
if (response.type === 'ERROR') {
|
||||
throw new Error(response.userFacingError ?? response.error)
|
||||
}
|
||||
if (response.type !== 'COMPLETE') {
|
||||
return { count: null, message: 'The action is still running.' }
|
||||
if (response.nextStep) {
|
||||
throw new Error('The action requires an unexpected additional step.')
|
||||
}
|
||||
const count = Number(response.values.count)
|
||||
const message = response.values.message
|
||||
@@ -165,11 +179,19 @@ async function getOrNull(path: string): Promise<unknown> {
|
||||
* @param path - Path under the API base URL.
|
||||
* @param offset - Messages to skip.
|
||||
* @param limit - Messages to return.
|
||||
* @param triggerName - Optional topic trigger name.
|
||||
* @returns The page.
|
||||
* @throws The request error, or an error when the body is not a message page.
|
||||
*/
|
||||
async function getMessagePage(path: string, offset: number, limit: number): Promise<EsbMessagePage> {
|
||||
const data = await apiClient.get<unknown>(path, { params: { offset, limit } })
|
||||
async function getMessagePage(
|
||||
path: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
triggerName?: string
|
||||
): Promise<EsbMessagePage> {
|
||||
const data = await apiClient.get<unknown>(path, {
|
||||
params: { offset, limit, ...(triggerName ? { trigger: triggerName } : {}) },
|
||||
})
|
||||
if (!hasArrays<EsbMessagePage>(data, 'messages')) {
|
||||
throw new Error('Invalid ESB message page')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the SQL Workbench API client
|
||||
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
import {
|
||||
sqlCapabilities,
|
||||
sqlExplain,
|
||||
sqlNotebook,
|
||||
sqlSchema,
|
||||
sqlSelectResult,
|
||||
sqlWritePreview,
|
||||
} from '@/mocks/fixtures/sql-workbench'
|
||||
import {
|
||||
cancelSqlExecution,
|
||||
createSqlNotebook,
|
||||
createSqlSnapshot,
|
||||
deleteSqlNotebook,
|
||||
deleteSqlSnapshot,
|
||||
duplicateSqlNotebook,
|
||||
executeSql,
|
||||
explainSql,
|
||||
getSqlHistory,
|
||||
getSqlNotebook,
|
||||
getSqlRows,
|
||||
getSqlSchema,
|
||||
getSqlWorkbenchCapabilities,
|
||||
listSqlNotebooks,
|
||||
listSqlNotebookVersions,
|
||||
listSqlSnapshots,
|
||||
publishSqlNotebook,
|
||||
restoreSqlNotebookVersion,
|
||||
saveSqlNotebook,
|
||||
sqlExportUrl,
|
||||
} from './sql-workbench'
|
||||
|
||||
const BASE = '/qqq/v1/sql-workbench'
|
||||
|
||||
describe('getSqlWorkbenchCapabilities', () => {
|
||||
it('returns the capabilities', async () => {
|
||||
expect(await getSqlWorkbenchCapabilities()).toEqual(sqlCapabilities)
|
||||
})
|
||||
|
||||
it.each([403, 404])('treats %i as unavailable', async (status) => {
|
||||
server.use(
|
||||
http.get(`${BASE}/capabilities`, () => HttpResponse.json({ error: 'x' }, { status }))
|
||||
)
|
||||
expect(await getSqlWorkbenchCapabilities()).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a non-workbench body (the SPA fallback) as unavailable', async () => {
|
||||
server.use(http.get(`${BASE}/capabilities`, () => HttpResponse.text('<html></html>')))
|
||||
expect(await getSqlWorkbenchCapabilities()).toBeNull()
|
||||
})
|
||||
|
||||
it('throws other errors', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/capabilities`, () => HttpResponse.json({ error: 'boom' }, { status: 500 }))
|
||||
)
|
||||
await expect(getSqlWorkbenchCapabilities()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('running SQL', () => {
|
||||
it('loads a schema, with and without refresh', async () => {
|
||||
let refresh: string | null = null
|
||||
server.use(
|
||||
http.get(`${BASE}/schema/:backend`, ({ request }) => {
|
||||
refresh = new URL(request.url).searchParams.get('refresh')
|
||||
return HttpResponse.json(sqlSchema)
|
||||
})
|
||||
)
|
||||
expect(await getSqlSchema('postgres')).toEqual(sqlSchema)
|
||||
expect(refresh).toBeNull()
|
||||
await getSqlSchema('postgres', true)
|
||||
expect(refresh).toBe('true')
|
||||
})
|
||||
|
||||
it('executes read-only and write requests', async () => {
|
||||
expect(await executeSql({ backendName: 'postgres', sql: 'SELECT 1' })).toEqual(sqlSelectResult)
|
||||
expect(await executeSql({ backendName: 'postgres', sql: 'UPDATE x', mode: 'WRITE' })).toEqual(
|
||||
sqlWritePreview
|
||||
)
|
||||
})
|
||||
|
||||
it('explains', async () => {
|
||||
expect(await explainSql({ backendName: 'postgres', sql: 'SELECT 1' })).toEqual(sqlExplain)
|
||||
})
|
||||
|
||||
it('pages with and without sorting', async () => {
|
||||
const seen: string[] = []
|
||||
server.use(
|
||||
http.get(`${BASE}/executions/:uuid/rows`, ({ request }) => {
|
||||
seen.push(new URL(request.url).search)
|
||||
return HttpResponse.json({ rows: [] })
|
||||
})
|
||||
)
|
||||
await getSqlRows('exec-1', 0, 10)
|
||||
await getSqlRows('exec-1', 10, 10, 2, 'desc')
|
||||
await getSqlRows('exec-1', 10, 10, 1)
|
||||
expect(seen).toEqual([
|
||||
'?offset=0&limit=10',
|
||||
'?offset=10&limit=10&sort=2&dir=desc',
|
||||
'?offset=10&limit=10&sort=1&dir=asc',
|
||||
])
|
||||
})
|
||||
|
||||
it('cancels, exports and lists history', async () => {
|
||||
expect(await cancelSqlExecution('exec-1')).toBe(true)
|
||||
expect(sqlExportUrl('exec 1', 'xlsx')).toBe(
|
||||
'/qqq/v1/sql-workbench/executions/exec%201/export?format=xlsx'
|
||||
)
|
||||
expect(await getSqlHistory()).toHaveLength(1)
|
||||
let params = ''
|
||||
server.use(
|
||||
http.get(`${BASE}/history`, ({ request }) => {
|
||||
params = new URL(request.url).search
|
||||
return HttpResponse.json({})
|
||||
})
|
||||
)
|
||||
expect(await getSqlHistory(5, 'postgres')).toEqual([])
|
||||
expect(params).toBe('?limit=5&backendName=postgres')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notebooks', () => {
|
||||
it('lists, loads, creates, saves and deletes', async () => {
|
||||
expect((await listSqlNotebooks('mine'))[0].title).toBe('Monthly signups')
|
||||
expect((await listSqlNotebooks('shared'))[0].visibility).toBe('SHARED')
|
||||
expect(await getSqlNotebook(1)).toEqual(sqlNotebook)
|
||||
expect((await createSqlNotebook({ title: 'New' })).title).toBe('New')
|
||||
expect((await saveSqlNotebook(1, { title: 'Renamed' })).title).toBe('Renamed')
|
||||
await expect(deleteSqlNotebook(1)).resolves.toBeUndefined()
|
||||
server.use(http.get(`${BASE}/notebooks`, () => HttpResponse.json({})))
|
||||
expect(await listSqlNotebooks('mine')).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes, duplicates, versions and snapshots', async () => {
|
||||
expect((await publishSqlNotebook(1, true)).visibility).toBe('SHARED')
|
||||
expect((await duplicateSqlNotebook(1)).id).toBe(10)
|
||||
expect(await listSqlNotebookVersions(1)).toHaveLength(2)
|
||||
expect((await restoreSqlNotebookVersion(1, 1)).currentVersion).toBe(3)
|
||||
expect(await listSqlSnapshots(1)).toEqual([])
|
||||
expect((await createSqlSnapshot(1, { executionUuid: 'exec-1', cellKey: 'c1' })).id).toBe(5)
|
||||
await expect(deleteSqlSnapshot(1, 5)).resolves.toBeUndefined()
|
||||
|
||||
server.use(
|
||||
http.get(`${BASE}/notebooks/:id/versions`, () => HttpResponse.json({})),
|
||||
http.get(`${BASE}/notebooks/:id/snapshots`, () => HttpResponse.json({}))
|
||||
)
|
||||
expect(await listSqlNotebookVersions(1)).toEqual([])
|
||||
expect(await listSqlSnapshots(1)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* 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 SQL Workbench API — permission-checked `/qqq/v1/sql-workbench` endpoints served by the
|
||||
* qbit-sql-workbench QBit. SQL errors come back as results (status FAILED / TIMED_OUT /
|
||||
* CANCELLED), not as request errors; request errors are 400 / 401 / 403 / 404.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SqlExecuteRequest,
|
||||
SqlExecuteResponse,
|
||||
SqlExplainResponse,
|
||||
SqlExportFormat,
|
||||
SqlHistoryEntry,
|
||||
SqlNotebook,
|
||||
SqlNotebookInput,
|
||||
SqlNotebookSummary,
|
||||
SqlNotebookVersionSummary,
|
||||
SqlResultSnapshot,
|
||||
SqlRowsPage,
|
||||
SqlSchema,
|
||||
SqlWorkbenchCapabilities,
|
||||
} from '@/types'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import apiClient, { apiUrl } from './client'
|
||||
|
||||
/** Base path of the workbench routes under the API base URL. */
|
||||
export const SQL_WORKBENCH_PATH = '/sql-workbench'
|
||||
|
||||
/**
|
||||
* Loads what the caller may do, the limits and the target backends from `GET /capabilities`.
|
||||
*
|
||||
* 403 (no access), 404, and a 2xx body that is not a capabilities object (the SPA fallback when
|
||||
* the backend does not have the QBit) all mean the workbench is unavailable.
|
||||
*
|
||||
* @returns The capabilities, or `null` when the workbench is unavailable to this user.
|
||||
* @throws The request error for any other failure.
|
||||
*/
|
||||
export async function getSqlWorkbenchCapabilities(): Promise<SqlWorkbenchCapabilities | null> {
|
||||
try {
|
||||
const data = await apiClient.get<unknown>(`${SQL_WORKBENCH_PATH}/capabilities`)
|
||||
return isObject(data) && Array.isArray(data.backends) && isObject(data.limits)
|
||||
? (data as unknown as SqlWorkbenchCapabilities)
|
||||
: null
|
||||
} catch (error) {
|
||||
const status = getErrorStatusCode(error)
|
||||
if (status === 403 || status === 404) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a backend's schema for autocomplete from `GET /schema/{backend}`.
|
||||
*
|
||||
* @param backendName - Target backend.
|
||||
* @param refresh - Re-read instead of using the server's cache.
|
||||
* @returns The schema.
|
||||
*/
|
||||
export async function getSqlSchema(backendName: string, refresh = false): Promise<SqlSchema> {
|
||||
return apiClient.get<SqlSchema>(
|
||||
`${SQL_WORKBENCH_PATH}/schema/${encodeURIComponent(backendName)}`,
|
||||
{
|
||||
params: refresh ? { refresh: true } : undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs SQL via `POST /execute`: read-only, a write preview (mode WRITE without a token), or a
|
||||
* write commit (mode WRITE with the preview's `confirmToken`).
|
||||
*
|
||||
* @param request - The run.
|
||||
* @returns The outcome.
|
||||
*/
|
||||
export async function executeSql(request: SqlExecuteRequest): Promise<SqlExecuteResponse> {
|
||||
return apiClient.post<SqlExecuteResponse>(`${SQL_WORKBENCH_PATH}/execute`, request, {
|
||||
timeout: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Explains one statement via `POST /explain`.
|
||||
*
|
||||
* @param request - Backend, SQL, parameters, and whether to ANALYZE (executes; rolled back).
|
||||
* @param request.backendName - Target backend.
|
||||
* @param request.sql - The statement.
|
||||
* @param request.params - Parameter values.
|
||||
* @param request.analyze - Run EXPLAIN ANALYZE.
|
||||
* @returns The plan.
|
||||
*/
|
||||
export async function explainSql(request: {
|
||||
backendName: string
|
||||
sql: string
|
||||
params?: Record<string, string | number | boolean | null>
|
||||
analyze?: boolean
|
||||
}): Promise<SqlExplainResponse> {
|
||||
return apiClient.post<SqlExplainResponse>(`${SQL_WORKBENCH_PATH}/explain`, request, {
|
||||
timeout: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages (and optionally sorts) a cached result via `GET /executions/{uuid}/rows`.
|
||||
*
|
||||
* @param executionUuid - The run.
|
||||
* @param offset - Rows to skip.
|
||||
* @param limit - Rows to return.
|
||||
* @param sort - Column index to sort by.
|
||||
* @param dir - Sort direction.
|
||||
* @returns The page.
|
||||
*/
|
||||
export async function getSqlRows(
|
||||
executionUuid: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
sort?: number,
|
||||
dir?: 'asc' | 'desc'
|
||||
): Promise<SqlRowsPage> {
|
||||
return apiClient.get<SqlRowsPage>(
|
||||
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/rows`,
|
||||
{
|
||||
params: { offset, limit, ...(sort == null ? {} : { sort, dir: dir ?? 'asc' }) },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a running execution via `POST /executions/{uuid}/cancel`.
|
||||
*
|
||||
* @param executionUuid - The run.
|
||||
* @returns Whether a running execution was found and cancelled.
|
||||
*/
|
||||
export async function cancelSqlExecution(executionUuid: string): Promise<boolean> {
|
||||
const data = await apiClient.post<{ cancelled: boolean }>(
|
||||
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/cancel`,
|
||||
{}
|
||||
)
|
||||
return data.cancelled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* The download URL of a cached result export (the browser opens it, sending its session cookie).
|
||||
*
|
||||
* @param executionUuid - The run.
|
||||
* @param format - csv, xlsx or json.
|
||||
* @returns The URL.
|
||||
*/
|
||||
export function sqlExportUrl(executionUuid: string, format: SqlExportFormat): string {
|
||||
return apiUrl(
|
||||
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/export?format=${format}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's recent executions from `GET /history`.
|
||||
*
|
||||
* @param limit - How many (server caps at 200).
|
||||
* @param backendName - Only this backend.
|
||||
* @returns Newest first.
|
||||
*/
|
||||
export async function getSqlHistory(limit = 50, backendName?: string): Promise<SqlHistoryEntry[]> {
|
||||
const data = await apiClient.get<{ executions: SqlHistoryEntry[] }>(
|
||||
`${SQL_WORKBENCH_PATH}/history`,
|
||||
{
|
||||
params: { limit, ...(backendName ? { backendName } : {}) },
|
||||
}
|
||||
)
|
||||
return data.executions ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists notebooks.
|
||||
*
|
||||
* @param scope - The caller's own, or the shared library.
|
||||
* @returns Summaries, most recently modified first.
|
||||
*/
|
||||
export async function listSqlNotebooks(scope: 'mine' | 'shared'): Promise<SqlNotebookSummary[]> {
|
||||
const data = await apiClient.get<{ notebooks: SqlNotebookSummary[] }>(
|
||||
`${SQL_WORKBENCH_PATH}/notebooks`,
|
||||
{
|
||||
params: { scope },
|
||||
}
|
||||
)
|
||||
return data.notebooks ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one notebook with its cells.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns The notebook.
|
||||
*/
|
||||
export async function getSqlNotebook(id: number): Promise<SqlNotebook> {
|
||||
return apiClient.get<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a personal notebook.
|
||||
*
|
||||
* @param input - Title, cells and parameters.
|
||||
* @returns The new notebook (version 1).
|
||||
*/
|
||||
export async function createSqlNotebook(input: SqlNotebookInput): Promise<SqlNotebook> {
|
||||
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks`, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a notebook (its cells are replaced; a new version is recorded).
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param input - The new content.
|
||||
* @returns The saved notebook.
|
||||
*/
|
||||
export async function saveSqlNotebook(id: number, input: SqlNotebookInput): Promise<SqlNotebook> {
|
||||
return apiClient.put<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}`, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a notebook (owner only).
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
*/
|
||||
export async function deleteSqlNotebook(id: number): Promise<void> {
|
||||
await apiClient.delete(`${SQL_WORKBENCH_PATH}/notebooks/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a notebook to, or withdraws it from, the shared library.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param shared - Publish (true) or withdraw (false).
|
||||
* @returns The notebook.
|
||||
*/
|
||||
export async function publishSqlNotebook(id: number, shared: boolean): Promise<SqlNotebook> {
|
||||
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/publish`, { shared })
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a personal copy of a readable notebook.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns The copy.
|
||||
*/
|
||||
export async function duplicateSqlNotebook(id: number): Promise<SqlNotebook> {
|
||||
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/duplicate`, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists a notebook's versions.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns Newest first.
|
||||
*/
|
||||
export async function listSqlNotebookVersions(id: number): Promise<SqlNotebookVersionSummary[]> {
|
||||
const data = await apiClient.get<{ versions: SqlNotebookVersionSummary[] }>(
|
||||
`${SQL_WORKBENCH_PATH}/notebooks/${id}/versions`
|
||||
)
|
||||
return data.versions ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a version (saved as a new version).
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param versionNumber - Version to restore.
|
||||
* @returns The notebook.
|
||||
*/
|
||||
export async function restoreSqlNotebookVersion(
|
||||
id: number,
|
||||
versionNumber: number
|
||||
): Promise<SqlNotebook> {
|
||||
return apiClient.post<SqlNotebook>(
|
||||
`${SQL_WORKBENCH_PATH}/notebooks/${id}/versions/${versionNumber}/restore`,
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists a notebook's pinned results.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns Newest first.
|
||||
*/
|
||||
export async function listSqlSnapshots(id: number): Promise<SqlResultSnapshot[]> {
|
||||
const data = await apiClient.get<{ snapshots: SqlResultSnapshot[] }>(
|
||||
`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots`
|
||||
)
|
||||
return data.snapshots ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins a cached result of the caller to a cell.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param input - The run, the cell, an optional label.
|
||||
* @param input.executionUuid - The run to pin.
|
||||
* @param input.cellKey - The cell it belongs to.
|
||||
* @param input.label - Optional label.
|
||||
* @returns The snapshot.
|
||||
*/
|
||||
export async function createSqlSnapshot(
|
||||
id: number,
|
||||
input: { executionUuid: string; cellKey: string; label?: string }
|
||||
): Promise<SqlResultSnapshot> {
|
||||
return apiClient.post<SqlResultSnapshot>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots`, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpins a snapshot.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param snapshotId - Snapshot id.
|
||||
*/
|
||||
export async function deleteSqlSnapshot(id: number, snapshotId: number): Promise<void> {
|
||||
await apiClient.delete(`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots/${snapshotId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is a plain object.
|
||||
*
|
||||
* @param value - Anything.
|
||||
* @returns True for non-null, non-array objects.
|
||||
*/
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -30,9 +30,10 @@ vi.mock('@/lib/api/auth', () => ({
|
||||
clearAuthMetadataCache: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createPasswordSession, logout, manageSession, resumeSession } from '@/lib/api/auth'
|
||||
import { createPasswordSession, getAuthenticationMetaData, logout, manageSession, resumeSession } from '@/lib/api/auth'
|
||||
import { AuthProvider } from './auth-provider'
|
||||
import { useAuth } from './use-auth'
|
||||
import * as oidc from './oidc'
|
||||
import { storeUser, getStoredUser, hasSessionHint, setSessionHint } from './auth-storage'
|
||||
|
||||
let auth: ReturnType<typeof useAuth> | null = null
|
||||
@@ -102,6 +103,62 @@ describe('AuthProvider with TABLE_BASED authentication', () => {
|
||||
write.mockRestore()
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps authentication loading until logout settles (failure: %s)', async (fails) => {
|
||||
let finishLogout!: () => void
|
||||
vi.mocked(logout).mockReturnValue(new Promise<void>((resolve, reject) => {
|
||||
finishLogout = () => fails ? reject(new Error('Logout unavailable')) : resolve()
|
||||
}))
|
||||
vi.mocked(createPasswordSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
await screen.findByText('signed out: no error')
|
||||
await act(async () => { await auth?.signInWithPassword('tess.table', 'table:pass-2026') })
|
||||
let pendingLogout: Promise<void> | undefined
|
||||
await act(async () => { pendingLogout = auth?.logout() })
|
||||
expect(screen.getByText('loading')).toBeInTheDocument()
|
||||
expect(auth?.isAuthenticated).toBe(false)
|
||||
expect(auth?.user).toBeNull()
|
||||
await act(async () => { finishLogout(); await pendingLogout })
|
||||
expect(screen.getByText('signed out: no error')).toBeInTheDocument()
|
||||
expect(auth?.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves loading when provider logout falls back to the local login page', async () => {
|
||||
vi.mocked(getAuthenticationMetaData).mockResolvedValueOnce({ name: 'oauth', type: 'OAUTH2' })
|
||||
vi.mocked(resumeSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
|
||||
vi.mocked(logout).mockResolvedValue(undefined)
|
||||
setSessionHint(true)
|
||||
const endSession = vi.spyOn(oidc, 'buildEndSessionUrl').mockRejectedValue(new Error('Provider unavailable'))
|
||||
try {
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
await screen.findByText('Tess Table <tess.table>')
|
||||
await act(async () => { await auth?.logout() })
|
||||
expect(endSession).toHaveBeenCalled()
|
||||
expect(screen.getByText('signed out: no error')).toBeInTheDocument()
|
||||
expect(auth?.isLoading).toBe(false)
|
||||
} finally {
|
||||
endSession.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps loading after provider logout starts an external navigation', async () => {
|
||||
vi.mocked(getAuthenticationMetaData).mockResolvedValueOnce({ name: 'oauth', type: 'OAUTH2' })
|
||||
vi.mocked(resumeSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
|
||||
vi.mocked(logout).mockResolvedValue(undefined)
|
||||
setSessionHint(true)
|
||||
const endSession = vi.spyOn(oidc, 'buildEndSessionUrl').mockResolvedValue('https://idp.example/logout')
|
||||
try {
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
await screen.findByText('Tess Table <tess.table>')
|
||||
// jsdom leaves the document in place when location.assign starts navigation.
|
||||
await act(async () => { await auth?.logout() })
|
||||
expect(endSession).toHaveBeenCalled()
|
||||
expect(screen.getByText('loading')).toBeInTheDocument()
|
||||
expect(auth?.isLoading).toBe(true)
|
||||
} finally {
|
||||
endSession.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports refused credentials and stays signed out', async () => {
|
||||
vi.mocked(createPasswordSession).mockRejectedValue(refused('Incorrect username or password.'))
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
|
||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user