Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49788dd4e8
|
||
|
|
1412af5f69 | ||
|
|
7f03ba1f4c
|
||
|
|
e333223470 | ||
|
|
3ab0ed88fa
|
||
|
|
12ef8571f1
|
||
|
|
a4c8eaf8c9
|
||
|
|
b0b0d469fe | ||
|
|
3ceed91214
|
||
|
|
4e12a5d524
|
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
|
||||
|
||||
**This checkout targets Next UI 1.0.0-RC.11**, with searchable-selector and script-completion input-race corrections. Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing; see the [RC11 notes](docs/releases/1.0.0-RC.11.md) for the preparation checkpoint and verification scope. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
**This checkout targets Next UI 1.0.0-RC.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.11</version>
|
||||
<version>1.0.0-RC.12</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
The explicit version overrides an older BOM-managed frontend. RC11 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
|
||||
The explicit version overrides an older BOM-managed frontend. 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>`.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ The ID may be on a layout-neutral wrapper around a native button or menu item. S
|
||||
- App home and chrome: `app-card-{appName}-icon`, `.banner.info`/`.banner.warning`/`.banner.error`, and `.qqq-branded-header-bar`.
|
||||
- Query: `button-filter-builder`, `button-views`, `quick-views-container`, `button-query-mode-basic`, `button-query-mode-advanced`, `table-header-{field}`, and `menu-item-{action}`.
|
||||
- Record view: `.recordView`, `record-sidebar`, `record-view-header-{table}`, `record-view-title-{table}`, `record-view-button-bar-{table}`, `record-view-actions-menu`, and `delete-confirmation-*` with `button-delete-yes`/`button-delete-no`.
|
||||
- File previews: a `FILE_PREVIEW` value keeps its cell or value ID (`grid-cell-{field}`, `field-value-{field}`) on the thumbnail button with `data-preview-kind`; the box is `{id}-thumbnail`. The dialog uses `file-preview-dialog`, `file-preview-image`/`-pdf`/`-video`/`-audio`/`-unavailable`, `file-preview-details`, and `file-preview-{previous,next,zoom-in,zoom-out,zoom-fit,open,download,close}`.
|
||||
- Forms: `.entityForm`, `record-create-{table}`/`record-edit-{table}`, `form-section-{section}`, `.field-wrapper.is-visible`, and `.stickyBottomButtonBar`. Default action buttons expose `data-button-variant` values such as `gradient`, `contained`, and `outlined`.
|
||||
|
||||
The record view has a phone accordion and action sheet, a tablet tab list without the large-screen sidebar, and a large-screen tab list with the sidebar. A selector can be present in the DOM while its control is hidden at a narrower width. Test custom CSS at all three widths, including focus and contrast, before shipping it.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Next UI agent guide
|
||||
|
||||
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
> **This checkout targets 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 previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) at `12cf774`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
|
||||
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the previously verified public candidate at the 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
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Next UI developer guide
|
||||
|
||||
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
> **This checkout targets 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 previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) (commit `12cf774`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
|
||||
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The previously verified public candidate at the 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.
|
||||
|
||||
@@ -35,7 +35,7 @@ The preferred QQQ-hosted distribution is the `com.kingsrook.qqq:qqq-frontend-nex
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.11</version>
|
||||
<version>1.0.0-RC.12</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@@ -48,7 +48,7 @@ pnpm build:export
|
||||
pnpm build:jar
|
||||
```
|
||||
|
||||
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). This checkout targets `1.0.0-RC.11`, overriding an older BOM-managed frontend; confirm its availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. It requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for its verified backend commit. Use a matching local backend/frontend build to test later branch changes.
|
||||
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). This checkout targets `1.0.0-RC.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 |
|
||||
|---|---|
|
||||
|
||||
@@ -10,11 +10,11 @@ RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-nex
|
||||
|
||||
The owner authorizes publishing new RCs at verified stability or daily milestones. Each RC gets a new immutable version and notes describing its fixes, backend prerequisites, verification, and known gaps. RC publication does not authorize final 1.0.
|
||||
|
||||
Previously verified public candidate at the RC11 preparation checkpoint: **[1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC9 remain available and unchanged.
|
||||
Previously verified public candidate at the 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
|
||||
|
||||
This checkout targets [RC11](1.0.0-RC.11.md); confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. The candidate adds searchable-selector and script-completion input-race corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24). These changes are absent from immutable RC10. Candidate notes distinguish versioned local checks from public delivery and retain the historical browser failures and remaining RC limits. Local previews may intentionally preserve an earlier version.
|
||||
This checkout targets [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.
|
||||
|
||||
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Next UI 1.0.0-RC.12 — preparation
|
||||
|
||||
At this preparation checkpoint, RC12 is being prepared for application testing and **is not yet published**. [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.12` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.12`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Source
|
||||
|
||||
Main at `b0b0d469feb0b1bd473fbf189540e712ffc09807` (merge of [PR46](https://github.com/QRun-IO/qqq-frontend-next/pull/46)). Merged since RC11: PR25, PR27, PR28, PR29, PR30, PR32, PR35, PR36, PR38, PR40, PR42, PR43, PR44 and PR46. The SQL Workbench work in draft PR47 is not included.
|
||||
|
||||
## Backend requirements
|
||||
|
||||
RC12 runs on a published QQQ 4.1 candidate: **4.1.0-RC.1** or later. Standalone branding images (below) need **4.1.0-RC.2** or later, which contains QQQ #1009's `/qqq/branding/logo` and `/qqq/branding/icon` endpoints. RC12 does not publish QQQ 4.1.
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Always show the record view's Actions menu when it has entries. Tables without processes or a custom `VIEW_SCREEN_ACTIONS` menu, and read-only tables, again reach Developer Mode and Audit (Material parity); Copy and Delete move into that menu instead of also appearing as separate buttons. [#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)
|
||||
- Clear remembered query filters and saved-view selections on logout or a detected user identity change. Same-user resumes keep their query state; global appearance, density and legacy column preferences remain intact. This correction awaits its own branch acceptance. [#1006](https://github.com/QRun-IO/qqq/issues/1006)
|
||||
- In searchable possible-value fields, invalidate old results immediately when the search term changes, so a debounced request cannot remove an option during its selection. [#970](https://github.com/QRun-IO/qqq/issues/970)
|
||||
- Keep code suggestions open when a delayed textarea scroll event reports the position already used for the popup; dismiss them when the scroll position actually changes. [#954](https://github.com/QRun-IO/qqq/issues/954)
|
||||
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
|
||||
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
|
||||
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
|
||||
|
||||
## Standalone branding and CSP (QQQ #1009, #1017)
|
||||
|
||||
The standalone Node server can route the exact root-relative `branding.logo` and
|
||||
`branding.icon` paths published by authentication metadata through fixed backend
|
||||
image roles. Local Node `public` files keep precedence; external image URLs and
|
||||
Java dashboard serving are unchanged. This does not expose the whole application
|
||||
overlay or add a general proxy.
|
||||
|
||||
This requires **a QQQ middleware-javalin build containing QQQ #1009's
|
||||
`/qqq/branding/logo` and `/qqq/branding/icon` endpoints**. They are absent from
|
||||
published **4.1.0-RC.1**. The minimum numbered release is not yet assigned; a
|
||||
source build must record the exact backend commit/JAR hashes. Older deployments
|
||||
must supply branding through their Node `public` files or an existing external
|
||||
image URL. No automatic compatibility with old relative backend assets is claimed.
|
||||
|
||||
The preserved standalone qualification against public RC1 remains **5 passed,
|
||||
38 failed** out of 43 cases because of missing branding assets. The original
|
||||
staging failure is also retained. The corrected cross-repository pairing has
|
||||
not yet received its browser qualification; neither #1009 nor #734 is closed by
|
||||
focused unit/HTTP results.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Pin the official Playwright runner containing the fixed Linux WebKit network library, and retain native browser diagnostics for navigation failures. The previous bundle's heap corruption was reproduced; migration to a stable runner remains tracked separately. [#904](https://github.com/QRun-IO/qqq/issues/904), [#973](https://github.com/QRun-IO/qqq/issues/973)
|
||||
- Correlate interrupted Firefox font diagnostics with real request and decode evidence, wait for initial fonts before the deliberate redirect, and observe transient feedback during the action that produces it. [#951](https://github.com/QRun-IO/qqq/issues/951), [#953](https://github.com/QRun-IO/qqq/issues/953), [#967](https://github.com/QRun-IO/qqq/issues/967)
|
||||
- Wait for nested report metadata and the destination's owned iframe before advancing acceptance navigation. [#961](https://github.com/QRun-IO/qqq/issues/961), [#972](https://github.com/QRun-IO/qqq/issues/972)
|
||||
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
|
||||
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
|
||||
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
|
||||
- Select all ten ESB scenarios for both phone and tablet acceptance, retaining real broker delivery, lifecycle, replay, SQL and permission checks. Matrix references now use the central QQQ tickets. [#986](https://github.com/QRun-IO/qqq/issues/986), [#990](https://github.com/QRun-IO/qqq/issues/990)
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR checks on the RC12 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the full real-backend acceptance workflow) are recorded on the preparation PR. Tag acceptance and public-artifact verification are recorded in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) after delivery. No RC11 test count or artifact digest is attributed to RC12.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
The RC11 limits carry forward unchanged. The RC11 tag acceptance recorded 2,990 passes and 2 owner-accepted failures (Firefox NAV-014 font diagnostic, tablet NAV-056 access-control diagnostics) under the documented secondary-browser/tablet exception ([#985](https://github.com/QRun-IO/qqq/issues/985), [#798](https://github.com/QRun-IO/qqq/issues/798)); it does not waive new product defects or authorize GA. Historical REC-054/PRC-061 observations remain under [#954](https://github.com/QRun-IO/qqq/issues/954) and [#970](https://github.com/QRun-IO/qqq/issues/970). Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Next UI — unreleased
|
||||
|
||||
[RC11 is published and immutable](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11). It includes the searchable-selector and script-completion corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24); its release details remain in [RC11 notes](1.0.0-RC.11.md). Current main also contains the later menu, copy-focus and grid fixes, which are not in that published artifact. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
[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
|
||||
|
||||
@@ -11,52 +11,16 @@
|
||||
- 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
|
||||
|
||||
- Clear remembered query filters and saved-view selections on logout or a detected user identity change. Same-user resumes keep their query state; global appearance, density and legacy column preferences remain intact. This correction awaits its own branch acceptance. [#1006](https://github.com/QRun-IO/qqq/issues/1006)
|
||||
- In searchable possible-value fields, invalidate old results immediately when the search term changes, so a debounced request cannot remove an option during its selection. [#970](https://github.com/QRun-IO/qqq/issues/970)
|
||||
- Keep code suggestions open when a delayed textarea scroll event reports the position already used for the popup; dismiss them when the scroll position actually changes. [#954](https://github.com/QRun-IO/qqq/issues/954)
|
||||
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
|
||||
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
|
||||
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
|
||||
None yet.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
- Pin the official Playwright runner containing the fixed Linux WebKit network library, and retain native browser diagnostics for navigation failures. The previous bundle's heap corruption was reproduced; migration to a stable runner remains tracked separately. [#904](https://github.com/QRun-IO/qqq/issues/904), [#973](https://github.com/QRun-IO/qqq/issues/973)
|
||||
- Correlate interrupted Firefox font diagnostics with real request and decode evidence, wait for initial fonts before the deliberate redirect, and observe transient feedback during the action that produces it. [#951](https://github.com/QRun-IO/qqq/issues/951), [#953](https://github.com/QRun-IO/qqq/issues/953), [#967](https://github.com/QRun-IO/qqq/issues/967)
|
||||
- Wait for nested report metadata and the destination's owned iframe before advancing acceptance navigation. [#961](https://github.com/QRun-IO/qqq/issues/961), [#972](https://github.com/QRun-IO/qqq/issues/972)
|
||||
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
|
||||
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
|
||||
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
|
||||
- Select all ten ESB scenarios for both phone and tablet acceptance, retaining real broker delivery, lifecycle, replay, SQL and permission checks. Matrix references now use the central QQQ tickets. [#986](https://github.com/QRun-IO/qqq/issues/986), [#990](https://github.com/QRun-IO/qqq/issues/990)
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
Current main `5ca3e0b` has a complete [3,010-case first-attempt acceptance pass](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36989591498): 620 per desktop browser and 575 per touch profile. That run qualifies the main tree before the query-state correction above. The earlier checkpoints below are retained separately.
|
||||
|
||||
The [combined acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) on head `7ff90f1` (the same tree as merge `289eca19`) passed all 2,972 executed cases: 614 each in Chromium, Firefox and WebKit, and 565 each on phone and tablet. All desktop gates covered 401 required rows. The workflow still failed its touch coverage gate because ten ESB rows were not selected; that original failure remains retained under [#990](https://github.com/QRun-IO/qqq/issues/990). After adding the selection markers, a separate native Linux run passed all 20 ESB phone/tablet cases, with zero retries, skips, flaky outcomes or report errors. Its frozen frontend export is unchanged; its freshly rebuilt backend is `55b33e6`. Complete touch collection is 575 cases per profile. The subsequent [complete PR23 outcome](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159) covers all 401 required IDs on each profile with zero missing and all 20 ESB touch cases passing, completing #990. Touch totals are 1,149 passes and one unrelated tablet NAV-056 failure; the full gate and workflow remain failed. [NAV-056 investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383) remains separate, with no shared-cause claim or new waiver.
|
||||
|
||||
Each linked issue and PR records the source commit, reproduction and scoped verification. Preserve failed full-run snapshots separately from later focused passes. The broader historical navigation inventory [#904](https://github.com/QRun-IO/qqq/issues/904), same-user session stall [#952](https://github.com/QRun-IO/qqq/issues/952), legacy process-navigation stall [#960](https://github.com/QRun-IO/qqq/issues/960), and original toast-paint uncertainty [#967](https://github.com/QRun-IO/qqq/issues/967) remain open. The [RC10 preparation notes](1.0.0-RC.10.md) retain their historical checkpoint; its [public release entry](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) records final delivery verification. See [RC11 notes](1.0.0-RC.11.md) for the successor candidate. No final-release approval is claimed by these results.
|
||||
None yet.
|
||||
|
||||
Final 1.0 remains held for real-application soak and explicit owner approval under [#712](https://github.com/QRun-IO/qqq/issues/712). This work does not authorize a QQQ 4.1 release. See the [testing-period policy](1.0-testing.md) and [issue lifecycle](../ISSUE-TRACKING.md).
|
||||
|
||||
## Pending standalone branding compatibility (QQQ #1009)
|
||||
|
||||
The standalone Node server can route the exact root-relative `branding.logo` and
|
||||
`branding.icon` paths published by authentication metadata through fixed backend
|
||||
image roles. Local Node `public` files keep precedence; external image URLs and
|
||||
Java dashboard serving are unchanged. This does not expose the whole application
|
||||
overlay or add a general proxy.
|
||||
|
||||
This requires **a QQQ middleware-javalin build containing QQQ #1009's
|
||||
`/qqq/branding/logo` and `/qqq/branding/icon` endpoints**. They are absent from
|
||||
published **4.1.0-RC.1**. The minimum numbered release is not yet assigned; a
|
||||
source build must record the exact backend commit/JAR hashes. Older deployments
|
||||
must supply branding through their Node `public` files or an existing external
|
||||
image URL. No automatic compatibility with old relative backend assets is claimed.
|
||||
|
||||
The preserved standalone qualification against public RC1 remains **5 passed,
|
||||
38 failed** out of 43 cases because of missing branding assets. The original
|
||||
staging failure is also retained. The corrected cross-repository pairing has
|
||||
not yet received its browser qualification; neither #1009 nor #734 is closed by
|
||||
focused unit/HTTP results.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.11",
|
||||
"version": "1.0.0-RC.12",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. Raise a cap only with a measured reason in the commit. SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB), so that route's cap is 355 KB.",
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Raise a cap only with a measured reason in the commit.",
|
||||
"firstLoadJsGzipKB": 350,
|
||||
"routes": {
|
||||
"/": 255,
|
||||
@@ -12,10 +12,10 @@
|
||||
"/app/_": 350,
|
||||
"/app/_/_": 350,
|
||||
"/app/_/_/copy": 390,
|
||||
"/app/_/_/dev": 360,
|
||||
"/app/_/_/edit": 350,
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350,
|
||||
"/app/_/_/dev": 355
|
||||
"/app/_/savedView/_": 350
|
||||
},
|
||||
"cssGzipKB": 21,
|
||||
"totalJsGzipKB": 1050,
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.11</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>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file FileKindIcon — the file-type icon shown for a FILE_PREVIEW value that has no image thumbnail.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { File, FileAudio, FileImage, FileText, FileVideo } from 'lucide-react'
|
||||
|
||||
import type { FilePreviewKind } from '@/lib/utils/file-preview'
|
||||
|
||||
const ICONS: Record<FilePreviewKind, typeof File> = {
|
||||
image: FileImage,
|
||||
pdf: FileText,
|
||||
video: FileVideo,
|
||||
audio: FileAudio,
|
||||
other: File,
|
||||
}
|
||||
|
||||
/**
|
||||
* A decorative icon for a kind of file.
|
||||
*
|
||||
* @param props - The kind and classes.
|
||||
* @returns The icon (hidden from assistive technology).
|
||||
*/
|
||||
export function FileKindIcon({ kind, className }: { kind: FilePreviewKind; className?: string }) {
|
||||
const Icon = ICONS[kind]
|
||||
return <Icon className={className} aria-hidden="true" data-file-kind={kind} />
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* FILE_PREVIEW stories: thumbnails as the query grid and the record view show them, and the
|
||||
* preview lightbox they open (arrow keys step through the gallery).
|
||||
*/
|
||||
import React from 'react'
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import { DataCell } from '@/components/query/DataCell'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
import { FilePreviewGallery } from './FilePreviewGallery'
|
||||
|
||||
/** An inline SVG "photo", so the stories need no network. */
|
||||
function swatch(from: string, to: string, label: string): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="1200" height="800" fill="url(#g)"/><text x="600" y="430" font-family="sans-serif" font-size="96" fill="white" text-anchor="middle">${label}</text></svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
const field: QFieldMetaData = {
|
||||
name: 'originalUrl', label: 'Image', type: 'STRING', isEditable: false, isRequired: false, isHeavy: false, isHidden: false,
|
||||
adornments: [{
|
||||
type: 'FILE_PREVIEW',
|
||||
values: { fileNameField: 'fileName', mimeTypeField: 'mimeType', widthField: 'width', heightField: 'height', sizeBytesField: 'fileSize', captionField: 'caption' },
|
||||
}],
|
||||
}
|
||||
|
||||
const records: QRecord[] = [
|
||||
{ tableName: 'media', values: { originalUrl: swatch('#0ea5e9', '#6366f1', 'Harbor'), fileName: 'harbor.svg', mimeType: 'image/svg+xml', width: 1200, height: 800, fileSize: 48213, caption: 'Morning at the harbor' } },
|
||||
{ tableName: 'media', values: { originalUrl: swatch('#f97316', '#db2777', 'Sunset'), fileName: 'sunset.svg', mimeType: 'image/svg+xml', width: 1200, height: 800, fileSize: 51022 } },
|
||||
{ tableName: 'media', values: { originalUrl: '/files/annual-report.pdf', fileName: 'annual-report.pdf', mimeType: 'application/pdf', fileSize: 1832000 } },
|
||||
]
|
||||
|
||||
/** Thumbnails in a grid-like list; click one (or focus it and press Enter) to preview. */
|
||||
function GridThumbnails() {
|
||||
return (
|
||||
<FilePreviewGallery records={records} fields={[field]}>
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{records.map((record) => (
|
||||
<tr key={String(record.values.fileName)} className="border-b border-border">
|
||||
<td className="p-2"><DataCell field={field} value={record.values.originalUrl} displayValue={undefined} record={record} /></td>
|
||||
<td className="p-2">{String(record.values.fileName)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Records/FilePreview',
|
||||
component: GridThumbnails,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'FILE_PREVIEW adornment: fixed-size lazy thumbnails (file-type icons for non-images) that open an accessible preview lightbox with zoom, details, open/download and arrow-key navigation.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof GridThumbnails>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
/** Query-grid thumbnails (40 px, the default `thumbnailMaxSize`). */
|
||||
export const QueryGrid: Story = {}
|
||||
|
||||
/** A record view value: a larger thumbnail with the file name. */
|
||||
export const RecordView: Story = {
|
||||
render: () => (
|
||||
<FilePreviewGallery records={[records[0]]} fields={[field]}>
|
||||
<FieldValue field={field} record={records[0]} thumbnailSize={160} />
|
||||
</FilePreviewGallery>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
import type { FieldAdornment, QFieldMetaData, QRecord } from '@/types'
|
||||
import { filePreview } from '@/lib/utils/file-preview'
|
||||
import { DataCell } from '@/components/query/DataCell'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
import { FilePreviewGallery } from './FilePreviewGallery'
|
||||
import { FileThumbnail } from './FileThumbnail'
|
||||
import { FilePreviewLightbox } from './FilePreviewLightbox'
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
function field(name: string, values: Record<string, unknown> = {}, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
|
||||
const adornments: FieldAdornment[] = [{ type: 'FILE_PREVIEW', values }]
|
||||
return { name, label: name, type: 'STRING', isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments, ...extra }
|
||||
}
|
||||
|
||||
function record(values: Record<string, unknown>, displayValues: Record<string, string> = {}): QRecord {
|
||||
return { tableName: 'media', values, displayValues }
|
||||
}
|
||||
|
||||
function item(overrides: Partial<FilePreviewItem> = {}): FilePreviewItem {
|
||||
return { url: '/files/a.png', thumbnailUrl: '/files/a-t.png', fileName: 'a.png', mimeType: 'image/png', kind: 'image', alt: 'a.png', previewable: true, thumbnailSize: 40, ...overrides }
|
||||
}
|
||||
|
||||
const media = field('url', { thumbnailUrlField: 'thumb', fileNameField: 'name', mimeTypeField: 'type', widthField: 'w', heightField: 'h', sizeBytesField: 'size', captionField: 'caption' })
|
||||
const rows = [
|
||||
record({ url: '/files/one.jpg', thumb: '/files/one-t.jpg', name: 'one.jpg', type: 'image/jpeg', w: 1200, h: 800, size: 204800, caption: 'First' }),
|
||||
record({ url: '/qqq/v1/table/media/2/blob/two.pdf', name: 'two.pdf', type: 'application/pdf' }),
|
||||
record({ url: '/files/three.png', name: 'three.png', type: 'image/png' }),
|
||||
]
|
||||
|
||||
/**
|
||||
* Renders the thumbnails of rows inside a gallery, as a grid would.
|
||||
*/
|
||||
function Gallery({ onRowClick }: { onRowClick?: () => void }) {
|
||||
return (
|
||||
<FilePreviewGallery records={rows} fields={[media]}>
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} onClick={onRowClick} data-testid={`row-${index}`}>
|
||||
<DataCell field={media} value={row.values.url} displayValue={undefined} record={row} />
|
||||
</div>
|
||||
))}
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileThumbnail (QRun-IO/qqq#1056)', () => {
|
||||
it('renders a fixed-size lazy thumbnail with a loading pulse until the image loads', () => {
|
||||
render(<FileThumbnail field={media} record={rows[0]} item={filePreview(media, rows[0])!} dataQqqId="grid-cell-url" />)
|
||||
const button = screen.getByRole('button', { name: 'Preview one.jpg' })
|
||||
expect(button).toHaveAttribute('aria-haspopup', 'dialog')
|
||||
const box = document.querySelector('[data-qqq-id="grid-cell-url-thumbnail"]') as HTMLElement
|
||||
expect(box.style.width).toBe('40px')
|
||||
expect(box.style.height).toBe('40px')
|
||||
const image = box.querySelector('img') as HTMLImageElement
|
||||
expect(image).toHaveAttribute('src', '/files/one-t.jpg')
|
||||
expect(image).toHaveAttribute('loading', 'lazy')
|
||||
expect(image).toHaveAttribute('alt', '')
|
||||
expect(document.querySelector('[data-qqq-id="grid-cell-url-thumbnail-loading"]')).not.toBeNull()
|
||||
fireEvent.load(image)
|
||||
expect(document.querySelector('[data-qqq-id="grid-cell-url-thumbnail-loading"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the file-type icon when the image fails or the file is not an image', () => {
|
||||
render(<FileThumbnail field={media} record={rows[0]} item={filePreview(media, rows[0])!} dataQqqId="t1" />)
|
||||
fireEvent.error(document.querySelector('[data-qqq-id="t1-thumbnail"] img')!)
|
||||
expect(document.querySelector('[data-qqq-id="t1-thumbnail"] img')).toBeNull()
|
||||
expect(document.querySelector('[data-qqq-id="t1-thumbnail"] [data-file-kind="image"]')).not.toBeNull()
|
||||
render(<FileThumbnail field={media} record={rows[1]} item={filePreview(media, rows[1])!} dataQqqId="t2" />)
|
||||
expect(document.querySelector('[data-qqq-id="t2-thumbnail"] [data-file-kind="pdf"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('is not a button when previewable is false, and keeps the alt text', () => {
|
||||
const still = field('url', { previewable: false })
|
||||
const row = record({ url: '/files/x.png' })
|
||||
render(<FileThumbnail field={still} record={row} item={filePreview(still, row)!} size={160} showName dataQqqId="t3" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByRole('img', { name: 'x.png' })).toBeInTheDocument()
|
||||
expect(screen.getByText('x.png')).toBeInTheDocument()
|
||||
expect((document.querySelector('[data-qqq-id="t3-thumbnail"]') as HTMLElement).style.width).toBe('160px')
|
||||
})
|
||||
|
||||
it('opens a preview of just its file outside a gallery', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<FileThumbnail field={media} record={rows[2]} item={filePreview(media, rows[2])!} dataQqqId="solo" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Preview three.png' }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText('three.png')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('Preview')).toBeInTheDocument()
|
||||
expect(within(dialog).queryByRole('button', { name: 'Next file' })).toBeNull()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FilePreviewGallery', () => {
|
||||
it('opens at the clicked value without triggering the row, steps with the arrow keys and returns focus on Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRowClick = vi.fn()
|
||||
render(<Gallery onRowClick={onRowClick} />)
|
||||
const first = screen.getByRole('button', { name: 'Preview one.jpg' })
|
||||
await user.click(first)
|
||||
expect(onRowClick).not.toHaveBeenCalled()
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByRole('heading', { name: 'one.jpg' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
|
||||
// the caption is the alt text when there is no alt text field
|
||||
expect(within(dialog).getByRole('img', { name: 'First' })).toHaveAttribute('src', '/files/one.jpg')
|
||||
// details: type, size, dimensions and caption
|
||||
const details = document.querySelector('[data-qqq-id="file-preview-details"]') as HTMLElement
|
||||
expect(details).toHaveTextContent('First')
|
||||
expect(details).toHaveTextContent('image/jpeg')
|
||||
expect(details).toHaveTextContent('200 KB')
|
||||
expect(details).toHaveTextContent('1200 × 800 px')
|
||||
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(within(dialog).getByRole('heading', { name: 'two.pdf' })).toBeInTheDocument()
|
||||
expect(dialog).toHaveAttribute('data-preview-kind', 'pdf')
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toHaveAttribute('src', '/qqq/v1/table/media/2/blob/two.pdf')
|
||||
|
||||
await user.keyboard('{End}')
|
||||
expect(within(dialog).getByText('3 of 3')).toBeInTheDocument()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
expect(within(dialog).getByText('3 of 3')).toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Previous file' }))
|
||||
expect(within(dialog).getByText('2 of 3')).toBeInTheDocument()
|
||||
await user.keyboard('{Home}')
|
||||
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(first).toHaveFocus()
|
||||
expect(onRowClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens from the keyboard without the key reaching the row', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onKeyDown = vi.fn()
|
||||
render(
|
||||
<div onKeyDown={onKeyDown}><Gallery /></div>,
|
||||
)
|
||||
screen.getByRole('button', { name: 'Preview three.png' }).focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('dialog')).getByText('3 of 3')).toBeInTheDocument()
|
||||
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: 'Enter' }))
|
||||
})
|
||||
|
||||
it('has no accessibility violations with the preview open', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { baseElement } = render(<Gallery />)
|
||||
expect(await axe(baseElement)).toHaveNoViolations()
|
||||
await user.click(screen.getByRole('button', { name: 'Preview one.jpg' }))
|
||||
await screen.findByRole('dialog')
|
||||
expect(await axe(baseElement)).toHaveNoViolations()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FilePreviewLightbox', () => {
|
||||
function show(items: FilePreviewItem[], index = 0) {
|
||||
const onIndexChange = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<FilePreviewLightbox items={items} index={index} onIndexChange={onIndexChange} onClose={onClose} />)
|
||||
return { onIndexChange, onClose, dialog: screen.getByRole('dialog') }
|
||||
}
|
||||
|
||||
it('zooms images with the buttons, keys and a double click, and resets to fit', async () => {
|
||||
const user = userEvent.setup()
|
||||
show([item()])
|
||||
const image = screen.getByRole('img', { name: 'a.png' })
|
||||
expect(image).toHaveAttribute('data-zoom', '1')
|
||||
expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
expect(image).toHaveAttribute('data-zoom', '1.5')
|
||||
await user.keyboard('+')
|
||||
expect(image).toHaveAttribute('data-zoom', '2')
|
||||
await user.keyboard('-')
|
||||
expect(image).toHaveAttribute('data-zoom', '1.5')
|
||||
await user.keyboard('0')
|
||||
expect(image).toHaveAttribute('data-zoom', '1')
|
||||
fireEvent.doubleClick(image)
|
||||
expect(image).toHaveAttribute('data-zoom', '2')
|
||||
await user.click(screen.getByRole('button', { name: 'Zoom 200%, fit to window' }))
|
||||
expect(image).toHaveAttribute('data-zoom', '1')
|
||||
for (let i = 0; i < 6; i++) await user.click(screen.getByRole('button', { name: 'Zoom in' }).closest('button')!)
|
||||
expect(image).toHaveAttribute('data-zoom', '4')
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('applies the zoom as a width once the image has its fitted size', () => {
|
||||
show([item()])
|
||||
const image = screen.getByRole('img', { name: 'a.png' }) as HTMLImageElement
|
||||
Object.defineProperty(image, 'naturalWidth', { value: 2000 })
|
||||
Object.defineProperty(image, 'naturalHeight', { value: 1000 })
|
||||
image.getBoundingClientRect = () => ({ width: 500, height: 250 } as DOMRect)
|
||||
fireEvent.load(image)
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-details"]')).toHaveTextContent('2000 × 1000 px')
|
||||
fireEvent.doubleClick(image)
|
||||
expect(image.style.width).toBe('1000px')
|
||||
})
|
||||
|
||||
it('offers open-original and download links (attachment for backend files)', () => {
|
||||
show([item({ url: '/qqq/v1/table/media/1/blob/a.png', fileName: 'a.png' })])
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toHaveAttribute('href', '/qqq/v1/table/media/1/blob/a.png')
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', '/qqq/v1/table/media/1/blob/a.png?download=1')
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('download', 'a.png')
|
||||
})
|
||||
|
||||
it('falls back to the file card for an image that fails to load', () => {
|
||||
show([item()])
|
||||
fireEvent.error(screen.getByRole('img', { name: 'a.png' }))
|
||||
expect(screen.getByText('This file could not be loaded.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeNull()
|
||||
})
|
||||
|
||||
it('explains types it cannot show', () => {
|
||||
show([item({ kind: 'other', mimeType: 'application/zip', fileName: 'b.zip', url: '/files/b.zip' })])
|
||||
expect(screen.getByText('No preview is available for this type of file.')).toBeInTheDocument()
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', '/files/b.zip')
|
||||
})
|
||||
|
||||
it('does not frame a PDF from another origin', () => {
|
||||
show([item({ kind: 'pdf', mimeType: 'application/pdf', fileName: 'c.pdf', url: 'https://elsewhere.example/c.pdf' })])
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toBeNull()
|
||||
expect(screen.getByText(/opens in a new tab instead/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('frames only backend file paths, not other pages of this origin', () => {
|
||||
show([item({ kind: 'pdf', mimeType: 'application/pdf', fileName: 'd.pdf', url: '/app/media/1' })])
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toBeNull()
|
||||
expect(screen.getByText(/not a file served by the application/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('plays video and audio, falling back when the player cannot load them', () => {
|
||||
show([item({ kind: 'video', mimeType: 'video/mp4', fileName: 'v.mp4', url: '/files/v.mp4' }), item({ kind: 'audio', mimeType: 'audio/mpeg', fileName: 's.mp3', url: '/files/s.mp3' })])
|
||||
const video = document.querySelector('[data-qqq-id="file-preview-video"]') as HTMLVideoElement
|
||||
expect(video).toHaveAttribute('src', '/files/v.mp4')
|
||||
expect(video).toHaveAttribute('controls')
|
||||
// arrow keys used inside the player are its own
|
||||
fireEvent.keyDown(video, { key: 'ArrowRight' })
|
||||
act(() => { fireEvent.error(video) })
|
||||
expect(screen.getByText('This file could not be loaded.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an audio player', () => {
|
||||
show([item({ kind: 'audio', mimeType: 'audio/mpeg', fileName: 's.mp3', url: '/files/s.mp3' })])
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-audio"]')).toHaveAttribute('src', '/files/s.mp3')
|
||||
})
|
||||
|
||||
it('reports navigation and closing to its owner, ignoring modified keys', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onIndexChange, onClose } = show([item(), item({ fileName: 'b.png', url: '/b.png' })], 1)
|
||||
await user.keyboard('{Control>}{ArrowRight}{/Control}')
|
||||
expect(onIndexChange).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Next file' }))
|
||||
expect(onIndexChange).toHaveBeenLastCalledWith(0)
|
||||
await user.click(screen.getByRole('button', { name: 'Close preview' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FILE_PREVIEW in the shared value renderers', () => {
|
||||
function renderValue(element: React.ReactElement) {
|
||||
return render(<QueryClientProvider client={new QueryClient()}>{element}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
it('DataCell shows a thumbnail in place of other adornments, and the usual value without a URL', () => {
|
||||
const both = { ...media, adornments: [{ type: 'LINK' as const }, ...media.adornments] }
|
||||
renderValue(<DataCell field={both} value={rows[0].values.url} displayValue={undefined} record={rows[0]} />)
|
||||
expect(document.querySelector('[data-qqq-id="grid-cell-url"]')).toHaveAttribute('data-preview-kind', 'image')
|
||||
const empty = record({ url: 'javascript:alert(1)' })
|
||||
renderValue(<DataCell field={field('bad')} value={empty.values.bad} displayValue={undefined} record={empty} />)
|
||||
expect(document.querySelector('[data-qqq-id="grid-cell-bad"]')).toHaveTextContent('—')
|
||||
})
|
||||
|
||||
it('FieldValue shows a record-view thumbnail with the file name, and an em dash without a file', () => {
|
||||
renderValue(<FieldValue field={media} record={rows[0]} thumbnailSize={160} />)
|
||||
const value = document.querySelector('[data-qqq-id="field-value-url"]') as HTMLElement
|
||||
expect(value).toHaveTextContent('one.jpg')
|
||||
expect((document.querySelector('[data-qqq-id="field-value-url-thumbnail"]') as HTMLElement).style.width).toBe('160px')
|
||||
renderValue(<FieldValue field={field('none')} record={record({ none: null })} />)
|
||||
expect(document.querySelector('[data-qqq-id="field-value-none"]')).toHaveTextContent('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file FilePreviewGallery — groups the FILE_PREVIEW values of a set of records (a grid page, a
|
||||
* child list, a record) so the preview lightbox steps through them with the arrow keys. The
|
||||
* lightbox code loads only when a preview first opens.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { createContext, lazy, Suspense, useCallback, useContext, useMemo, useState } from 'react'
|
||||
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
import { filePreviewItems } from '@/lib/utils/file-preview'
|
||||
|
||||
const FilePreviewLightbox = lazy(() => import('./FilePreviewLightbox'))
|
||||
|
||||
/** What a thumbnail asks of the gallery around it. */
|
||||
interface FilePreviewGalleryContextValue {
|
||||
/**
|
||||
* Opens the preview at a record's field.
|
||||
*
|
||||
* @returns `false` when the value is not part of this gallery.
|
||||
*/
|
||||
open: (record: QRecord, field: QFieldMetaData) => boolean
|
||||
}
|
||||
|
||||
const FilePreviewGalleryContext = createContext<FilePreviewGalleryContextValue | null>(null)
|
||||
|
||||
/** Props for {@link FilePreviewGallery}. */
|
||||
interface FilePreviewGalleryProps {
|
||||
/** Records on screen, in display order. */
|
||||
records: QRecord[]
|
||||
/** Fields shown for them, in display order (fields without FILE_PREVIEW are ignored). */
|
||||
fields: QFieldMetaData[]
|
||||
/** Selected table variant as v1 JSON, for backend file URLs. */
|
||||
tableVariant?: string
|
||||
/** The content holding the thumbnails. */
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the gallery to the thumbnails rendered inside it.
|
||||
*
|
||||
* @param props - See {@link FilePreviewGalleryProps}.
|
||||
* @returns The children, plus the lightbox while one is open.
|
||||
*/
|
||||
export function FilePreviewGallery({ records, fields, tableVariant, children }: FilePreviewGalleryProps) {
|
||||
const entries = useMemo(() => filePreviewItems(records, fields, tableVariant), [records, fields, tableVariant])
|
||||
const [index, setIndex] = useState<number | null>(null)
|
||||
|
||||
const open = useCallback((record: QRecord, field: QFieldMetaData) => {
|
||||
const position = entries.findIndex((entry) => entry.record === record && entry.field.name === field.name)
|
||||
if (position < 0) return false
|
||||
setIndex(position)
|
||||
return true
|
||||
}, [entries])
|
||||
|
||||
const value = useMemo(() => ({ open }), [open])
|
||||
const items = useMemo(() => entries.map((entry) => entry.item), [entries])
|
||||
|
||||
return (
|
||||
<FilePreviewGalleryContext.Provider value={value}>
|
||||
{children}
|
||||
{index !== null && items.length > 0 && (
|
||||
<LightboxHost items={items} index={Math.min(index, items.length - 1)} onIndexChange={setIndex} onClose={() => setIndex(null)} />
|
||||
)}
|
||||
</FilePreviewGalleryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery around a thumbnail, if any.
|
||||
*
|
||||
* @returns The gallery, or `null` outside one.
|
||||
*/
|
||||
export function useFilePreviewGallery(): FilePreviewGalleryContextValue | null {
|
||||
return useContext(FilePreviewGalleryContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and shows the lightbox.
|
||||
*
|
||||
* @param props - The lightbox props.
|
||||
* @returns The lazily loaded lightbox.
|
||||
*/
|
||||
export function LightboxHost(props: { items: FilePreviewItem[]; index: number; onIndexChange: (index: number) => void; onClose: () => void }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<FilePreviewLightbox {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file FilePreviewLightbox — the accessible preview dialog for FILE_PREVIEW values: the full-size
|
||||
* image with zoom, a same-origin PDF, a video or audio player, or a file card with open and
|
||||
* download links; with the file's details and arrow-key navigation across the gallery. Loaded
|
||||
* on demand by {@link FilePreviewGallery}.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { ChevronLeft, ChevronRight, Download, ExternalLink, Maximize2, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
import { apiUrl } from '@/lib/api/client'
|
||||
import { attachmentUrl } from '@/lib/utils/adornment-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { formatBytes, isBackendFileUrl, isSameOrigin } from '@/lib/utils/file-preview'
|
||||
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
|
||||
import { FileKindIcon } from './FileKindIcon'
|
||||
|
||||
/** Zoom steps of an image preview (1 = fit to the window). */
|
||||
export const ZOOM_STEPS = [1, 1.5, 2, 3, 4]
|
||||
|
||||
/** Props for {@link FilePreviewLightbox}. */
|
||||
export interface FilePreviewLightboxProps {
|
||||
/** The files the preview steps through. */
|
||||
items: FilePreviewItem[]
|
||||
/** Index of the file shown. */
|
||||
index: number
|
||||
/** Called with the index to show next. */
|
||||
onIndexChange: (index: number) => void
|
||||
/** Called when the preview closes. */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview dialog. Escape closes it, focus is trapped while it is open and returns to the
|
||||
* thumbnail that opened it; Left/Right (and Home/End) step through the items; `+`, `-` and `0`
|
||||
* zoom an image, as do the toolbar buttons and a double click.
|
||||
*
|
||||
* @param props - See {@link FilePreviewLightboxProps}.
|
||||
* @returns The dialog.
|
||||
*/
|
||||
export function FilePreviewLightbox({ items, index, onIndexChange, onClose }: FilePreviewLightboxProps) {
|
||||
const restoreFocus = useRestoreFocus(true)
|
||||
const item = items[Math.min(Math.max(index, 0), items.length - 1)]
|
||||
const count = items.length
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setZoom(1)
|
||||
setFailed(false)
|
||||
setNatural(null)
|
||||
}, [index])
|
||||
|
||||
// warm the browser cache with the neighbouring images, so stepping through is instant
|
||||
useEffect(() => {
|
||||
if (count < 2 || typeof Image === 'undefined') return
|
||||
for (const neighbour of [items[(index + 1) % count], items[(index - 1 + count) % count]]) {
|
||||
if (neighbour?.kind === 'image' && neighbour.url) new Image().src = neighbour.url
|
||||
}
|
||||
}, [items, index, count])
|
||||
|
||||
const go = useCallback((delta: number) => {
|
||||
if (count > 1) onIndexChange((index + delta + count) % count)
|
||||
}, [count, index, onIndexChange])
|
||||
|
||||
const zoomBy = useCallback((direction: 1 | -1) => {
|
||||
setZoom((current) => {
|
||||
const position = ZOOM_STEPS.indexOf(current)
|
||||
const next = ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, Math.max(0, position + direction))]
|
||||
return next ?? 1
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!item) return null
|
||||
const isImage = item.kind === 'image' && !failed
|
||||
const width = item.width ?? natural?.width
|
||||
const height = item.height ?? natural?.height
|
||||
|
||||
/**
|
||||
* Keyboard navigation and zoom; keys typed into a media player's controls are left to it.
|
||||
*
|
||||
* @param event - The key event.
|
||||
*/
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('video, audio, iframe, input, textarea, select')) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
let handled = true
|
||||
if (event.key === 'ArrowRight') go(1)
|
||||
else if (event.key === 'ArrowLeft') go(-1)
|
||||
else if (event.key === 'Home' && count > 1) onIndexChange(0)
|
||||
else if (event.key === 'End' && count > 1) onIndexChange(count - 1)
|
||||
else if (isImage && (event.key === '+' || event.key === '=')) zoomBy(1)
|
||||
else if (isImage && event.key === '-') zoomBy(-1)
|
||||
else if (isImage && event.key === '0') setZoom(1)
|
||||
else handled = false
|
||||
if (handled) event.preventDefault()
|
||||
}
|
||||
|
||||
const details = [
|
||||
item.mimeType && { label: 'Type', value: item.mimeType },
|
||||
item.sizeBytes !== undefined && { label: 'Size', value: formatBytes(item.sizeBytes) },
|
||||
width && height && { label: 'Dimensions', value: `${width} × ${height} px` },
|
||||
].filter(Boolean) as Array<{ label: string; value: string }>
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose() }}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-[2000] bg-black/80" />
|
||||
<Dialog.Content
|
||||
onCloseAutoFocus={restoreFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed inset-2 z-[2001] flex flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-xl focus:outline-none sm:inset-6"
|
||||
data-qqq-id="file-preview-dialog"
|
||||
data-preview-kind={item.kind}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Dialog.Title className="truncate text-sm font-semibold" title={item.fileName} data-qqq-id="file-preview-title">
|
||||
{item.fileName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-xs text-muted-foreground" aria-live="polite" data-qqq-id="file-preview-position">
|
||||
{count > 1 ? `${index + 1} of ${count}` : 'Preview'}
|
||||
{count > 1 && <span className="sr-only">. Use the left and right arrow keys to see the others.</span>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" role="toolbar" aria-label="Preview actions">
|
||||
{isImage && (
|
||||
<>
|
||||
<ToolbarButton label="Zoom out" onClick={() => zoomBy(-1)} disabled={zoom <= ZOOM_STEPS[0]} id="zoom-out"><ZoomOut className="h-4 w-4" aria-hidden="true" /></ToolbarButton>
|
||||
<ToolbarButton label={zoom === 1 ? 'Fit to window' : `Zoom ${Math.round(zoom * 100)}%, fit to window`} onClick={() => setZoom(1)} id="zoom-fit">
|
||||
<Maximize2 className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="ml-1 min-w-9 text-xs tabular-nums" aria-hidden="true">{Math.round(zoom * 100)}%</span>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label="Zoom in" onClick={() => zoomBy(1)} disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]} id="zoom-in"><ZoomIn className="h-4 w-4" aria-hidden="true" /></ToolbarButton>
|
||||
</>
|
||||
)}
|
||||
<FileActions item={item} />
|
||||
<Dialog.Close asChild>
|
||||
<button type="button" aria-label="Close preview" data-qqq-id="file-preview-close"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1 bg-muted/40">
|
||||
<PreviewStage item={item} zoom={zoom} failed={failed} onFailed={() => setFailed(true)}
|
||||
onNaturalSize={setNatural} onToggleZoom={() => setZoom((current) => (current === 1 ? 2 : 1))} />
|
||||
{count > 1 && (
|
||||
<>
|
||||
<NavButton side="left" label="Previous file" onClick={() => go(-1)}><ChevronLeft className="h-5 w-5" aria-hidden="true" /></NavButton>
|
||||
<NavButton side="right" label="Next file" onClick={() => go(1)}><ChevronRight className="h-5 w-5" aria-hidden="true" /></NavButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(details.length > 0 || item.caption) && (
|
||||
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 border-t border-border px-3 py-2 text-xs sm:px-4" data-qqq-id="file-preview-details">
|
||||
{item.caption && <p className="w-full text-sm text-foreground">{item.caption}</p>}
|
||||
{details.length > 0 && (
|
||||
<dl className="flex flex-wrap gap-x-6 gap-y-1">
|
||||
{details.map((detail) => (
|
||||
<div key={detail.label} className="flex gap-1.5">
|
||||
<dt className="text-muted-foreground">{detail.label}</dt>
|
||||
<dd className="font-medium tabular-nums">{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The previewed file itself, by kind.
|
||||
*
|
||||
* @param props - The item, zoom and callbacks.
|
||||
* @returns The stage content.
|
||||
*/
|
||||
function PreviewStage({ item, zoom, failed, onFailed, onNaturalSize, onToggleZoom }: {
|
||||
item: FilePreviewItem
|
||||
zoom: number
|
||||
failed: boolean
|
||||
onFailed: () => void
|
||||
onNaturalSize: (size: { width: number; height: number }) => void
|
||||
onToggleZoom: () => void
|
||||
}) {
|
||||
const [fitWidth, setFitWidth] = useState<number | null>(null)
|
||||
|
||||
if (failed || item.kind === 'other' || !item.url) {
|
||||
return <Unavailable item={item} reason={failed ? 'This file could not be loaded.' : 'No preview is available for this type of file.'} />
|
||||
}
|
||||
|
||||
if (item.kind === 'image') {
|
||||
const zoomed = zoom > 1 && fitWidth
|
||||
return (
|
||||
<div className={cn('absolute inset-0 overflow-auto p-4', !zoomed && 'flex items-center justify-center')} data-qqq-id="file-preview-stage">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer */}
|
||||
<img
|
||||
key={item.url}
|
||||
src={item.url}
|
||||
alt={item.alt}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onDoubleClick={onToggleZoom}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
onNaturalSize({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
setFitWidth(image.getBoundingClientRect().width || null)
|
||||
}}
|
||||
onError={onFailed}
|
||||
className={cn('select-none', zoomed ? 'mx-auto max-w-none cursor-zoom-out' : 'max-h-full max-w-full cursor-zoom-in object-contain')}
|
||||
style={zoomed ? { width: `${Math.round(fitWidth * zoom)}px` } : undefined}
|
||||
data-qqq-id="file-preview-image"
|
||||
data-zoom={zoom}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.kind === 'pdf') {
|
||||
// frames are limited to the dashboard's own origin by its Content-Security-Policy, and within
|
||||
// it to backend file paths, so a stored URL cannot frame another page of the dashboard
|
||||
if (!isSameOrigin(item.url)) {
|
||||
return <Unavailable item={item} reason="This PDF is on another site, so it opens in a new tab instead of here." />
|
||||
}
|
||||
if (!isBackendFileUrl(item.url)) {
|
||||
return <Unavailable item={item} reason="This PDF is not a file served by the application, so it opens in a new tab instead of here." />
|
||||
}
|
||||
return <iframe src={item.url} title={item.fileName} className="absolute inset-0 h-full w-full border-0 bg-white" data-qqq-id="file-preview-pdf" />
|
||||
}
|
||||
|
||||
if (item.kind === 'video') {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<video key={item.url} src={item.url} controls preload="metadata" className="max-h-full max-w-full" onError={onFailed} data-qqq-id="file-preview-video">
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 p-4">
|
||||
<FileKindIcon kind="audio" className="h-16 w-16 text-muted-foreground" />
|
||||
<audio key={item.url} src={item.url} controls preload="metadata" className="w-full max-w-md" onError={onFailed} data-qqq-id="file-preview-audio" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback for a file that cannot be shown here: its icon, name and open/download links.
|
||||
*
|
||||
* @param props - The item and why it is not shown.
|
||||
* @returns The fallback panel.
|
||||
*/
|
||||
function Unavailable({ item, reason }: { item: FilePreviewItem; reason: string }) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center" data-qqq-id="file-preview-unavailable">
|
||||
<FileKindIcon kind={item.kind} className="h-16 w-16 text-muted-foreground" />
|
||||
<p className="max-w-md break-all text-sm font-medium">{item.fileName}</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{reason}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-in-new-tab and download links for the full-size file.
|
||||
*
|
||||
* @param props - The item.
|
||||
* @returns The links.
|
||||
*/
|
||||
function FileActions({ item }: { item: FilePreviewItem }) {
|
||||
if (!item.url) return null
|
||||
// backend field downloads honour ?download=1; other URLs rely on the download attribute (same origin)
|
||||
const backendFile = item.url.startsWith(apiUrl('/table/'))
|
||||
const downloadHref = backendFile ? attachmentUrl(item.url) : item.url
|
||||
const linkClass = 'inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
return (
|
||||
<>
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer" className={linkClass} title="Open original" data-qqq-id="file-preview-open">
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Open original (opens in a new tab)</span>
|
||||
</a>
|
||||
<a href={downloadHref} download={item.fileName} className={linkClass} title="Download" data-qqq-id="file-preview-download">
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Download {item.fileName}</span>
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon button of the toolbar.
|
||||
*
|
||||
* @param props - Label, handler, state and icon.
|
||||
* @returns The button.
|
||||
*/
|
||||
function ToolbarButton({ label, onClick, disabled, id, children }: { label: string; onClick: () => void; disabled?: boolean; id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled} aria-label={label} title={label} data-qqq-id={`file-preview-${id}`}
|
||||
className="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-2 text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40">
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A previous/next button over the stage.
|
||||
*
|
||||
* @param props - Side, label, handler and icon.
|
||||
* @returns The button.
|
||||
*/
|
||||
function NavButton({ side, label, onClick, children }: { side: 'left' | 'right'; label: string; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} aria-label={label} data-qqq-id={`file-preview-${side === 'left' ? 'previous' : 'next'}`}
|
||||
className={cn('absolute top-1/2 -translate-y-1/2 inline-flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card/90 text-foreground shadow hover:bg-card focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
side === 'left' ? 'left-2' : 'right-2')}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePreviewLightbox
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file FileThumbnail — a FILE_PREVIEW value as a fixed-size, lazily loaded thumbnail (or a
|
||||
* file-type icon) that opens the preview lightbox: through the surrounding
|
||||
* {@link FilePreviewGallery} when there is one, else for this file alone.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import type { QFieldMetaData, QRecord } from '@/types'
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { FileKindIcon } from './FileKindIcon'
|
||||
import { LightboxHost, useFilePreviewGallery } from './FilePreviewGallery'
|
||||
|
||||
/** Props for {@link FileThumbnail}. */
|
||||
interface FileThumbnailProps {
|
||||
/** The adorned field. */
|
||||
field: QFieldMetaData
|
||||
/** The record holding the value. */
|
||||
record: QRecord
|
||||
/** The resolved preview (see `filePreview`). */
|
||||
item: FilePreviewItem
|
||||
/** Edge of the thumbnail box in CSS pixels; defaults to the adornment's `thumbnailMaxSize`. */
|
||||
size?: number
|
||||
/** Show the file name beside the thumbnail (record views). */
|
||||
showName?: boolean
|
||||
/** The `data-qqq-id` of the value. */
|
||||
dataQqqId: string
|
||||
/** Additional classes for the root. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the thumbnail. The box has a fixed size, so loading never shifts the layout; a pulse
|
||||
* shows until the image loads, and the file-type icon replaces an image that fails to load.
|
||||
* Clicks and keys do not reach an enclosing row, so a grid row does not also navigate.
|
||||
*
|
||||
* @param props - See {@link FileThumbnailProps}.
|
||||
* @returns The thumbnail, as a button when the value is previewable.
|
||||
*/
|
||||
export function FileThumbnail({ field, record, item, size, showName = false, dataQqqId, className }: FileThumbnailProps) {
|
||||
const gallery = useFilePreviewGallery()
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [standalone, setStandalone] = useState(false)
|
||||
const edge = size ?? item.thumbnailSize
|
||||
const image = item.kind === 'image' && item.thumbnailUrl && !failed
|
||||
|
||||
const box = (
|
||||
<span
|
||||
className="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-muted"
|
||||
style={{ width: edge, height: edge }}
|
||||
data-qqq-id={`${dataQqqId}-thumbnail`}
|
||||
>
|
||||
{image ? (
|
||||
<>
|
||||
{!loaded && <span className="absolute inset-0 animate-pulse bg-muted-foreground/15" aria-hidden="true" data-qqq-id={`${dataQqqId}-thumbnail-loading`} />}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer */}
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.previewable ? '' : item.alt}
|
||||
width={edge}
|
||||
height={edge}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
className={cn('h-full w-full', edge > 64 ? 'object-contain' : 'object-cover')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<FileKindIcon kind={item.kind} className="h-1/2 w-1/2 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
// the name sits under the thumbnail, no wider than it, so narrow value columns do not break it up
|
||||
const name = showName ? <span className="block truncate text-sm text-foreground" style={{ maxWidth: edge }}>{item.fileName}</span> : null
|
||||
const layout = showName ? 'inline-flex flex-col items-start gap-1' : 'inline-flex items-center gap-2'
|
||||
|
||||
if (!item.previewable) {
|
||||
return (
|
||||
<span className={cn(layout, className)} data-qqq-id={dataQqqId} title={item.fileName}>
|
||||
{box}
|
||||
{name}
|
||||
{!image && !showName && <span className="sr-only">{item.fileName}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the preview within the gallery, or alone when the value is not part of one.
|
||||
*
|
||||
* @param event - The click.
|
||||
*/
|
||||
const open = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
// Safari does not focus a clicked button; focus it so closing the preview returns here
|
||||
event.currentTarget.focus()
|
||||
if (!gallery?.open(record, field)) setStandalone(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() }}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`Preview ${item.fileName}`}
|
||||
title={item.fileName}
|
||||
className={cn('group max-w-full cursor-zoom-in rounded-md text-left', layout, 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1', className)}
|
||||
data-qqq-id={dataQqqId}
|
||||
data-preview-kind={item.kind}
|
||||
>
|
||||
<span className="contents [&>span:first-child]:transition-shadow group-hover:[&>span:first-child]:shadow-md">{box}</span>
|
||||
{name}
|
||||
</button>
|
||||
{standalone && <LightboxHost items={[item]} index={0} onIndexChange={() => undefined} onClose={() => setStandalone(false)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file DataCell — dispatches to the correct cell renderer based on QQQ field type and adornments. Handles LINK, CHIP, ERROR, RENDER_HTML, REVEAL, FILE_DOWNLOAD, TOOLTIP adornments (backend value keys) and type-based fallbacks.
|
||||
* @file DataCell — dispatches to the correct cell renderer based on QQQ field type and adornments. Handles FILE_PREVIEW, LINK, CHIP, ERROR, RENDER_HTML, REVEAL, FILE_DOWNLOAD, TOOLTIP adornments (backend value keys) and type-based fallbacks.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -27,6 +27,8 @@ 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 { filePreview } from '@/lib/utils/file-preview'
|
||||
import { FileThumbnail } from '@/components/preview/FileThumbnail'
|
||||
|
||||
/**
|
||||
* Props for the DataCell component.
|
||||
@@ -56,6 +58,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) {
|
||||
|
||||
+127
-117
@@ -43,10 +43,13 @@ import { selectHelpContent, QUERY_SCREEN_HELP_ROLES } from '@/lib/utils/help-uti
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { arrangePinnedColumns, effectivePins, getQueryColumns, orderColumns, type ColumnPins, type QueryColumn } from '@/lib/utils/query-columns'
|
||||
import { isColumnVisible } from '@/lib/utils/saved-view-utils'
|
||||
import { joinedPreviewField } from '@/lib/utils/file-preview'
|
||||
import { HelpContent } from '@/components/records/HelpContent'
|
||||
import { ColumnHeaderMenu, type ColumnMenuActions } from './ColumnHeaderMenu'
|
||||
import { tableHeaderQqqId } from '@/lib/utils/qqq-id'
|
||||
import { DataCell } from './DataCell'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
|
||||
/**
|
||||
* Props for the DataGrid component.
|
||||
@@ -232,6 +235,9 @@ export function DataGrid({
|
||||
}
|
||||
}, [tableMetaData, columnVisibility, columnOrder, columnPins])
|
||||
|
||||
// The fields the preview lightbox steps through (FILE_PREVIEW columns, as their cells read them)
|
||||
const previewFields = useMemo(() => visibleFields.map((column) => column.isJoin ? joinedPreviewField(column.field, column.tableName) : column.field), [visibleFields])
|
||||
|
||||
// A user-resized width wins, then explicit metadata, then the readable Next UI default.
|
||||
const widthOf = useCallback((column: QueryColumn) => Math.max(MIN_COLUMN_WIDTH, columnWidths[column.name] ?? column.defaultWidth), [columnWidths])
|
||||
|
||||
@@ -351,6 +357,8 @@ export function DataGrid({
|
||||
|
||||
const fieldColumns: ColumnDef<QRecord>[] = visibleFields.map((column) => {
|
||||
const field = column.field
|
||||
// join values are keyed `joinTable.field`; a FILE_PREVIEW field reads them so
|
||||
const cellField = column.isJoin ? joinedPreviewField(field, column.tableName) : field
|
||||
const sortInfo = sortMap.get(column.name)
|
||||
const help = selectHelpContent(field.helpContents, QUERY_SCREEN_HELP_ROLES)
|
||||
const filtered = column.isQueryCriteria && filteredColumns?.has(column.name)
|
||||
@@ -424,7 +432,7 @@ export function DataGrid({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataCell
|
||||
field={field}
|
||||
field={cellField}
|
||||
value={trimValue(row.original.values[column.name])}
|
||||
displayValue={trimValue(row.original.displayValues?.[column.name])}
|
||||
record={row.original}
|
||||
@@ -752,129 +760,131 @@ export function DataGrid({
|
||||
// Main grid
|
||||
// ------------------------------------------------------------------
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`relative w-full ${height ? 'overflow-auto' : 'overflow-x-auto'}`}
|
||||
style={height ? { height } : undefined}
|
||||
data-qqq-id={`grid-${tableName}`}
|
||||
data-grid-scroll={height ? 'viewport' : 'page'}
|
||||
>
|
||||
{/* D-Q-6: SR announcement for background refetches */}
|
||||
<FilePreviewGallery records={records} fields={previewFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
ref={scrollRef}
|
||||
className={`relative w-full ${height ? 'overflow-auto' : 'overflow-x-auto'}`}
|
||||
style={height ? { height } : undefined}
|
||||
data-qqq-id={`grid-${tableName}`}
|
||||
data-grid-scroll={height ? 'viewport' : 'page'}
|
||||
>
|
||||
{isFetching ? 'Loading results' : ''}
|
||||
</div>
|
||||
|
||||
{isFetching && (
|
||||
<div className="sticky left-0 top-0 z-10 h-0.5 w-full overflow-hidden bg-primary/20">
|
||||
<div className="h-full bg-primary animate-[slideRight_1s_ease-in-out_infinite]" />
|
||||
{/* D-Q-6: SR announcement for background refetches */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{isFetching ? 'Loading results' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table
|
||||
ref={tableRef}
|
||||
className="w-full border-collapse table-fixed min-w-[600px]"
|
||||
style={{ minWidth: `${table.getTotalSize()}px` }}
|
||||
role="grid"
|
||||
aria-label={`${tableMetaData.label} records`}
|
||||
>
|
||||
<thead className={height ? 'sticky top-0 z-[3]' : undefined}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className="border-b border-border bg-muted"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
// Compute aria-sort for sortable columns
|
||||
const isSelectCol = header.id === '_select'
|
||||
const sortInfo = !isSelectCol
|
||||
? sortOrder.find((s) => s.fieldName === header.id)
|
||||
: undefined
|
||||
const isSortable = !isSelectCol && header.column.columnDef.enableSorting !== false
|
||||
const ariaSortValue: 'ascending' | 'descending' | 'none' | undefined = !isSortable
|
||||
? undefined
|
||||
: sortInfo
|
||||
? sortInfo.isAscending
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
const pinned = pinStyles[header.id]
|
||||
// the selection column always stays in view, as the pinned columns do
|
||||
const stickyClass = isSelectCol ? 'sticky left-0 z-[2] bg-muted' : pinned ? `${pinned.className} z-[2] bg-muted` : ''
|
||||
{isFetching && (
|
||||
<div className="sticky left-0 top-0 z-10 h-0.5 w-full overflow-hidden bg-primary/20">
|
||||
<div className="h-full bg-primary animate-[slideRight_1s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
data-qqq-id={isSelectCol ? undefined : tableHeaderQqqId(undefined, header.id)}
|
||||
scope="col"
|
||||
data-col={header.id}
|
||||
data-pinned={isSelectCol ? undefined : pins[header.id]}
|
||||
className={cn('group relative text-left font-semibold text-foreground select-none', cellClass, stickyClass, isSelectCol && 'pointer-coarse:px-0', !isSelectCol && columnMenu && 'pr-1')}
|
||||
style={{ width: `${header.getSize()}px`, ...(pinned?.style ?? {}) }}
|
||||
aria-sort={ariaSortValue}
|
||||
>
|
||||
{/* These locally defined headers are pure render callbacks. Calling them
|
||||
preserves their child identity when current props rebuild the columns. */}
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: typeof header.column.columnDef.header === 'function'
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
<table
|
||||
ref={tableRef}
|
||||
className="w-full border-collapse table-fixed min-w-[600px]"
|
||||
style={{ minWidth: `${table.getTotalSize()}px` }}
|
||||
role="grid"
|
||||
aria-label={`${tableMetaData.label} records`}
|
||||
>
|
||||
<thead className={height ? 'sticky top-0 z-[3]' : undefined}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr
|
||||
key={headerGroup.id}
|
||||
className="border-b border-border bg-muted"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
// Compute aria-sort for sortable columns
|
||||
const isSelectCol = header.id === '_select'
|
||||
const sortInfo = !isSelectCol
|
||||
? sortOrder.find((s) => s.fieldName === header.id)
|
||||
: undefined
|
||||
const isSortable = !isSelectCol && header.column.columnDef.enableSorting !== false
|
||||
const ariaSortValue: 'ascending' | 'descending' | 'none' | undefined = !isSortable
|
||||
? undefined
|
||||
: sortInfo
|
||||
? sortInfo.isAscending
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
const pinned = pinStyles[header.id]
|
||||
// the selection column always stays in view, as the pinned columns do
|
||||
const stickyClass = isSelectCol ? 'sticky left-0 z-[2] bg-muted' : pinned ? `${pinned.className} z-[2] bg-muted` : ''
|
||||
|
||||
{/* Column resize handle */}
|
||||
{!isSelectCol && (() => {
|
||||
const fieldLabel = visibleFields.find(f => f.name === header.id)?.label ?? header.id
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/40 active:bg-primary focus:outline-none focus-visible:bg-primary/60"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${fieldLabel} column`}
|
||||
aria-valuenow={Math.round(header.getSize())}
|
||||
aria-valuemin={MIN_COLUMN_WIDTH}
|
||||
aria-valuemax={2000}
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) =>
|
||||
handleResizeMouseDown(e, header.id, header.getSize())
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
handleResizeKeyDown(e, header.id, header.getSize())
|
||||
}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
data-qqq-id={isSelectCol ? undefined : tableHeaderQqqId(undefined, header.id)}
|
||||
scope="col"
|
||||
data-col={header.id}
|
||||
data-pinned={isSelectCol ? undefined : pins[header.id]}
|
||||
className={cn('group relative text-left font-semibold text-foreground select-none', cellClass, stickyClass, isSelectCol && 'pointer-coarse:px-0', !isSelectCol && columnMenu && 'pr-1')}
|
||||
style={{ width: `${header.getSize()}px`, ...(pinned?.style ?? {}) }}
|
||||
aria-sort={ariaSortValue}
|
||||
>
|
||||
{/* These locally defined headers are pure render callbacks. Calling them
|
||||
preserves their child identity when current props rebuild the columns. */}
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: typeof header.column.columnDef.header === 'function'
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<GridRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
columns={columns}
|
||||
isSelected={row.getIsSelected()}
|
||||
isChecked={isRowSelectedByQuery ? isRowSelectedByQuery(row.index) : row.getIsSelected()}
|
||||
rowClass={rowClass}
|
||||
cellClass={cellClass}
|
||||
pinStyles={pinStyles}
|
||||
onRowMouseDown={disableRowClick ? undefined : handleRowMouseDown}
|
||||
onRowClick={disableRowClick ? undefined : handleRowClick}
|
||||
onRowDoubleClick={disableRowClick ? undefined : handleRowDoubleClick}
|
||||
onOpenRecord={disableRowClick ? undefined : openRecord}
|
||||
onCellKeyDown={handleCellKeyDown}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Column resize handle */}
|
||||
{!isSelectCol && (() => {
|
||||
const fieldLabel = visibleFields.find(f => f.name === header.id)?.label ?? header.id
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/40 active:bg-primary focus:outline-none focus-visible:bg-primary/60"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${fieldLabel} column`}
|
||||
aria-valuenow={Math.round(header.getSize())}
|
||||
aria-valuemin={MIN_COLUMN_WIDTH}
|
||||
aria-valuemax={2000}
|
||||
tabIndex={0}
|
||||
onMouseDown={(e) =>
|
||||
handleResizeMouseDown(e, header.id, header.getSize())
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
handleResizeKeyDown(e, header.id, header.getSize())
|
||||
}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<GridRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
columns={columns}
|
||||
isSelected={row.getIsSelected()}
|
||||
isChecked={isRowSelectedByQuery ? isRowSelectedByQuery(row.index) : row.getIsSelected()}
|
||||
rowClass={rowClass}
|
||||
cellClass={cellClass}
|
||||
pinStyles={pinStyles}
|
||||
onRowMouseDown={disableRowClick ? undefined : handleRowMouseDown}
|
||||
onRowClick={disableRowClick ? undefined : handleRowClick}
|
||||
onRowDoubleClick={disableRowClick ? undefined : handleRowDoubleClick}
|
||||
onOpenRecord={disableRowClick ? undefined : openRecord}
|
||||
onCellKeyDown={handleCellKeyDown}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ import type { QTableMetaData, QRecord, QFieldMetaData } from '@/types'
|
||||
import { getQueryColumns, orderColumns } from '@/lib/utils/query-columns'
|
||||
import { isColumnVisible } from '@/lib/utils/saved-view-utils'
|
||||
|
||||
import { joinedPreviewField } from '@/lib/utils/file-preview'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { DataCell } from './DataCell'
|
||||
|
||||
interface RecordCardViewProps {
|
||||
@@ -83,7 +86,7 @@ export function RecordCardView({
|
||||
() => orderColumns(getQueryColumns(tableMetaData), columnOrder)
|
||||
.filter((column) => isColumnVisible(column.name, columnVisibility))
|
||||
.slice(0, maxFieldsPerCard)
|
||||
.map((column) => ({ ...column.field, name: column.name, label: column.label })),
|
||||
.map((column) => ({ ...(column.isJoin ? joinedPreviewField(column.field, column.tableName) : column.field), name: column.name, label: column.label })),
|
||||
[tableMetaData, columnVisibility, columnOrder, maxFieldsPerCard]
|
||||
)
|
||||
|
||||
@@ -164,91 +167,93 @@ export function RecordCardView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
role="list"
|
||||
aria-label={`${tableMetaData.label} records`}
|
||||
data-qqq-id={`record-card-view-${tableName}`}
|
||||
>
|
||||
{records.map((record, index) => {
|
||||
const recordId = getRecordId(record, index)
|
||||
const isSelected = isRowSelectedByQuery ? isRowSelectedByQuery(index) : Boolean(rowSelection[recordId])
|
||||
const recordLabel = record.recordLabel || recordId
|
||||
<FilePreviewGallery records={records} fields={visibleFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
role="list"
|
||||
aria-label={`${tableMetaData.label} records`}
|
||||
data-qqq-id={`record-card-view-${tableName}`}
|
||||
>
|
||||
{records.map((record, index) => {
|
||||
const recordId = getRecordId(record, index)
|
||||
const isSelected = isRowSelectedByQuery ? isRowSelectedByQuery(index) : Boolean(rowSelection[recordId])
|
||||
const recordLabel = record.recordLabel || recordId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={recordId}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onClick={() => handleCardClick(record)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleCardClick(record)
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer rounded-xl border p-4 transition-colors hover:border-primary/30 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-card'
|
||||
}`}
|
||||
data-qqq-id={`record-card-${recordId}`}
|
||||
>
|
||||
{/* Card header: checkbox + record label */}
|
||||
<div className="flex items-start gap-3 pointer-coarse:items-center">
|
||||
{/* The label is the checkbox's touch target (44 px on coarse pointers, globals.css) */}
|
||||
<label
|
||||
className="inline-flex shrink-0 cursor-pointer items-center justify-center pointer-coarse:-my-2.5 pointer-coarse:-ml-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSelectionToggle(recordId, index)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${recordLabel}`}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input text-primary focus:ring-ring cursor-pointer pointer-coarse:mt-0 pointer-coarse:h-5 pointer-coarse:w-5"
|
||||
data-qqq-id={`card-select-${recordId}`}
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="truncate text-sm font-semibold text-card-foreground"
|
||||
data-qqq-id={`card-title-${recordId}`}
|
||||
>
|
||||
{recordLabel}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card body: field key-value pairs */}
|
||||
<dl className="mt-3 grid grid-cols-1 gap-1.5">
|
||||
{visibleFields.map((field) => {
|
||||
// Skip the primary key if it is the same as recordLabel
|
||||
if (field.name === tableMetaData.primaryKeyField && recordLabel === String(record.values[field.name])) {
|
||||
return null
|
||||
return (
|
||||
<div
|
||||
key={recordId}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
onClick={() => handleCardClick(record)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleCardClick(record)
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer rounded-xl border p-4 transition-colors hover:border-primary/30 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-card'
|
||||
}`}
|
||||
data-qqq-id={`record-card-${recordId}`}
|
||||
>
|
||||
{/* Card header: checkbox + record label */}
|
||||
<div className="flex items-start gap-3 pointer-coarse:items-center">
|
||||
{/* The label is the checkbox's touch target (44 px on coarse pointers, globals.css) */}
|
||||
<label
|
||||
className="inline-flex shrink-0 cursor-pointer items-center justify-center pointer-coarse:-my-2.5 pointer-coarse:-ml-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSelectionToggle(recordId, index)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${recordLabel}`}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input text-primary focus:ring-ring cursor-pointer pointer-coarse:mt-0 pointer-coarse:h-5 pointer-coarse:w-5"
|
||||
data-qqq-id={`card-select-${recordId}`}
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="truncate text-sm font-semibold text-card-foreground"
|
||||
data-qqq-id={`card-title-${recordId}`}
|
||||
>
|
||||
{recordLabel}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={field.name} className="flex items-baseline gap-2 text-sm" data-qqq-id={`card-field-${field.name}`}>
|
||||
<dt className="shrink-0 text-muted-foreground">
|
||||
{field.label}:
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-card-foreground">
|
||||
{/* Formatted exactly like the grid cell (dates, money, possible-value links) */}
|
||||
<DataCell field={field} value={record.values[field.name]} displayValue={record.displayValues?.[field.name]} record={record} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Card body: field key-value pairs */}
|
||||
<dl className="mt-3 grid grid-cols-1 gap-1.5">
|
||||
{visibleFields.map((field) => {
|
||||
// Skip the primary key if it is the same as recordLabel
|
||||
if (field.name === tableMetaData.primaryKeyField && recordLabel === String(record.values[field.name])) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.name} className="flex items-baseline gap-2 text-sm" data-qqq-id={`card-field-${field.name}`}>
|
||||
<dt className="shrink-0 text-muted-foreground">
|
||||
{field.label}:
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-card-foreground">
|
||||
{/* Formatted exactly like the grid cell (dates, money, possible-value links) */}
|
||||
<DataCell field={field} value={record.values[field.name]} displayValue={record.displayValues?.[field.name]} record={record} />
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FilePreviewGallery>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useId, useState } from 'react'
|
||||
import React, { useId, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowUpRight, ExternalLink, X } from 'lucide-react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
@@ -29,6 +29,9 @@ import type { QTableMetaData, QRecord, QAssociation } from '@/types'
|
||||
import { resolveAssociationValues, type AssociationTableState } from '@/lib/utils/association-utils'
|
||||
import { serializeFilter } from '@/lib/utils/filter-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { hasFilePreview } from '@/lib/utils/file-preview'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { FieldValue } from './FieldValue'
|
||||
import { RecordHoverCard } from './RecordHoverCard'
|
||||
import { EntityForm } from '@/components/forms/EntityForm'
|
||||
@@ -78,6 +81,14 @@ export function AssociatedRecords({
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
|
||||
const headingId = useId()
|
||||
const associationId = encodeURIComponent(association.name)
|
||||
const reverse = joinTableMetaData ? association.join.leftTable !== parentTableMetaData.name : false
|
||||
const availableRecords = joinTableMetaData && !reverse && joinTableMetaData.readPermission ? loadedRecords : undefined
|
||||
// stable arrays per page, so the preview gallery's items are not rebuilt on every render
|
||||
const shownRecords = useMemo(() => (availableRecords ?? []).slice(0, visibleCount), [availableRecords, visibleCount])
|
||||
const joinFields = joinTableMetaData?.fields
|
||||
const visibleFields = useMemo(() => Object.values(joinFields ?? {})
|
||||
.filter((f) => !f.isHidden && !f.isHeavy)
|
||||
.slice(0, 6), [joinFields])
|
||||
|
||||
if (metadata?.isDenied) return null
|
||||
|
||||
@@ -89,13 +100,8 @@ export function AssociatedRecords({
|
||||
}
|
||||
|
||||
const relationship = resolveAssociationValues(association, parentTableMetaData, parentRecord, joinTableMetaData)
|
||||
const reverse = association.join.leftTable !== parentTableMetaData.name
|
||||
const availableRecords = !reverse && joinTableMetaData.readPermission ? loadedRecords : undefined
|
||||
const records = availableRecords ?? []
|
||||
const fixedValues = 'values' in relationship ? relationship.values : undefined
|
||||
const visibleFields = Object.values(joinTableMetaData.fields)
|
||||
.filter((f) => !f.isHidden && !f.isHeavy)
|
||||
.slice(0, 6)
|
||||
|
||||
// Build from params for outgoing links
|
||||
const fromParams = navigateFrom
|
||||
@@ -200,63 +206,65 @@ export function AssociatedRecords({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{records.slice(0, visibleCount).map((childRecord, rowIdx) => {
|
||||
const childPk =
|
||||
childRecord.values[joinTableMetaData.primaryKeyField] as string | number
|
||||
const recordHref = joinTableMetaData.readPermission && childPk !== undefined
|
||||
? `/app/${encodeURIComponent(joinTableMetaData.name)}/${encodeURIComponent(String(childPk))}${fromParamsFirst}`
|
||||
: undefined
|
||||
return (
|
||||
<tr
|
||||
key={childPk ?? rowIdx}
|
||||
className="hover:bg-accent transition-colors"
|
||||
data-qqq-id={`assoc-row-${joinTableMetaData.name}-${childPk}`}
|
||||
>
|
||||
{visibleFields.map((field, fieldIdx) => (
|
||||
<td
|
||||
key={field.name}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm"
|
||||
data-qqq-id={`grid-cell-${field.name}`}
|
||||
>
|
||||
{fieldIdx <= 1 && recordHref ? (
|
||||
<RecordHoverCard
|
||||
tableName={joinTableMetaData.name}
|
||||
primaryKey={childPk}
|
||||
tableMetaData={joinTableMetaData}
|
||||
navigateFrom={navigateFrom}
|
||||
>
|
||||
<Link
|
||||
href={recordHref}
|
||||
className="text-primary hover:text-primary/80 hover:underline"
|
||||
aria-label={`View ${joinTableMetaData.label} record ${childPk}`}
|
||||
>
|
||||
{childRecord.displayValues?.[field.name] ?? String(childRecord.values[field.name] ?? '')}
|
||||
</Link>
|
||||
</RecordHoverCard>
|
||||
) : (
|
||||
<FieldValue field={field} record={childRecord} allTables={allTables} navigateFrom={navigateFrom} />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{recordHref && (
|
||||
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
|
||||
<Link
|
||||
href={recordHref}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80',
|
||||
'pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center',
|
||||
'focus:outline-none focus:underline'
|
||||
)}
|
||||
data-qqq-id={`link-view-${joinTableMetaData.name}-${childPk}`}
|
||||
<FilePreviewGallery records={shownRecords} fields={visibleFields} tableVariant={storedRecordVariantJson(joinTableMetaData)}>
|
||||
{shownRecords.map((childRecord, rowIdx) => {
|
||||
const childPk =
|
||||
childRecord.values[joinTableMetaData.primaryKeyField] as string | number
|
||||
const recordHref = joinTableMetaData.readPermission && childPk !== undefined
|
||||
? `/app/${encodeURIComponent(joinTableMetaData.name)}/${encodeURIComponent(String(childPk))}${fromParamsFirst}`
|
||||
: undefined
|
||||
return (
|
||||
<tr
|
||||
key={childPk ?? rowIdx}
|
||||
className="hover:bg-accent transition-colors"
|
||||
data-qqq-id={`assoc-row-${joinTableMetaData.name}-${childPk}`}
|
||||
>
|
||||
{visibleFields.map((field, fieldIdx) => (
|
||||
<td
|
||||
key={field.name}
|
||||
className="whitespace-nowrap px-3 py-2 text-sm"
|
||||
data-qqq-id={`grid-cell-${field.name}`}
|
||||
>
|
||||
View
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{fieldIdx <= 1 && recordHref && !hasFilePreview(field) ? (
|
||||
<RecordHoverCard
|
||||
tableName={joinTableMetaData.name}
|
||||
primaryKey={childPk}
|
||||
tableMetaData={joinTableMetaData}
|
||||
navigateFrom={navigateFrom}
|
||||
>
|
||||
<Link
|
||||
href={recordHref}
|
||||
className="text-primary hover:text-primary/80 hover:underline"
|
||||
aria-label={`View ${joinTableMetaData.label} record ${childPk}`}
|
||||
>
|
||||
{childRecord.displayValues?.[field.name] ?? String(childRecord.values[field.name] ?? '')}
|
||||
</Link>
|
||||
</RecordHoverCard>
|
||||
) : (
|
||||
<FieldValue field={field} record={childRecord} allTables={allTables} navigateFrom={navigateFrom} />
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{recordHref && (
|
||||
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
|
||||
<Link
|
||||
href={recordHref}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80',
|
||||
'pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center',
|
||||
'focus:outline-none focus:underline'
|
||||
)}
|
||||
data-qqq-id={`link-view-${joinTableMetaData.name}-${childPk}`}
|
||||
>
|
||||
View
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</FilePreviewGallery>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -38,10 +38,12 @@ 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 { 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,9 +90,9 @@ interface FieldValueProps {
|
||||
* @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} />
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ import { recordHashAction } from '@/lib/utils/material-links'
|
||||
import { recordSectionField, recordViewActionsPlacement, showRecordSidebar, visibleRecordViewSections } from '@/lib/utils/record-layout-utils'
|
||||
|
||||
import { RecordViewSection } from './RecordViewSection'
|
||||
import { RECORD_VIEW_THUMBNAIL_SIZE } from '@/lib/utils/file-preview'
|
||||
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { FieldValue } from './FieldValue'
|
||||
import { FieldLabel } from './FieldLabel'
|
||||
import { RecordViewHeader } from './RecordViewHeader'
|
||||
@@ -556,182 +559,187 @@ function RecordViewContent({
|
||||
[tableMetaData, allTables, navigateFrom]
|
||||
)
|
||||
const viewId = `record-view-${tableMetaData.name}`
|
||||
// the record's FILE_PREVIEW values, in section order, form the preview gallery of the page
|
||||
const previewFields = [...new Set((tableMetaData.sections ?? []).flatMap((section) => section.fieldNames ?? []))]
|
||||
.map((name) => tableMetaData.fields[name]).filter((field): field is QFieldMetaData => Boolean(field))
|
||||
const materialViewId = `record-view-${sanitizeQqqId(tableMetaData.name)}`
|
||||
|
||||
return (
|
||||
<RecordViewContext.Provider value={contextValue}>
|
||||
<div className="recordView" data-qqq-id={materialViewId !== viewId ? materialViewId : undefined}>
|
||||
<div
|
||||
className={cn('space-y-5', className)}
|
||||
data-qqq-id={viewId}
|
||||
>
|
||||
{/* Back link — returns to source page if navigated from another record, otherwise table list */}
|
||||
<Link
|
||||
href={safeFromPath || `/app/${tableMetaData.name}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors pointer-coarse:min-h-11"
|
||||
data-qqq-id="link-back-to-table"
|
||||
<FilePreviewGallery records={[record]} fields={previewFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
|
||||
<div className="recordView" data-qqq-id={materialViewId !== viewId ? materialViewId : undefined}>
|
||||
<div
|
||||
className={cn('space-y-5', className)}
|
||||
data-qqq-id={viewId}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to {fromLabel || tableMetaData.label}
|
||||
</Link>
|
||||
{/* Back link — returns to source page if navigated from another record, otherwise table list */}
|
||||
<Link
|
||||
href={safeFromPath || `/app/${tableMetaData.name}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors pointer-coarse:min-h-11"
|
||||
data-qqq-id="link-back-to-table"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to {fromLabel || tableMetaData.label}
|
||||
</Link>
|
||||
|
||||
<div className={cn(hasSidebar && 'lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-6')}>
|
||||
{hasSidebar && <RecordViewSidebar sections={orderedSections} onNavigate={navigateToSection} />}
|
||||
<div className="min-w-0 space-y-5">
|
||||
<div className={cn(hasSidebar && 'lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-6')}>
|
||||
{hasSidebar && <RecordViewSidebar sections={orderedSections} onNavigate={navigateToSection} />}
|
||||
<div className="min-w-0 space-y-5">
|
||||
|
||||
{/* Record header — avatar + name + T1 fields + view-mode toggle + actions */}
|
||||
<RecordViewHeader
|
||||
actionsPlacement={recordViewActionsPlacement(instance, tableMetaData)}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
t1Fields={t1Fields}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
hideActions={hideActions}
|
||||
processes={processes}
|
||||
allProcesses={allProcesses}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
auditSource={auditSource}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
onRecordChanged={onRefetch}
|
||||
/>
|
||||
|
||||
{viewMode === 'tabs' && t1Sections.filter((section) => section.widgetName).map((section) => (
|
||||
<RecordViewSection key={section.name} section={section} tableMetaData={tableMetaData} record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap} allTables={allTables} navigateFrom={navigateFrom}
|
||||
renderAssociation={renderAssociation} />
|
||||
))}
|
||||
|
||||
{/* Record errors/warnings */}
|
||||
{(record.errors?.length ?? 0) > 0 && (
|
||||
<div role="alert" className="rounded-md border border-red-200 bg-red-50 px-4 py-3">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{record.errors!.map((err, i) => (
|
||||
<li key={i} className="text-sm text-red-700">{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{/* The warning of the save that led here (Material: create/update navigation state) */}
|
||||
<SaveWarningBanner tableName={tableMetaData.name} primaryKey={parentPk as string | number} />
|
||||
{(record.warnings?.length ?? 0) > 0 && (
|
||||
<div role="status" className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{record.warnings!.map((warn, i) => (
|
||||
<li key={i} className="text-sm text-yellow-700">{warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === View mode: Tabs (default) === */}
|
||||
{viewMode === 'tabs' && tabs.length > 0 && (
|
||||
<RecordViewTabs
|
||||
{/* Record header — avatar + name + T1 fields + view-mode toggle + actions */}
|
||||
<RecordViewHeader
|
||||
actionsPlacement={recordViewActionsPlacement(instance, tableMetaData)}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
secondarySections={secondarySections}
|
||||
tertiarySections={tertiarySections}
|
||||
associations={associations}
|
||||
renderAssociation={renderAssociation}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
t1Fields={t1Fields}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
hideActions={hideActions}
|
||||
processes={processes}
|
||||
allProcesses={allProcesses}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
auditSource={auditSource}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
onRecordChanged={onRefetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === View mode: List (compact top-to-bottom data view) === */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-4" data-qqq-id="record-view-list-mode">
|
||||
{/* T1 sections as compact cards */}
|
||||
{t1Sections.length > 0 && t1Sections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{viewMode === 'tabs' && t1Sections.filter((section) => section.widgetName).map((section) => (
|
||||
<RecordViewSection key={section.name} section={section} tableMetaData={tableMetaData} record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap} allTables={allTables} navigateFrom={navigateFrom}
|
||||
renderAssociation={renderAssociation} />
|
||||
))}
|
||||
|
||||
{/* T2 sections as compact cards */}
|
||||
{secondarySections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* T3 sections as compact cards */}
|
||||
{tertiarySections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Related records */}
|
||||
<RecordViewAssociated associations={associations} renderAssociation={renderAssociation} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No tabs/sections — just show all fields if nothing to tab */}
|
||||
{tabs.length === 0 && secondarySections.length === 0 && t1Fields.length === 0 && (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<dl className="grid gap-x-8 gap-y-4 grid-cols-1 sm:grid-cols-2">
|
||||
{Object.values(tableMetaData.fields)
|
||||
.filter((f) => !f.isHidden)
|
||||
.map((field) => (
|
||||
<div key={field.name} className="flex flex-col gap-0.5" data-qqq-id={`record-field-${field.name}`}>
|
||||
<dt className="text-sm font-semibold text-foreground">
|
||||
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
|
||||
</dt>
|
||||
<dd>
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
|
||||
</dd>
|
||||
</div>
|
||||
{/* Record errors/warnings */}
|
||||
{(record.errors?.length ?? 0) > 0 && (
|
||||
<div role="alert" className="rounded-md border border-red-200 bg-red-50 px-4 py-3">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{record.errors!.map((err, i) => (
|
||||
<li key={i} className="text-sm text-red-700">{err}</li>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{/* The warning of the save that led here (Material: create/update navigation state) */}
|
||||
<SaveWarningBanner tableName={tableMetaData.name} primaryKey={parentPk as string | number} />
|
||||
{(record.warnings?.length ?? 0) > 0 && (
|
||||
<div role="status" className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3">
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{record.warnings!.map((warn, i) => (
|
||||
<li key={i} className="text-sm text-yellow-700">{warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === View mode: Tabs (default) === */}
|
||||
{viewMode === 'tabs' && tabs.length > 0 && (
|
||||
<RecordViewTabs
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
secondarySections={secondarySections}
|
||||
tertiarySections={tertiarySections}
|
||||
associations={associations}
|
||||
renderAssociation={renderAssociation}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === View mode: List (compact top-to-bottom data view) === */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="space-y-4" data-qqq-id="record-view-list-mode">
|
||||
{/* T1 sections as compact cards */}
|
||||
{t1Sections.length > 0 && t1Sections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* T2 sections as compact cards */}
|
||||
{secondarySections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* T3 sections as compact cards */}
|
||||
{tertiarySections.map((section) => (
|
||||
<div
|
||||
key={section.name}
|
||||
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
|
||||
>
|
||||
<RecordViewSection
|
||||
section={section}
|
||||
renderAssociation={renderAssociation}
|
||||
tableMetaData={tableMetaData}
|
||||
record={record}
|
||||
widgetMetaDataMap={widgetMetaDataMap}
|
||||
allTables={allTables}
|
||||
navigateFrom={navigateFrom}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Related records */}
|
||||
<RecordViewAssociated associations={associations} renderAssociation={renderAssociation} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No tabs/sections — just show all fields if nothing to tab */}
|
||||
{tabs.length === 0 && secondarySections.length === 0 && t1Fields.length === 0 && (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<dl className="grid gap-x-8 gap-y-4 grid-cols-1 sm:grid-cols-2">
|
||||
{Object.values(tableMetaData.fields)
|
||||
.filter((f) => !f.isHidden)
|
||||
.map((field) => (
|
||||
<div key={field.name} className="flex flex-col gap-0.5" data-qqq-id={`record-field-${field.name}`}>
|
||||
<dt className="text-sm font-semibold text-foreground">
|
||||
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
|
||||
</dt>
|
||||
<dd>
|
||||
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FilePreviewGallery>
|
||||
</RecordViewContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -16,12 +16,13 @@
|
||||
// Phone action sheet: focus management and routes (QRun-IO/qqq#694, #649)
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QProcessMetaData, QTableMetaData } from '@/types'
|
||||
import type { AuditSource } from '@/lib/api/audits'
|
||||
import type { RecordViewActionsPlacement } from '@/lib/utils/record-layout-utils'
|
||||
import { RecordViewHeader } from './RecordViewHeader'
|
||||
|
||||
@@ -144,20 +145,92 @@ describe('RecordViewHeader #/launchProcess= links', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const readOnly = { ...table, insertPermission: false, editPermission: false, deletePermission: false,
|
||||
capabilities: ['TABLE_QUERY', 'TABLE_GET'] } as unknown as QTableMetaData
|
||||
|
||||
function renderTable(tableMetaData: QTableMetaData, auditSource: AuditSource = null) {
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<RecordViewHeader tableMetaData={tableMetaData} record={{ tableName: 'person', values: { id: 5 }, recordLabel: 'Morgan Sample' }}
|
||||
t1Fields={[]} viewMode="tabs" setViewMode={vi.fn()} hideActions={false} navigateFrom={{ path: '/app/person', label: 'Person' }}
|
||||
auditSource={auditSource} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Labels of the desktop Actions menu's items, after opening it. */
|
||||
async function openDesktopActions(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Record actions menu' }))
|
||||
return screen.getAllByRole('menuitem').map((item) => item.textContent)
|
||||
}
|
||||
|
||||
describe('RecordViewHeader for a read-only user', () => {
|
||||
it('offers no phone Actions trigger when there is nothing to do', () => {
|
||||
const readOnly = { ...table, insertPermission: false, editPermission: false, deletePermission: false,
|
||||
capabilities: ['TABLE_QUERY', 'TABLE_GET'] } as unknown as QTableMetaData
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<RecordViewHeader tableMetaData={readOnly} record={{ tableName: 'person', values: { id: 5 }, recordLabel: 'Morgan Sample' }}
|
||||
t1Fields={[]} viewMode="tabs" setViewMode={vi.fn()} hideActions={false} navigateFrom={{ path: '/app/person', label: 'Person' }} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('still offers Developer Mode from the desktop Actions menu, with no write actions (#45)', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(readOnly)
|
||||
for (const action of ['Edit', 'Copy', 'Delete']) {
|
||||
expect(screen.queryByRole('button', { name: `${action} Person record` })).not.toBeInTheDocument()
|
||||
}
|
||||
const items = await openDesktopActions(user)
|
||||
expect(items).toEqual(['Developer Mode'])
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Developer Mode' }))
|
||||
expect(push).toHaveBeenCalledWith('/app/person/5/dev')
|
||||
})
|
||||
|
||||
it('offers Developer Mode and Audit from the phone sheet', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(readOnly, 'table')
|
||||
await user.click(screen.getByRole('button', { name: 'Record actions' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Record actions' })
|
||||
expect(within(sheet).getByRole('button', { name: 'Developer Mode' })).toBeInTheDocument()
|
||||
expect(within(sheet).getByRole('button', { name: 'Audit' })).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('button', { name: 'Edit Person' })).not.toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('button', { name: 'Delete Person' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no Actions controls when a custom menu resolves to nothing', () => {
|
||||
const empty = { ...readOnly, menus: [{ slot: 'VIEW_SCREEN_ACTIONS', label: 'Actions', items: [
|
||||
{ itemType: 'BUILT_IN', values: { option: 'EDIT' } },
|
||||
] }] } as unknown as QTableMetaData
|
||||
renderTable(empty)
|
||||
expect(screen.queryByRole('button', { name: 'Record actions menu' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Record actions' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RecordViewHeader Actions menu for a table without processes or a custom menu (#45)', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders the default menu with New, Copy, Delete and Developer Mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(table)
|
||||
expect(await openDesktopActions(user)).toEqual(['New', 'Copy', 'Edit', 'Delete', 'Developer Mode'])
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Developer Mode' }))
|
||||
expect(push).toHaveBeenCalledWith('/app/person/5/dev')
|
||||
})
|
||||
|
||||
it('adds Audit when the user can read audits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(table, 'table')
|
||||
const items = await openDesktopActions(user)
|
||||
expect(items).toContain('Developer Mode')
|
||||
expect(items).toContain('Audit')
|
||||
})
|
||||
|
||||
it('does not duplicate Copy or Delete as standalone buttons beside the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTable(table)
|
||||
expect(screen.getByRole('button', { name: 'Edit Person record' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Copy Person record' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete Person record' })).not.toBeInTheDocument()
|
||||
const items = await openDesktopActions(user)
|
||||
expect(items.filter((label) => label === 'Copy')).toHaveLength(1)
|
||||
expect(items.filter((label) => label === 'Delete')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RecordViewHeader layout', () => {
|
||||
it('keeps secondary actions in the process menu without duplicate desktop buttons', async () => {
|
||||
const user = userEvent.setup()
|
||||
@@ -170,10 +243,10 @@ describe('RecordViewHeader layout', () => {
|
||||
expect(push).toHaveBeenCalledWith('/app/person/5/copy')
|
||||
})
|
||||
|
||||
it('keeps standalone secondary actions when there is no process menu', () => {
|
||||
renderHeader()
|
||||
expect(screen.getByRole('button', { name: 'Copy Person record' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete Person record' })).toBeInTheDocument()
|
||||
it('keeps the table processes in the Actions menu alongside Developer Mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader([{ name: 'tagRecords', label: 'Tag Records', hasPermission: true } as QProcessMetaData])
|
||||
expect(await openDesktopActions(user)).toEqual(['New', 'Copy', 'Edit', 'Delete', 'Tag Records', 'Developer Mode'])
|
||||
})
|
||||
|
||||
it('lets the controls wrap onto their own row instead of squeezing the title', () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { recordFieldFile, deliverRecordFieldFile } from '@/lib/utils/record-down
|
||||
import { RecordActions } from './RecordActions'
|
||||
import { RecordMenuIcon } from './RecordMenuIcon'
|
||||
import { FieldLabel } from './FieldLabel'
|
||||
import { HEADER_THUMBNAIL_SIZE } from '@/lib/utils/file-preview'
|
||||
import { FieldValue } from './FieldValue'
|
||||
import { DeleteConfirmDialog } from './DeleteConfirmDialog'
|
||||
import { AuditHistoryDialog } from './AuditHistoryDialog'
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -352,7 +354,7 @@ export function RecordViewHeader({
|
||||
<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
|
||||
|
||||
@@ -30,6 +30,7 @@ import { gridSpanClasses, initialSectionOpen, isCollapsibleSection, recordSectio
|
||||
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 +243,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 +264,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>
|
||||
)
|
||||
@@ -291,7 +292,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>
|
||||
)
|
||||
|
||||
@@ -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 = [
|
||||
{ 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()
|
||||
|
||||
@@ -34,6 +34,9 @@ import { Download } from 'lucide-react'
|
||||
import type { QRecord, QTableMetaData } from '@/types'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
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'
|
||||
@@ -325,7 +328,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.
|
||||
@@ -335,7 +338,16 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
|
||||
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])
|
||||
// a join column's values are keyed `joinTable.field`, and so are its FILE_PREVIEW field references
|
||||
const fields = useMemo(() => Object.fromEntries(columns.map((column) => [column.name, widgetField(
|
||||
column.name.includes('.') ? joinedPreviewField(column.field, column.name.slice(0, column.name.indexOf('.'))) : column.field, column.name, column.label)])), [columns])
|
||||
// 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])
|
||||
const galleryFields = useMemo(() => Object.values(fields), [fields])
|
||||
|
||||
if (!valid || !table || records === undefined) {
|
||||
return <WidgetPayloadNotice widgetName={widgetName} message={payloadProblem('child record list', 'queryOutput.records and child table metadata')} />
|
||||
@@ -389,42 +401,39 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
|
||||
</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 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>
|
||||
) : (
|
||||
<FieldValue field={fields[column.name]} record={cellRecord} allTables={allTables} widgetMetaDataMap={instance?.widgets} />
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</FilePreviewGallery>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FieldAdornment, QFieldMetaData, QRecord } from '@/types'
|
||||
import {
|
||||
DEFAULT_THUMBNAIL_SIZE, filePreview, filePreviewItems, formatBytes, hasFilePreview, isBackendFileUrl, isSameOrigin,
|
||||
joinedPreviewField, kindOfMimeType, mimeTypeOfName, safePreviewUrl,
|
||||
} from './file-preview'
|
||||
|
||||
function field(name: string, adornments: FieldAdornment[] = [], extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
|
||||
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 }
|
||||
}
|
||||
|
||||
const preview = (values: Record<string, unknown> = {}): FieldAdornment => ({ type: 'FILE_PREVIEW', values })
|
||||
|
||||
describe('safePreviewUrl (QRun-IO/qqq#1056)', () => {
|
||||
it('accepts https URLs and same-origin paths', () => {
|
||||
expect(safePreviewUrl('https://cdn.example.com/a.jpg')).toBe('https://cdn.example.com/a.jpg')
|
||||
expect(safePreviewUrl(' /files/a b.jpg ')).toBe('/files/a b.jpg')
|
||||
})
|
||||
|
||||
it('serves backend field downloads from the v1 route', () => {
|
||||
expect(safePreviewUrl('/data/media/7/blob/a.png')).toBe('/qqq/v1/table/media/7/blob/a.png')
|
||||
})
|
||||
|
||||
it('rejects other schemes, protocol-relative and malformed values', () => {
|
||||
for (const value of ['javascript:alert(1)', '//evil.example/a.png', 'ftp://x/a.png', 'images/a.png', '', ' ', '/a"onerror="x', '/a\u0000b', 42, null, undefined]) {
|
||||
expect(safePreviewUrl(value)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts inline images only where asked, and never other data URLs', () => {
|
||||
const png = 'data:image/png;base64,iVBORw0KGgo='
|
||||
expect(safePreviewUrl(png)).toBeUndefined()
|
||||
expect(safePreviewUrl(png, true)).toBe(png)
|
||||
expect(safePreviewUrl('data:text/html,<b>x</b>', true)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('file type helpers', () => {
|
||||
it('maps MIME types to preview kinds', () => {
|
||||
expect(kindOfMimeType('image/webp')).toBe('image')
|
||||
expect(kindOfMimeType('Application/PDF; charset=binary')).toBe('pdf')
|
||||
expect(kindOfMimeType('video/mp4')).toBe('video')
|
||||
expect(kindOfMimeType('audio/mpeg')).toBe('audio')
|
||||
expect(kindOfMimeType('application/zip')).toBe('other')
|
||||
expect(kindOfMimeType(undefined)).toBe('other')
|
||||
})
|
||||
|
||||
it('reads MIME types from extensions, ignoring query strings', () => {
|
||||
expect(mimeTypeOfName('photo.JPG')).toBe('image/jpeg')
|
||||
expect(mimeTypeOfName('https://x.example/a/clip.webm?sig=1#t')).toBe('video/webm')
|
||||
expect(mimeTypeOfName('report.pdf')).toBe('application/pdf')
|
||||
expect(mimeTypeOfName('song.mp3')).toBe('audio/mpeg')
|
||||
expect(mimeTypeOfName('archive.tar.zz')).toBeUndefined()
|
||||
expect(mimeTypeOfName('https://x.example/a.b/file')).toBeUndefined()
|
||||
expect(mimeTypeOfName(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('formats byte sizes', () => {
|
||||
expect(formatBytes(512)).toBe('512 B')
|
||||
expect(formatBytes(1536)).toBe('1.5 KB')
|
||||
expect(formatBytes(15 * 1024 * 1024)).toBe('15 MB')
|
||||
expect(formatBytes(3 * 1024 ** 4)).toBe('3 TB')
|
||||
expect(formatBytes(-1)).toBe('')
|
||||
expect(formatBytes(Number.NaN)).toBe('')
|
||||
})
|
||||
|
||||
it('tells same-origin URLs from others', () => {
|
||||
expect(isSameOrigin('/files/a.pdf')).toBe(true)
|
||||
expect(isSameOrigin(`${window.location.origin}/a.pdf`)).toBe(true)
|
||||
expect(isSameOrigin('https://elsewhere.example/a.pdf')).toBe(false)
|
||||
expect(isSameOrigin('//elsewhere.example/a.pdf')).toBe(false)
|
||||
expect(isSameOrigin('not a url')).toBe(false)
|
||||
expect(isBackendFileUrl('/qqq/v1/table/media/1/blob/a.pdf')).toBe(true)
|
||||
expect(isBackendFileUrl(`${window.location.origin}/qqq/v1/table/media/1/blob/a.pdf`)).toBe(true)
|
||||
expect(isBackendFileUrl('/app/media/1')).toBe(false)
|
||||
expect(isBackendFileUrl('/qqq-not/a.pdf')).toBe(false)
|
||||
expect(isBackendFileUrl('https://elsewhere.example/qqq/a.pdf')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filePreview', () => {
|
||||
it('ignores fields without the adornment', () => {
|
||||
expect(hasFilePreview(field('url'))).toBe(false)
|
||||
expect(hasFilePreview(undefined)).toBe(false)
|
||||
expect(filePreview(field('url'), record({ url: 'https://x.example/a.jpg' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the field itself as thumbnail and full-size source by default', () => {
|
||||
const item = filePreview(field('url', [preview()]), record({ url: 'https://x.example/a/cat%20photo.png' }))
|
||||
expect(item).toEqual({
|
||||
thumbnailUrl: 'https://x.example/a/cat%20photo.png', url: 'https://x.example/a/cat%20photo.png', fileName: 'cat photo.png',
|
||||
mimeType: 'image/png', kind: 'image', alt: 'cat photo.png', previewable: true, thumbnailSize: DEFAULT_THUMBNAIL_SIZE,
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the thumbnail, full-size URL and details from the named fields', () => {
|
||||
const f = field('originalUrl', [preview({
|
||||
thumbnailUrlField: 'thumbnailUrl', fullSizeUrlField: 'galleryUrl', fileNameField: 'fileName', mimeTypeField: 'mimeType',
|
||||
altTextField: 'alt', captionField: 'caption', widthField: 'width', heightField: 'height', sizeBytesField: 'fileSize', thumbnailMaxSize: 64,
|
||||
})])
|
||||
const item = filePreview(f, record({
|
||||
originalUrl: 'https://x.example/o.jpg', thumbnailUrl: 'https://x.example/t.jpg', galleryUrl: 'https://x.example/g.jpg',
|
||||
fileName: 'sunset.jpg', mimeType: 'image/jpeg', alt: 'A sunset', caption: 'Evening', width: 4000, height: '3000', fileSize: 2048,
|
||||
}))
|
||||
expect(item).toMatchObject({
|
||||
thumbnailUrl: 'https://x.example/t.jpg', url: 'https://x.example/g.jpg', fileName: 'sunset.jpg', mimeType: 'image/jpeg',
|
||||
kind: 'image', alt: 'A sunset', caption: 'Evening', width: 4000, height: 3000, sizeBytes: 2048, thumbnailSize: 64,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the field value when a named URL field is empty or unsafe, and to the thumbnail when only it is set', () => {
|
||||
const f = field('url', [preview({ fullSizeUrlField: 'big', thumbnailUrlField: 'small' })])
|
||||
expect(filePreview(f, record({ url: '/a.png', big: 'javascript:x', small: '' }))?.url).toBe('/a.png')
|
||||
expect(filePreview(f, record({ url: null, small: '/t.png' }))).toMatchObject({ url: '/t.png', thumbnailUrl: '/t.png' })
|
||||
expect(filePreview(f, record({ url: null, small: null }))).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the caption as alt text and the display value as file name', () => {
|
||||
const f = field('url', [preview({ captionField: 'caption' })])
|
||||
const item = filePreview(f, record({ url: '/x/123', caption: 'Team photo' }, { url: 'team.webp' }))
|
||||
expect(item).toMatchObject({ alt: 'Team photo', fileName: 'team.webp', kind: 'image', mimeType: 'image/webp' })
|
||||
// a display value that is the URL itself is not a file name
|
||||
expect(filePreview(f, record({ url: '/x/b.gif' }, { url: '/x/b.gif' }))?.fileName).toBe('b.gif')
|
||||
})
|
||||
|
||||
it('takes the type from the MIME field, then the default, then the extension', () => {
|
||||
const f = field('url', [preview({ mimeTypeField: 'type', defaultMimeType: 'application/pdf' })])
|
||||
expect(filePreview(f, record({ url: '/f/a', type: 'video/mp4' }))?.kind).toBe('video')
|
||||
expect(filePreview(f, record({ url: '/f/a', type: '' }))?.kind).toBe('pdf')
|
||||
const wildcard = field('url', [preview({ defaultMimeType: 'image/*' })])
|
||||
expect(filePreview(wildcard, record({ url: '/f/a.mp3' }))).toMatchObject({ kind: 'audio', mimeType: 'audio/mpeg' })
|
||||
expect(filePreview(wildcard, record({ url: '/f/blob' }))).toMatchObject({ kind: 'image', mimeType: 'image/*' })
|
||||
const none = filePreview(field('url', [preview()]), record({ url: '/f/blob' }))
|
||||
expect(none?.kind).toBe('other')
|
||||
expect(none).not.toHaveProperty('thumbnailUrl')
|
||||
expect(none).not.toHaveProperty('mimeType')
|
||||
})
|
||||
|
||||
it('previews a BLOB through its FILE_DOWNLOAD URL and file name', () => {
|
||||
const f = field('photo', [{ type: 'FILE_DOWNLOAD', values: { fileNameField: 'photoFileName' } }, preview()], { type: 'BLOB' })
|
||||
const item = filePreview(f, record({ photo: '/data/media/3/photo/me.png' }, { photo: 'me.png' }), '{"type":"site","id":"1"}')
|
||||
expect(item).toMatchObject({ url: '/qqq/v1/table/media/3/photo/me.png?tableVariant=%7B%22type%22%3A%22site%22%2C%22id%22%3A%221%22%7D', fileName: 'me.png', kind: 'image' })
|
||||
})
|
||||
|
||||
it('honours previewable=false (boolean or text) and clamps the thumbnail size', () => {
|
||||
expect(filePreview(field('u', [preview({ previewable: false })]), record({ u: '/a.png' }))?.previewable).toBe(false)
|
||||
expect(filePreview(field('u', [preview({ previewable: 'FALSE' })]), record({ u: '/a.png' }))?.previewable).toBe(false)
|
||||
expect(filePreview(field('u', [preview({ thumbnailMaxSize: 4 })]), record({ u: '/a.png' }))?.thumbnailSize).toBe(16)
|
||||
expect(filePreview(field('u', [preview({ thumbnailMaxSize: '9000' })]), record({ u: '/a.png' }))?.thumbnailSize).toBe(512)
|
||||
expect(filePreview(field('u', [preview({ thumbnailMaxSize: 'big' })]), record({ u: '/a.png' }))?.thumbnailSize).toBe(DEFAULT_THUMBNAIL_SIZE)
|
||||
})
|
||||
|
||||
it('ignores non-positive dimensions and blank details', () => {
|
||||
const f = field('u', [preview({ widthField: 'w', heightField: 'h', sizeBytesField: 's', altTextField: 'a', fileNameField: 'n' })])
|
||||
const item = filePreview(f, record({ u: '/a.png', w: 0, h: 'x', s: '', a: ' ', n: null }))
|
||||
expect(item).not.toHaveProperty('width')
|
||||
expect(item).not.toHaveProperty('height')
|
||||
expect(item).not.toHaveProperty('sizeBytes')
|
||||
expect(item?.alt).toBe('a.png')
|
||||
})
|
||||
|
||||
it('names a data URL file after the field', () => {
|
||||
expect(filePreview(field('logo', [preview()]), record({ logo: 'data:image/png;base64,AAAA' }))?.fileName).toBe('logo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filePreviewItems', () => {
|
||||
it('lists the previewable values of adorned fields, record by record', () => {
|
||||
const a = field('a', [preview()])
|
||||
const b = field('b', [preview({ previewable: false })])
|
||||
const c = field('c')
|
||||
const records = [record({ a: '/1.png', b: '/x.png', c: '/c.png' }), record({ a: null }), record({ a: '/2.png' })]
|
||||
const items = filePreviewItems(records, [a, b, c])
|
||||
expect(items.map((entry) => entry.item.url)).toEqual(['/1.png', '/2.png'])
|
||||
expect(items[1].record).toBe(records[2])
|
||||
expect(filePreviewItems(records, [c])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('joinedPreviewField', () => {
|
||||
it('prefixes the field name and FILE_PREVIEW field references with the join table', () => {
|
||||
const f = field('photoUrl', [{ type: 'CHIP' }, preview({ thumbnailUrlField: 'thumbUrl', thumbnailMaxSize: 48, captionField: '' })])
|
||||
const joined = joinedPreviewField(f, 'author')
|
||||
expect(joined.name).toBe('author.photoUrl')
|
||||
expect(joined.adornments[0]).toEqual({ type: 'CHIP' })
|
||||
expect(joined.adornments[1].values).toEqual({ thumbnailUrlField: 'author.thumbUrl', thumbnailMaxSize: 48, captionField: '' })
|
||||
expect(filePreview(joined, record({ 'author.photoUrl': '/p.png', 'author.thumbUrl': '/t.png' }))?.thumbnailUrl).toBe('/t.png')
|
||||
})
|
||||
|
||||
it('returns other fields unchanged', () => {
|
||||
const f = field('name')
|
||||
expect(joinedPreviewField(f, 'author')).toBe(f)
|
||||
expect(joinedPreviewField({ name: 'x', adornments: [{ type: 'FILE_PREVIEW' }] }, 'j')).toEqual({ name: 'j.x', adornments: [{ type: 'FILE_PREVIEW', values: {} }] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file file-preview — resolves a FILE_PREVIEW-adorned field of a record into the thumbnail,
|
||||
* full-size URL and file details a thumbnail and the preview lightbox show, using the backend's
|
||||
* value keys (`AdornmentType.FilePreviewValues`). Only explicitly adorned fields are previewed.
|
||||
*/
|
||||
|
||||
import type { FieldAdornment, QFieldMetaData, QRecord } from '@/types'
|
||||
import { isSafeImageSource } from '@/lib/theme/apply-branding'
|
||||
import { fieldDownloadUrl, fileDownload, findAdornment } from './adornment-utils'
|
||||
|
||||
/** What kind of preview a file gets. */
|
||||
export type FilePreviewKind = 'image' | 'pdf' | 'video' | 'audio' | 'other'
|
||||
|
||||
/** A previewable file value, resolved from a record. */
|
||||
export interface FilePreviewItem {
|
||||
/** URL of the small rendition shown as a thumbnail (images only). */
|
||||
thumbnailUrl?: string
|
||||
/** URL of the full-size file, shown in the preview and used to open or download it. */
|
||||
url: string
|
||||
/** File name for the title, alt text fallback and download. */
|
||||
fileName: string
|
||||
/** MIME type when known (from a field, the default, or the extension). */
|
||||
mimeType?: string
|
||||
/** Preview kind. */
|
||||
kind: FilePreviewKind
|
||||
/** Alternative text for the image. */
|
||||
alt: string
|
||||
/** Caption, when the backend names a caption field. */
|
||||
caption?: string
|
||||
/** Pixel width, when known. */
|
||||
width?: number
|
||||
/** Pixel height, when known. */
|
||||
height?: number
|
||||
/** File size in bytes, when known. */
|
||||
sizeBytes?: number
|
||||
/** Whether clicking the thumbnail opens the preview. */
|
||||
previewable: boolean
|
||||
/** Largest edge of the list thumbnail, in CSS pixels. */
|
||||
thumbnailSize: number
|
||||
}
|
||||
|
||||
/** Backend value keys of a FILE_PREVIEW adornment (`AdornmentType.FilePreviewValues`). */
|
||||
export const FILE_PREVIEW_KEYS = {
|
||||
thumbnailUrlField: 'thumbnailUrlField',
|
||||
fullSizeUrlField: 'fullSizeUrlField',
|
||||
fileNameField: 'fileNameField',
|
||||
mimeTypeField: 'mimeTypeField',
|
||||
altTextField: 'altTextField',
|
||||
captionField: 'captionField',
|
||||
widthField: 'widthField',
|
||||
heightField: 'heightField',
|
||||
sizeBytesField: 'sizeBytesField',
|
||||
defaultMimeType: 'defaultMimeType',
|
||||
thumbnailMaxSize: 'thumbnailMaxSize',
|
||||
previewable: 'previewable',
|
||||
} as const
|
||||
|
||||
/** Thumbnail size the backend defaults to, and its bounds. */
|
||||
export const DEFAULT_THUMBNAIL_SIZE = 40
|
||||
/** Thumbnail edge in record view sections, which also show the file name. */
|
||||
export const RECORD_VIEW_THUMBNAIL_SIZE = 160
|
||||
/** Thumbnail edge in the record view header. */
|
||||
export const HEADER_THUMBNAIL_SIZE = 96
|
||||
const MIN_THUMBNAIL_SIZE = 16
|
||||
const MAX_THUMBNAIL_SIZE = 512
|
||||
|
||||
/** Characters never valid in a URL from a record value: control characters, quotes, angle brackets, backslashes. */
|
||||
const UNSAFE_URL_CHARACTERS = /[\u0000-\u001f\u007f<>"\\]/
|
||||
|
||||
const EXTENSION_TYPES: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif',
|
||||
svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
|
||||
pdf: 'application/pdf',
|
||||
mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime',
|
||||
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', wav: 'audio/wav', oga: 'audio/ogg', ogg: 'audio/ogg', flac: 'audio/flac',
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a field has a FILE_PREVIEW adornment.
|
||||
*
|
||||
* @param field - Field metadata.
|
||||
* @returns `true` when the field is adorned for previews.
|
||||
*/
|
||||
export function hasFilePreview(field: { adornments?: FieldAdornment[] } | undefined): boolean {
|
||||
return Boolean(field?.adornments?.some((adornment) => adornment.type === 'FILE_PREVIEW'))
|
||||
}
|
||||
|
||||
/**
|
||||
* A URL that may be loaded into an image, media element or frame and opened in a new tab:
|
||||
* an http(s) URL or a same-origin path (never `//host`, `javascript:` or other schemes). Backend
|
||||
* field-download paths (`/data/...`) are served from the v1 route. Inline `data:image/*` sources
|
||||
* are accepted for images only.
|
||||
*
|
||||
* @param value - A URL from a record value.
|
||||
* @param allowDataImage - Whether `data:image/*` is acceptable (thumbnails and image previews).
|
||||
* @returns The URL to use, or `undefined` when it is unsafe or empty.
|
||||
*/
|
||||
export function safePreviewUrl(value: unknown, allowDataImage = false): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const url = value.trim()
|
||||
if (!url || url.startsWith('//') || UNSAFE_URL_CHARACTERS.test(url)) return undefined
|
||||
if (/^data:/i.test(url)) return allowDataImage && /^data:image\//i.test(url) && isSafeImageSource(url) ? url : undefined
|
||||
if (!url.startsWith('/') && !/^https?:\/\//i.test(url)) return undefined
|
||||
if (!isSafeImageSource(url)) return undefined
|
||||
return fieldDownloadUrl(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a URL is served by the page's own origin (frames and media are limited to `'self'`
|
||||
* by the dashboard's Content-Security-Policy).
|
||||
*
|
||||
* @param url - An absolute URL or a path.
|
||||
* @returns `true` for paths and URLs on the current origin.
|
||||
*/
|
||||
export function isSameOrigin(url: string): boolean {
|
||||
if (url.startsWith('/') && !url.startsWith('//')) return true
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
return new URL(url).origin === window.location.origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a URL is a file served by the QQQ backend on this origin (a path under `/qqq/`). Only
|
||||
* these are framed in the preview: any other same-origin page in an iframe would run with the
|
||||
* dashboard's own origin.
|
||||
*
|
||||
* @param url - A URL or path.
|
||||
* @returns True for a same-origin `/qqq/...` path.
|
||||
*/
|
||||
export function isBackendFileUrl(url: string): boolean {
|
||||
if (!isSameOrigin(url)) return false
|
||||
try {
|
||||
return new URL(url, typeof window === 'undefined' ? 'http://localhost' : window.location.origin).pathname.startsWith('/qqq/')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview kind of a MIME type.
|
||||
*
|
||||
* @param mimeType - A MIME type such as `image/png`.
|
||||
* @returns The kind, or `other`.
|
||||
*/
|
||||
export function kindOfMimeType(mimeType: string | undefined): FilePreviewKind {
|
||||
const type = (mimeType ?? '').toLowerCase().split(';')[0].trim()
|
||||
if (type.startsWith('image/')) return 'image'
|
||||
if (type === 'application/pdf') return 'pdf'
|
||||
if (type.startsWith('video/')) return 'video'
|
||||
if (type.startsWith('audio/')) return 'audio'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* The MIME type of a file name or URL, from its extension.
|
||||
*
|
||||
* @param name - A file name or URL.
|
||||
* @returns The MIME type, or `undefined` for an unknown extension.
|
||||
*/
|
||||
export function mimeTypeOfName(name: string | undefined): string | undefined {
|
||||
const path = (name ?? '').split(/[?#]/)[0]
|
||||
const dot = path.lastIndexOf('.')
|
||||
if (dot < 0 || dot < path.lastIndexOf('/')) return undefined
|
||||
return EXTENSION_TYPES[path.slice(dot + 1).toLowerCase()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a byte count for people (`1.4 MB`).
|
||||
*
|
||||
* @param bytes - Number of bytes.
|
||||
* @returns The formatted size.
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ['KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes / 1024
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the FILE_PREVIEW adornment of a field against a record.
|
||||
*
|
||||
* The adorned field's own value is the default thumbnail and full-size source; for a field that
|
||||
* also has FILE_DOWNLOAD (a BLOB), the download URL the backend wrote into the value is used.
|
||||
* Unsafe or missing URLs yield `null`, so the caller renders the value as usual.
|
||||
*
|
||||
* @param field - Field metadata with a FILE_PREVIEW adornment.
|
||||
* @param record - The record holding the value and the fields the adornment names.
|
||||
* @param tableVariant - Selected table variant as v1 JSON, for backend field-download URLs.
|
||||
* @returns The preview item, or `null` when the field is not adorned or has no usable URL.
|
||||
*/
|
||||
export function filePreview(field: QFieldMetaData, record: QRecord, tableVariant?: string): FilePreviewItem | null {
|
||||
const adornment = findAdornment(field, 'FILE_PREVIEW')
|
||||
if (!adornment) return null
|
||||
const settings = adornment.values ?? {}
|
||||
const fieldName = (key: keyof typeof FILE_PREVIEW_KEYS): string | undefined => {
|
||||
const name = settings[FILE_PREVIEW_KEYS[key]]
|
||||
return typeof name === 'string' && name ? name : undefined
|
||||
}
|
||||
const valueOf = (key: keyof typeof FILE_PREVIEW_KEYS): unknown => {
|
||||
const name = fieldName(key)
|
||||
return name ? record.values[name] : undefined
|
||||
}
|
||||
const text = (value: unknown): string | undefined =>
|
||||
((typeof value === 'string' && value.trim()) || typeof value === 'number') ? String(value).trim() : undefined
|
||||
const positive = (value: unknown): number | undefined => {
|
||||
const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN
|
||||
return Number.isFinite(number) && number > 0 ? number : undefined
|
||||
}
|
||||
|
||||
const download = findAdornment(field, 'FILE_DOWNLOAD') ? fileDownload(field, record, tableVariant) : null
|
||||
const ownUrl = download?.url ?? safePreviewUrl(record.values[field.name], true)
|
||||
const fullSizeField = fieldName('fullSizeUrlField')
|
||||
const url = fullSizeField ? safePreviewUrl(record.values[fullSizeField], true) ?? ownUrl : ownUrl
|
||||
const thumbnailField = fieldName('thumbnailUrlField')
|
||||
const thumbnailSource = thumbnailField ? safePreviewUrl(record.values[thumbnailField], true) : undefined
|
||||
if (!url && !thumbnailSource) return null
|
||||
|
||||
const display = record.displayValues?.[field.name]
|
||||
const fileName = text(valueOf('fileNameField')) ?? download?.fileName
|
||||
?? (typeof display === 'string' && display && !/^(https?:)?\//i.test(display) ? display : undefined)
|
||||
?? lastSegment(url ?? thumbnailSource ?? '') ?? field.label
|
||||
const defaultMimeType = typeof settings.defaultMimeType === 'string' ? settings.defaultMimeType : undefined
|
||||
const declaredType = text(valueOf('mimeTypeField')) ?? (defaultMimeType && !defaultMimeType.endsWith('/*') ? defaultMimeType : undefined)
|
||||
const mimeType = declaredType ?? mimeTypeOfName(fileName) ?? mimeTypeOfName(url) ?? defaultMimeType
|
||||
const kind = kindOfMimeType(mimeType)
|
||||
const size = positive(settings.thumbnailMaxSize)
|
||||
const previewable = settings.previewable !== false && String(settings.previewable).toLowerCase() !== 'false'
|
||||
|
||||
return {
|
||||
...(kind === 'image' && (thumbnailSource ?? url) ? { thumbnailUrl: thumbnailSource ?? url } : {}),
|
||||
url: url ?? thumbnailSource ?? '',
|
||||
fileName,
|
||||
...(mimeType ? { mimeType } : {}),
|
||||
kind,
|
||||
alt: text(valueOf('altTextField')) ?? text(valueOf('captionField')) ?? fileName,
|
||||
...optional('caption', text(valueOf('captionField'))),
|
||||
...optional('width', positive(valueOf('widthField'))),
|
||||
...optional('height', positive(valueOf('heightField'))),
|
||||
...optional('sizeBytes', positive(valueOf('sizeBytesField'))),
|
||||
previewable,
|
||||
thumbnailSize: size ? Math.min(MAX_THUMBNAIL_SIZE, Math.max(MIN_THUMBNAIL_SIZE, Math.round(size))) : DEFAULT_THUMBNAIL_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Previewable items of the given fields across records, in record then field order: the set the
|
||||
* lightbox steps through with the arrow keys.
|
||||
*
|
||||
* @param records - Records on screen.
|
||||
* @param fields - Fields shown for them.
|
||||
* @param tableVariant - Selected table variant as v1 JSON.
|
||||
* @returns The items with the record and field each came from.
|
||||
*/
|
||||
export function filePreviewItems(records: QRecord[], fields: QFieldMetaData[], tableVariant?: string): Array<{ record: QRecord; field: QFieldMetaData; item: FilePreviewItem }> {
|
||||
const adorned = fields.filter(hasFilePreview)
|
||||
if (adorned.length === 0) return []
|
||||
const items: Array<{ record: QRecord; field: QFieldMetaData; item: FilePreviewItem }> = []
|
||||
for (const record of records) {
|
||||
for (const field of adorned) {
|
||||
const item = filePreview(field, record, tableVariant)
|
||||
if (item?.previewable) items.push({ record, field, item })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* An object holding one optional property, or none when the value is missing.
|
||||
*
|
||||
* @param key - Property name.
|
||||
* @param value - Property value.
|
||||
* @returns `{ [key]: value }` or `{}`.
|
||||
*/
|
||||
function optional<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
|
||||
return value === undefined ? {} : { [key]: value } as Record<K, V>
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoded last path segment of a URL.
|
||||
*
|
||||
* @param url - A URL or path.
|
||||
* @returns The segment, or `undefined` when there is none.
|
||||
*/
|
||||
function lastSegment(url: string): string | undefined {
|
||||
if (/^data:/i.test(url)) return undefined
|
||||
const segment = url.split(/[?#]/)[0].split('/').pop()
|
||||
if (!segment) return undefined
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A join column's field as its record values are keyed (`joinTable.field`): the field name and
|
||||
* the FILE_PREVIEW field references get the join table prefix. Fields without FILE_PREVIEW are
|
||||
* returned as they are.
|
||||
*
|
||||
* @param field - The joined table's field.
|
||||
* @param joinTableName - The join table's name.
|
||||
* @returns The field to resolve against a main-table record holding join values.
|
||||
*/
|
||||
export function joinedPreviewField<F extends { name: string; adornments?: FieldAdornment[] }>(field: F, joinTableName: string): F {
|
||||
if (!hasFilePreview(field)) return field
|
||||
const fieldKeys = new Set<string>(Object.values(FILE_PREVIEW_KEYS).filter((key) => key.endsWith('Field')))
|
||||
return {
|
||||
...field,
|
||||
name: `${joinTableName}.${field.name}`,
|
||||
adornments: (field.adornments ?? []).map((adornment) => adornment.type !== 'FILE_PREVIEW' ? adornment : {
|
||||
...adornment,
|
||||
values: Object.fromEntries(Object.entries(adornment.values ?? {}).map(([key, value]) =>
|
||||
[key, fieldKeys.has(key) && typeof value === 'string' && value ? `${joinTableName}.${value}` : value])),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ export type QComponentType =
|
||||
* - `FILE_UPLOAD` — renders a file picker for uploading content to the field
|
||||
* - `TOOLTIP` — adds a hover tooltip with additional context
|
||||
* - `WIDGET` — renders the field value as the data of a named widget
|
||||
* - `FILE_PREVIEW` — renders an image thumbnail (or file icon) that opens a preview
|
||||
*/
|
||||
export type AdornmentType =
|
||||
| 'LINK'
|
||||
@@ -205,3 +206,4 @@ export type AdornmentType =
|
||||
| 'FILE_UPLOAD'
|
||||
| 'TOOLTIP'
|
||||
| 'WIDGET'
|
||||
| 'FILE_PREVIEW'
|
||||
@@ -861,7 +861,8 @@ export interface Banner {
|
||||
* uses the backend's keys, for example `toRecordFromTable` / `target` (LINK),
|
||||
* `color.<value>` / `icon.<value>` (CHIP), `width` (SIZE), `languageMode`
|
||||
* (CODE_EDITOR), `fileNameField` / `defaultMimeType` (FILE_DOWNLOAD), `format`
|
||||
* (FILE_UPLOAD), `staticText` / `tooltipDynamic` (TOOLTIP) and `widgetName` (WIDGET).
|
||||
* (FILE_UPLOAD), `staticText` / `tooltipDynamic` (TOOLTIP), `widgetName` (WIDGET) and
|
||||
* `thumbnailUrlField` / `fullSizeUrlField` / `mimeTypeField` / `thumbnailMaxSize` (FILE_PREVIEW).
|
||||
* Read them with the helpers in `@/lib/utils/adornment-utils`.
|
||||
*/
|
||||
export interface FieldAdornment {
|
||||
|
||||
@@ -912,6 +912,7 @@
|
||||
"fixture": "persona viewer",
|
||||
"scenarios": [
|
||||
"no Edit, Copy or Delete",
|
||||
"the Actions menu (or phone sheet) still offers Developer Mode, with no New, Edit, Copy or Delete (QRun-IO/qqq-frontend-next#45)",
|
||||
"create and edit URLs show the permission message"
|
||||
],
|
||||
"negative": [
|
||||
@@ -1195,7 +1196,7 @@
|
||||
"The phone Actions sheet fits the screen, takes focus, keeps Tab and Shift+Tab inside, closes by Escape or a backdrop tap with focus back on its trigger, and runs Edit"
|
||||
],
|
||||
"negative": [
|
||||
"A read-only user gets no phone Actions trigger (asserted in REC-046)"
|
||||
"A read-only user's phone Actions sheet offers no write action (asserted in REC-046)"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq#708"
|
||||
|
||||
@@ -120,8 +120,14 @@ test.describe('Processes added to every screen, for a user whose metadata omits
|
||||
|
||||
await open(page, '/app/qryBin/2')
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Nuts' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Delete Storage Bin record' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Record actions menu' })).toHaveCount(0)
|
||||
// the default Actions menu still renders (Delete, Developer Mode, Audit), without the omitted process (#45)
|
||||
await page.getByRole('button', { name: 'Record actions menu' }).click()
|
||||
const recordItems = page.locator('[data-qqq-id="record-view-actions-menu"]').getByRole('menuitem')
|
||||
await expect(recordItems.filter({ hasText: 'Delete' })).toHaveCount(1)
|
||||
await expect(recordItems.filter({ hasText: 'Developer Mode' })).toHaveCount(1)
|
||||
await expect(recordItems.filter({ hasText: 'Audit' })).toHaveCount(1)
|
||||
await expect(recordItems.filter({ hasText: 'Tag Records' })).toHaveCount(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('button', { name: 'Tag Records', exact: true })).toHaveCount(0)
|
||||
await open(page, `/app/${TAG}?recordsParam=recordIds&recordIds=1&tableName=person`)
|
||||
await expect(page.locator('[data-qqq-id="not-found-state"]', { hasText: TAG })).toBeVisible()
|
||||
@@ -153,7 +159,12 @@ test.describe('Processes added to every screen, without process permission', ()
|
||||
await open(page, '/app/qryBin/2')
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Nuts' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Edit Storage Bin record' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Record actions menu' })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: 'Record actions menu' }).click()
|
||||
const recordItems = page.locator('[data-qqq-id="record-view-actions-menu"]').getByRole('menuitem')
|
||||
await expect(recordItems.filter({ hasText: 'Developer Mode' })).toHaveCount(1)
|
||||
await expect(recordItems.filter({ hasText: 'Audit' })).toHaveCount(1)
|
||||
await expect(recordItems.filter({ hasText: 'Tag Records' })).toHaveCount(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('button', { name: 'Tag Records', exact: true })).toHaveCount(0)
|
||||
|
||||
// the v1 route the UI uses refuses it, as does the unversioned one
|
||||
|
||||
@@ -56,9 +56,26 @@ test.describe('read-only persona', () => {
|
||||
for (const action of ['Edit', 'Copy', 'Delete']) {
|
||||
await expect(page.getByRole('button', { name: `${action} Person record` })).toHaveCount(0)
|
||||
}
|
||||
await expect(page.getByRole('button', { name: 'Record actions menu' })).toHaveCount(0)
|
||||
// Phone: no Actions trigger opening an empty sheet either
|
||||
await expect(page.getByRole('button', { name: 'Record actions', exact: true })).toHaveCount(0)
|
||||
// The Actions control remains for Developer Mode (#45), but offers no write action
|
||||
const desktopMenu = page.getByRole('button', { name: 'Record actions menu', exact: true })
|
||||
const sheetTrigger = page.getByRole('button', { name: 'Record actions', exact: true })
|
||||
await expect(desktopMenu.or(sheetTrigger).first()).toBeVisible()
|
||||
if (await sheetTrigger.count()) {
|
||||
await sheetTrigger.click()
|
||||
const sheet = page.getByRole('dialog', { name: 'Record actions' })
|
||||
await expect(sheet.getByRole('button', { name: 'Developer Mode', exact: true })).toBeVisible()
|
||||
for (const action of ['New', 'Edit Person', 'Copy Person', 'Delete Person']) {
|
||||
await expect(sheet.getByRole('button', { name: action, exact: true })).toHaveCount(0)
|
||||
}
|
||||
} else {
|
||||
await desktopMenu.click()
|
||||
const menu = page.locator('[data-qqq-id="record-view-actions-menu"]')
|
||||
await expect(menu.getByRole('menuitem', { name: 'Developer Mode', exact: true })).toBeVisible()
|
||||
for (const action of ['New', 'Edit', 'Copy', 'Delete']) {
|
||||
await expect(menu.getByRole('menuitem', { name: action, exact: true })).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await page.goto('/app/person/1/edit', { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'permission' })).toHaveText('You do not have permission to edit Person records.')
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function openForm(page: Page, path: string, heading: string) {
|
||||
|
||||
/**
|
||||
* Starts a record action (Edit, Copy or Delete) whether the view shows it as a
|
||||
* button, inside the Actions menu (tables with record processes), or in the phone
|
||||
* button, inside the Actions menu, or in the phone
|
||||
* action sheet.
|
||||
*/
|
||||
export async function recordAction(page: Page, action: 'Edit' | 'Copy' | 'Delete', tableLabel: string) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// E2E: FILE_PREVIEW thumbnails and the preview lightbox (QRun-IO/qqq#1056), against mocked v1 routes
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
import { checkA11y } from './a11y-helpers'
|
||||
|
||||
// a 2x2 PNG, served for every /media-fixtures/*.png request
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP4z8DwnwEMGBgYGBgYAAAg6AHvOnTvPAAAAABJRU5ErkJggg==', 'base64')
|
||||
|
||||
const field = (name: string, label: string, type: string, adornments: unknown[] = []) =>
|
||||
({ name, label, type, isEditable: false, isRequired: false, isHeavy: false, isHidden: false, adornments })
|
||||
|
||||
const MEDIA_TABLE = {
|
||||
name: 'media',
|
||||
label: 'Media',
|
||||
isHidden: false,
|
||||
primaryKeyField: 'id',
|
||||
fields: {
|
||||
id: field('id', 'ID', 'INTEGER'),
|
||||
name: field('name', 'Name', 'STRING'),
|
||||
originalUrl: field('originalUrl', 'Image', 'STRING', [{
|
||||
type: 'FILE_PREVIEW',
|
||||
values: { thumbnailUrlField: 'thumbnailUrl', fileNameField: 'fileName', mimeTypeField: 'mimeType', widthField: 'width', heightField: 'height', sizeBytesField: 'fileSize' },
|
||||
}]),
|
||||
thumbnailUrl: field('thumbnailUrl', 'Thumbnail URL', 'STRING'),
|
||||
fileName: field('fileName', 'File Name', 'STRING'),
|
||||
mimeType: field('mimeType', 'MIME Type', 'STRING'),
|
||||
width: field('width', 'Width', 'INTEGER'),
|
||||
height: field('height', 'Height', 'INTEGER'),
|
||||
fileSize: field('fileSize', 'File Size', 'LONG'),
|
||||
},
|
||||
sections: [
|
||||
{ name: 'identity', label: 'Media', tier: 'T1', fieldNames: ['id', 'name', 'originalUrl'], isHidden: false },
|
||||
{ name: 'file', label: 'File', tier: 'T2', fieldNames: ['fileName', 'mimeType', 'width', 'height', 'fileSize', 'thumbnailUrl'], isHidden: false },
|
||||
],
|
||||
exposedJoins: [],
|
||||
capabilities: ['TABLE_QUERY', 'TABLE_GET', 'TABLE_COUNT'],
|
||||
readPermission: true,
|
||||
insertPermission: false,
|
||||
editPermission: false,
|
||||
deletePermission: false,
|
||||
usesVariants: false,
|
||||
variantTableLabel: '',
|
||||
}
|
||||
|
||||
const media = (id: number, name: string, mimeType = 'image/png') => ({
|
||||
tableName: 'media', recordLabel: name,
|
||||
values: {
|
||||
id, name, originalUrl: `/media-fixtures/${name}`, thumbnailUrl: mimeType.startsWith('image/') ? `/media-fixtures/t-${name}` : null,
|
||||
fileName: name, mimeType, width: 1200, height: 800, fileSize: 102400 * id,
|
||||
},
|
||||
displayValues: { id: String(id) },
|
||||
})
|
||||
|
||||
const MEDIA_RECORDS = [media(1, 'harbor.png'), media(2, 'sunset.png'), media(3, 'brochure.zip', 'application/zip')]
|
||||
|
||||
const INSTANCE = {
|
||||
...METADATA,
|
||||
tables: { ...METADATA.tables, media: MEDIA_TABLE },
|
||||
apps: {
|
||||
crm: {
|
||||
...METADATA.apps.crm,
|
||||
children: [...METADATA.apps.crm.children, { name: 'media', label: 'Media', type: 'TABLE', iconName: 'image' }],
|
||||
sections: [{ ...METADATA.apps.crm.sections[0], tables: ['person', 'media'] }],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function setupMediaMocks(page: Page) {
|
||||
await setupApiMocks(page)
|
||||
// later routes win: only the whole-instance request, not /metaData/table or /metaData/authentication
|
||||
await page.route(/\/qqq\/v1\/metaData(\?|$)/, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(INSTANCE) }))
|
||||
await page.route('**/qqq/v1/metaData/table/media**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(MEDIA_TABLE) }))
|
||||
await page.route(/\/qqq\/v1\/table\/media\/\d+(\?|$)/, (route) => {
|
||||
const id = Number(/\/table\/media\/(\d+)/.exec(route.request().url())?.[1])
|
||||
const record = MEDIA_RECORDS.find((r) => r.values.id === id)
|
||||
return record
|
||||
? route.fulfill({ contentType: 'application/json', body: JSON.stringify({ record }) })
|
||||
: route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"not found"}' })
|
||||
})
|
||||
await page.route('**/qqq/v1/table/media/count**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ count: MEDIA_RECORDS.length }) }))
|
||||
await page.route('**/qqq/v1/table/media/query**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ records: MEDIA_RECORDS }) }))
|
||||
await page.route('**/media-fixtures/**', (route) => route.fulfill({ contentType: 'image/png', body: PNG }))
|
||||
}
|
||||
|
||||
async function waitForGrid(page: Page) {
|
||||
await expect(page.locator('[data-qqq-id="main-content"]')).toBeVisible({ timeout: 30000 })
|
||||
await expect(page.locator('[data-qqq-id^="grid-row-"]').first()).toBeVisible({ timeout: 30000 })
|
||||
}
|
||||
|
||||
test.describe('FILE_PREVIEW', () => {
|
||||
test('the query grid shows thumbnails that open a navigable preview', async ({ page }) => {
|
||||
await setupMediaMocks(page)
|
||||
await page.goto('/app/media')
|
||||
await waitForGrid(page)
|
||||
|
||||
const thumbnail = page.getByRole('button', { name: 'Preview harbor.png' })
|
||||
await expect(thumbnail).toBeVisible()
|
||||
const image = thumbnail.locator('img')
|
||||
await expect(image).toHaveAttribute('src', '/media-fixtures/t-harbor.png')
|
||||
await expect.poll(() => image.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth)).toBeGreaterThan(0)
|
||||
// a non-image file gets the file icon
|
||||
await expect(page.getByRole('button', { name: 'Preview brochure.zip' }).locator('[data-file-kind="other"]')).toBeVisible()
|
||||
await checkA11y(page)
|
||||
|
||||
await thumbnail.click()
|
||||
// the click opens the preview, not the record
|
||||
await expect(page).toHaveURL(/\/app\/media(\?|$)/)
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole('heading', { name: 'harbor.png' })).toBeVisible()
|
||||
await expect(dialog.getByText('1 of 3')).toBeVisible()
|
||||
await expect(dialog.locator('[data-qqq-id="file-preview-image"]')).toHaveAttribute('src', '/media-fixtures/harbor.png')
|
||||
await expect(dialog.locator('[data-qqq-id="file-preview-details"]')).toContainText('1200 × 800 px')
|
||||
await checkA11y(page)
|
||||
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await expect(dialog.getByRole('heading', { name: 'sunset.png' })).toBeVisible()
|
||||
await page.keyboard.press('+')
|
||||
await expect(dialog.locator('[data-qqq-id="file-preview-image"]')).toHaveAttribute('data-zoom', '1.5')
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await expect(dialog.getByText('No preview is available for this type of file.')).toBeVisible()
|
||||
await expect(dialog.locator('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', '/media-fixtures/brochure.zip')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(thumbnail).toBeFocused()
|
||||
})
|
||||
|
||||
test('the record view shows a large thumbnail with the file name', async ({ page }) => {
|
||||
await setupMediaMocks(page)
|
||||
await page.goto('/app/media/2')
|
||||
await expect(page.locator('[data-qqq-id="main-content"]')).toBeVisible({ timeout: 30000 })
|
||||
const thumbnail = page.getByRole('button', { name: 'Preview sunset.png' })
|
||||
await expect(thumbnail).toBeVisible({ timeout: 30000 })
|
||||
await expect(thumbnail).toContainText('sunset.png')
|
||||
await thumbnail.press('Enter')
|
||||
await expect(page.getByRole('dialog').getByRole('heading', { name: 'sunset.png' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user