Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d0f93f75 |
No files matched your search
@@ -6,5 +6,3 @@ playwright-report
|
||||
coverage
|
||||
.env*
|
||||
*.log
|
||||
|
||||
.pnpm-store
|
||||
@@ -1 +0,0 @@
|
||||
dynamic-components/** -text
|
||||
@@ -1,13 +0,0 @@
|
||||
# Copyright 2026 QRun.IO, Inc.
|
||||
# Licensed under the Apache License, Version 2.0.
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
node-version: '22.20.0'
|
||||
prebuild: corepack enable && pnpm install --frozen-lockfile && pnpm typecheck && pnpm lint && pnpm test && pnpm build:export
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
ci:
|
||||
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
|
||||
github.event.action)) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
acceptance:
|
||||
if: github.event_name != 'pull_request' || !contains(fromJSON('["labeled","unlabeled"]'),
|
||||
github.event.action) || github.event.label.name == 'full-acceptance'
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
with:
|
||||
qqq_ref: ${{ inputs.qqq_ref || '' }}
|
||||
dashboard-image:
|
||||
@@ -38,4 +38,4 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-collaboration/
|
||||
/.next-presence-live/
|
||||
/out/
|
||||
|
||||
# production
|
||||
@@ -43,6 +41,3 @@ target/
|
||||
/log/
|
||||
/.playwright-mcp/
|
||||
/storybook-static/
|
||||
/public/scripting-generated/
|
||||
/public/pdf-generated/
|
||||
/public/dynamic-qfmd-components/
|
||||
@@ -1,5 +0,0 @@
|
||||
|
||||
|
||||
# Reviewed findings committed before the Gitea migration; exact historical fingerprints only.
|
||||
0969231c95f876bb0a41529e8c6c4621a0257aee:src/lib/qa-capture/sanitizers.test.ts:jwt:33
|
||||
23a855297de0f84b564b20d782e01180a68b5257:tests/acceptance/specs/security/restrictions.spec.ts:generic-api-key:248
|
||||
@@ -1,9 +0,0 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
## This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -1,11 +1,8 @@
|
||||
# QQQ Frontend Next
|
||||
|
||||
> **QBit integration branch (2026-11-04).** File delivery/preview, TypeScript editing and generic process controls use the QQQ UIUX APIs. See [current verification and dependency setup](docs/QBIT-RELEASE-STATUS.md) and [remaining release requirements](docs/QBIT-KNOWN-ISSUES.md). These changes are source/local snapshots, not part of the published RC.36 baseline described below.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
**Published baseline: Next UI 1.0.0-RC.36**, which adds accessibility fixes: readable link text, 24px clear targets, h2 widget titles and skip-link focus to RC35. Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing; see the [RC36 notes](docs/releases/1.0.0-RC.36.md) for the preparation checkpoint and verification scope. [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
**This checkout targets Next UI 1.0.0-RC.36**, which adds accessibility fixes: readable link text, 24px clear targets, h2 widget titles and skip-link focus to RC35. Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing; see the [RC36 notes](docs/releases/1.0.0-RC.36.md) for the preparation checkpoint and verification scope. [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
**Current roadmap and known issues:** [Next UI 1.0 tracker](https://github.com/QRun-IO/qqq/issues/713). GitHub issues are the source of truth for status and remaining work; see [issue tracking](docs/ISSUE-TRACKING.md).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM registry-1.docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 AS frontend
|
||||
FROM registry-1.docker.io/library/node:22-bookworm-slim AS frontend
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
@@ -9,12 +9,8 @@ ENV QQQ_BACKEND_URL=http://host.docker.internal:8000
|
||||
ENV NEXT_PUBLIC_MOCK_API=false
|
||||
RUN pnpm build
|
||||
|
||||
FROM registry-1.docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 AS runtime
|
||||
FROM registry-1.docker.io/library/node:22-bookworm-slim AS runtime
|
||||
LABEL org.opencontainers.image.source="https://github.com/QRun-IO/qqq-frontend-next"
|
||||
# Standalone production starts node directly; package managers are build-only.
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn-* \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
|
||||
/usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
|
||||
COPY --from=frontend --chown=node:node /app/.next/standalone ./
|
||||
|
||||
+49
-37
@@ -23,12 +23,10 @@ Axios interceptor
|
||||
▼
|
||||
TanStack Query cache (onError)
|
||||
│ 401 → silently ignored (already handled)
|
||||
│ 403 → toast the server's message, or "Permission denied"
|
||||
│ 403 → toast "Permission denied" / "You do not have permission…"
|
||||
│ 404 → toast "Resource not found"
|
||||
│ 5xx → toast "Server error — retrying…" / "Server error — please try again"
|
||||
│ other status → toast the server's message, or "Request failed (HTTP 409)"
|
||||
│ no response → toast "No response from the server" / "The server took too long to respond"
|
||||
│ every toast names the request, and a repeat of the same failure updates it
|
||||
│ network → toast "Something went wrong"
|
||||
▼
|
||||
Component (optional per-query onError)
|
||||
│ Only needed when generic toast is wrong for the specific UX
|
||||
@@ -68,38 +66,52 @@ The `unauthorizedCallback` is wired to the auth provider's logout/redirect funct
|
||||
|
||||
**Location:** `src/lib/query-client.ts` — `handleQueryError` function
|
||||
|
||||
It is registered on both `QueryCache.onError` and `MutationCache.onError`. A query or mutation whose
|
||||
component renders its own error opts out with `meta: HANDLES_OWN_ERRORS`.
|
||||
```typescript
|
||||
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
|
||||
const status = getErrorStatusCode(error)
|
||||
|
||||
| Failure | Banner text |
|
||||
|---|---|
|
||||
| Cancelled request (an axios cancel or an aborted `fetch`), 401 | none |
|
||||
| 403 | the server's message (of a JSON body), or `Permission denied` |
|
||||
| 404 | `Resource not found` |
|
||||
| 5xx | `Server error — retrying…` (query), `Server error — please try again` (mutation) |
|
||||
| Any other status (400, 409, 422, …) | the server's message (`userFacingError`, `error` or `message` of a JSON body), or `Request failed (HTTP 409)` |
|
||||
| No response (connection failed) | `No response from the server` |
|
||||
| No response (the 30 s timeout) | `The server took too long to respond` |
|
||||
| An error the application raised (`throw new Error('Invalid audit response')`) | that sentence |
|
||||
| A runtime error (`TypeError`, a schema error, …) | `The page hit an unexpected error (TypeError)` |
|
||||
if (status === 401) return // handled by axios interceptor
|
||||
|
||||
Under the text, every banner names what failed: the request as `POST /qqq/v1/table/order/query (HTTP 409)`,
|
||||
or `While loading records` (the query key's namespace in plain words: `possibleValues` reads
|
||||
`possible values`) for a query that failed outside a request. The
|
||||
request is written without its query string, and every path segment that is not a plain word (a record
|
||||
id, a UUID, an encoded value) is shown as `{id}`. Nothing else of the request or response is shown.
|
||||
if (status === 403) {
|
||||
toast.error(
|
||||
context === 'mutation'
|
||||
? 'You do not have permission to perform this action'
|
||||
: 'Permission denied'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
**One banner per failure.** The banner's sonner `id` is its text plus the request, so a failure that
|
||||
repeats (a polling query that fails on every attempt) updates the banner already on screen instead of
|
||||
adding one. A different failure gets its own banner. A lost connection is one cause whichever request
|
||||
met it, so it has a single banner, which names the request that failed most recently. Without the id
|
||||
every failed poll added a banner, and sonner pauses a toast's timer while the tab is hidden or the
|
||||
pointer rests on the stack, so the banners could pile up.
|
||||
if (status === 404) {
|
||||
toast.error('Resource not found')
|
||||
return
|
||||
}
|
||||
|
||||
**Console.** Each banner is also logged once with `console.warn('[API] Request failed:', { context,
|
||||
status, code, name, request, area })`: the HTTP status, the axios error code (`ERR_NETWORK`,
|
||||
`ECONNABORTED`, `ERR_BAD_REQUEST`), the error's name, the request as above and the query namespace.
|
||||
It never logs a message, body, header or query string, because those can carry record data.
|
||||
if (status !== undefined && status >= 500) {
|
||||
toast.error(
|
||||
context === 'mutation'
|
||||
? 'Server error — please try again'
|
||||
: 'Server error — retrying…'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Network failure or unexpected status
|
||||
toast.error('Something went wrong')
|
||||
}
|
||||
```
|
||||
|
||||
This handler is registered on both `QueryCache` and `MutationCache`:
|
||||
|
||||
```typescript
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => handleQueryError(error, 'query'),
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => handleQueryError(error, 'mutation'),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
**Implication for components:** For most queries and mutations, you do not need `onError` at all. The global handler covers toasting. Add per-query handling only when you need to do something the global handler cannot — such as mapping a 400 error's field messages back to form fields.
|
||||
|
||||
@@ -127,7 +139,7 @@ Returns `undefined` for anything else (non-object errors, null, network failures
|
||||
|
||||
### 400 — Validation Error
|
||||
|
||||
The global handler toasts the server's message for a 400 (or `Request failed (HTTP 400)` when it sent none). 400s typically carry field-level validation messages that belong beside individual form fields, so a form handles 400 explicitly, and sets `meta: HANDLES_OWN_ERRORS` on the mutation when the toast would repeat what the form shows.
|
||||
The global handler does NOT toast on 400 because 400s typically carry field-level validation messages that must be mapped back to individual form fields. Handle 400 explicitly wherever you submit a form.
|
||||
|
||||
```typescript
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
@@ -195,7 +207,7 @@ No additional per-component handling is needed for 5xx unless you want a custom
|
||||
|
||||
### Network Failure (no response)
|
||||
|
||||
Treated as non-4xx by the retry logic — queries retry up to 3 times. After all retries are exhausted the global handler toasts "No response from the server" (or "The server took too long to respond" for the 30 s timeout) with the request that failed. No per-component handling needed for the common case.
|
||||
Treated as non-4xx by the retry logic — queries retry up to 3 times. The global handler toasts "Something went wrong" after all retries are exhausted. No per-component handling needed for the common case.
|
||||
|
||||
---
|
||||
|
||||
@@ -262,8 +274,8 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
|
||||
onError: (error) => {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
// Map field errors from the server response body to form fields.
|
||||
// The global handler toasts the server's message for a 400; the form
|
||||
// places each field's message beside it.
|
||||
// The global handler does NOT toast on 400, so we must do it here or
|
||||
// rely on inline field error messages in the form UI.
|
||||
const body = (error as AxiosError<{ errors: Record<string, string> }>).response?.data
|
||||
if (body?.errors) {
|
||||
for (const [field, message] of Object.entries(body.errors)) {
|
||||
@@ -293,6 +305,6 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
|
||||
| Layer | File | Handles |
|
||||
|---|---|---|
|
||||
| Axios interceptor | `src/lib/api/client.ts` | 401 → redirect to login |
|
||||
| Global query handler | `src/lib/query-client.ts` | Every status but 401, no response, page errors → one toast per failure, naming the request |
|
||||
| Global query handler | `src/lib/query-client.ts` | 403, 404, 5xx, network → toast |
|
||||
| Per-component `onError` | Your component | 400 field errors, custom empty states, navigation |
|
||||
| `getErrorStatusCode` | `src/lib/utils/error-utils.ts` | Normalize `AxiosError` and plain-object errors to HTTP status code |
|
||||
@@ -1,29 +0,0 @@
|
||||
# Isolated scripting editor
|
||||
|
||||
ScriptViewer routes explicit server-controlled ISOLATED projects before legacy hooks mount. Unknown nonempty modes fail closed. The native scripting extension must be advertised; otherwise isolated source stays unavailable. Developer page provides semantic project creation and opens the same IDE; project names never imply execution mode or actor identity.
|
||||
|
||||
The isolated CodeMirror editor supports multi-file TypeScript, immutable source history, explicit save/build/test/publish, server compiler diagnostics, scoped API declarations, JSON context tests and exact-commit GitHub imports with retained provenance. Edits invalidate build/test evidence. Saves and imports carry the observed latest revision. No browser credential fields or background repository synchronization.
|
||||
|
||||
## Semantic language service
|
||||
|
||||
Pinned editor-only typescript-editor alias6.0.3 powers a same-origin classic worker. It receives a bounded virtual filesystem and host-generated SDK declarations, with57 trusted ES2022 standard-library files bundled at build time. Diagnostics, completion, hover, go-to-definition(F12), formatting(Ctrl+Shift+F), file navigation and compiler diagnostic selection operate on the same workspace. Text remains ordinary undoable editor state. Tab moves focus.
|
||||
|
||||
There is no source execution, compiler emit, runtime fetch, arbitrary package loading, filesystem, DOM/Node declarations or user npm hooks. Browser analysis is advisory; authoritative strict builds still use the qualified isolated server. Worker cold deadline10s/warm3s terminates it on timeout; bounded request/result sizes, four pending calls, idle30s termination and recycling after200 requests limit work. The next explicit request may start a new worker; no hidden retry of timed-out source analysis. A browser worker supplies termination responsiveness, not an OS-enforced memory sandbox. Debugging and automatic imports are not implemented.
|
||||
|
||||
scripts/build-scripting-worker.mjs assembles only trusted package/compiler and our own session source into public/scripting-generated/typescript-6.0.3.worker.js, with license/notices. Generated9.6MB artifact is ignored and built before dev/build/build:export. Runtime worker requests no other resource. Exact compiler and library dependencies remain independent from server runtime qualification.
|
||||
|
||||
The alias package also exports tsc and would shadow the existing compiler under pnpm exec tsc. Use **pnpm typecheck** or **pnpm tsc**: both explicitly call node_modules/typescript/bin/tsc (the existing5.9 build compiler). Existing CI typecheck scripts follow that path. Do not replace frontend build TypeScript with the editor compiler. Compatible CodeMirror versions are pinned through pnpm overrides to avoid duplicate nominal EditorView/state classes.
|
||||
|
||||
## Authorization and uncertainty
|
||||
|
||||
Server permissions and worker availability control lifecycle actions, with backend checks authoritative. Use the matching Core `4.1.0-SNAPSHOT` dependency set described in [current integration status](QBIT-RELEASE-STATUS.md); the old mixed private-artifact/RC20 setup is superseded. Project creation and publication use typed core authority; isolated revision/file content and ownership are immutable. Real native HTTP lifecycle succeeds with protocol-only fixture worker; no production provider is implied.
|
||||
|
||||
Stop waiting or network timeout makes mutation outcome uncertain and prevents another mutation. History refresh does not prove completion; inspect history/reopen. Creation uncertainty directs the user to Scripts before retrying. Abort does not undo committed work. GitHub import needs a server-installed managed access provider and full immutable commit.
|
||||
|
||||
## Verification
|
||||
|
||||
2026-10-30: **114 focused tests across12 files**, existing build-compiler typecheck and changed-file ESLint passed. Real Playwright Chromium worker check exercises strict diagnostics, multi-file completion/hover/definition/format, rejects remote types and proves source does not execute or fetch. Final optimized production build with typecheck, static generation and standalone packaging passed at 21:36, including the creation panel (parent run20209, editor-build-verification.log). Native server tests separately cover create/save/build/context-test/publish and permission/association/legacy denials. No live browser-to-deployed-worker end-to-end or production sandbox qualification claim.
|
||||
|
||||
Run pnpm scripting:worker:check after generating the worker; this environment uses PLAYWRIGHT_BROWSERS_PATH=C:/Users/james/AppData/Local/ms-playwright. The harness serves only a local trusted page/worker with connect-src none and closes browser/server on completion.
|
||||
|
||||
Current integration verification (2026-11-04): the real Chromium offline-worker check passes strict diagnostics, multi-file completion/hover/definition and formatting, with no source execution or external requests. Typecheck, lint and production worker packaging pass on the canonical integration branch. See [current status](QBIT-RELEASE-STATUS.md) for the final full-suite evidence. This frontend result does not qualify the server's untrusted-code runtime.
|
||||
@@ -1,47 +0,0 @@
|
||||
# PDF preview boundary
|
||||
|
||||
The generic file lightbox uses pinned Mozilla `pdfjs-dist` **6.4.299**, loaded only for
|
||||
PDFs, and a locally packaged worker. It no longer embeds uploaded PDF responses in an
|
||||
application-origin iframe or offers a PDF "Open original" navigation. Downloads retain
|
||||
the existing owner authorization callback.
|
||||
|
||||
Retained attachment previews reach it only after the tracked-download helper verifies
|
||||
server receipt, byte length and SHA-256, and assigns `application/pdf` from the fixed
|
||||
PDF signature. The generic renderer accepts local same-origin blob URLs or native
|
||||
file endpoints at the exact configured API origin and base path. Both dashboard hosts must permit `blob:` in CSP `connect-src` so the verified local bytes can be read; the current Javalin and standalone defaults include it. It never forwards API
|
||||
credentials to arbitrary external URLs. Uploaded MIME labels alone are insufficient.
|
||||
|
||||
The renderer supplies bytes (not a document URL) to an explicitly created parser
|
||||
Worker, preventing the library's fake-worker fallback. It creates a canvas and an
|
||||
optional plain-text view only: no PDF annotation/action, form, XFA, scripting or HTML
|
||||
viewer layer. Auxiliary resource requests are rejected, worker fetching and WASM are
|
||||
disabled, and no document-selected external URL is followed. Missing auxiliary fonts
|
||||
or unsupported resources can limit fidelity; the retained original is downloadable.
|
||||
|
||||
Limits: 20 MiB input, 1000 pages, one visible page at a time, 8 million canvas pixels,
|
||||
8192 pixels per edge, 30 seconds to load/parse, 15 seconds per page render/text read.
|
||||
Text is streamed and stopped at 10,000 items or 100,000 characters, slicing each
|
||||
string before aggregation. The stream is cancelled on page change, close or timeout;
|
||||
page controls remain busy through text completion.
|
||||
Closing the dialog aborts input loading, cancels rendering, destroys the loading task
|
||||
and terminates the worker. These are application limits, not a browser-process memory
|
||||
quota: compressed PDF complexity can allocate parser memory before page checks.
|
||||
Canvas rendering and a Web Worker do **not** constitute a full hostile-parser security
|
||||
sandbox. Browser/library patching, CSP and deployment qualification remain necessary.
|
||||
|
||||
Primary references checked on 2026-10-30:
|
||||
|
||||
- [Mozilla release 6.4.299](https://github.com/mozilla/pdf.js/releases/tag/v6.4.299)
|
||||
- [Mozilla PDF.js API](https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html)
|
||||
- [Mozilla prior arbitrary-JavaScript advisory](https://github.com/mozilla/pdf.js/security/advisories/GHSA-wgrm-67xf-hhpq)
|
||||
|
||||
The prior advisory affected versions through 4.1.392 and was fixed in 4.2.67. The current
|
||||
6.4.299 API no longer exposes the old `isEvalSupported` option; this integration does
|
||||
not pretend an obsolete option supplies isolation. It uses the pinned current release,
|
||||
no PDF scripting manager, and the application's restrictive script CSP.
|
||||
|
||||
## Current verification (2026-11-04)
|
||||
|
||||
The production export and local Maven JAR include the pinned worker. Twelve Chromium browser scenarios pass against that packaged UI, including actual PDF worker/canvas pixels, page navigation, no executed PDF OpenAction, separate retained preview/download receipts, refusal of incomplete receipts, and ordered-file submission. Real Core/Collaboration API sessions separately pass ordinary signed-PDF canvas preview, page controls and PDF download.
|
||||
|
||||
Signed attachment PDFs are read with credentials omitted and redirects refused, bounded to 20 MiB and 30 seconds, and checked by byte signature before a local preview URL is exposed. Their storage origin needs host CSP/CORS configuration. Tracked deliveries retain the receipt/hash path. Cancellation and streaming-limit tests cover both paths. See [current status](QBIT-RELEASE-STATUS.md) for the full suite and [release requirements](QBIT-KNOWN-ISSUES.md) for remaining deployment qualifications.
|
||||
@@ -1,13 +0,0 @@
|
||||
# Metadata-driven process result fields
|
||||
|
||||
`ViewFormComponent` delegates adorned result fields to the shared `FieldValue` component. Hidden fields remain in the result value map so native metadata can supply display companions; they are not rendered as separate controls. Ordinary process values and validation errors keep their existing behavior.
|
||||
|
||||
`LINK` supports either `toRecordFromTable` (a static table name) or `toRecordFromTableField` (the name of another result field containing the table name). The field value is the record identity. Navigation uses the native encoded table/record route. A missing companion table produces no link. Possible-value sources that identify a known instance table also retain native record navigation.
|
||||
|
||||
`FILE_DOWNLOAD` can specify `fileNameField` and `deliveryModeField`. If the latter is present, its companion value must be `SIGNED_URL` or `AUTHENTICATED_FETCH`; missing or unknown modes render no access action. Older fields without that metadata retain their existing behavior.
|
||||
|
||||
For `AUTHENTICATED_FETCH`, the issued URL is never placed in an `href`, image source or iframe. Download uses the shared authenticated transfer helper, which checks the configured origin, response bounds, exact digest/length and completed durable receipt. An additional `FILE_PREVIEW` adornment enables an explicit preview action. Only verified PDF/raster blobs reach the shared preview component; component-owned blob URLs are revoked when closed or unmounted. Unsupported content and transfer/audit errors remain visible and never fall back to direct access.
|
||||
|
||||
`CODE_EDITOR` result fields use the shared read-only source display, including renderer-supplied plain catalog help and HTML/JSON examples. No template or script evaluation occurs in a process result.
|
||||
|
||||
Verification for this increment: 178 focused tests across 18 files, owned ESLint and the explicit build-compiler typecheck passed. Each actual transfer creates a fresh correlation UUID only after validating the configured endpoint, preserving the immutable file coordinate and using that same fresh attempt for its receipt. Source changes and unmount abort active body/receipt work. Final integrated production-build/browser acceptance remains pending. These generic components do not embed a QBit name or depend on its backend implementation.
|
||||
@@ -1,11 +0,0 @@
|
||||
# QBit integration known issues and release requirements
|
||||
|
||||
Updated: 2026-11-05.
|
||||
|
||||
- The Core, Collaboration and Next UI changes are verified source branches and local snapshots, not a published dependency set. Public Core RC.21 / Collaboration RC.6 lack the required retention contracts. Merge and publication through the existing release process remain separate from local verification.
|
||||
- The frontend checks include real Core/Collaboration sessions and file storage, plus browser protocol fixtures for retained receipts. They do not establish end-to-end document generation/printing or deployed untrusted-script execution. Those QBits and workers retain their own qualification requirements.
|
||||
- External signed PDF storage needs CORS for the dashboard origin and its origin in CSP `connect-src`. Application credentials are never sent to those URLs. Hosts replacing dashboard CSP must retain `blob:` in `connect-src` for verified local previews.
|
||||
- Lint reports six existing warnings in unchanged files. The unit suite has one existing skipped test. Canonical Next 16.4.1 and Zod 3 are preserved; dependency patch upgrades remain a separate release review.
|
||||
- The local artifact is `qqq-frontend-next:1.0.0-SNAPSHOT`, built with an explicit Maven revision override. It includes generated PDF and TypeScript workers. The prepared release-managed version is `1.0.0-RC.38`; RC.36 and RC.37 already exist in Central and must never be overwritten.
|
||||
- Next publisher support and the shared producer are merged (Praetor PR238, platform-ci PR67); the local caller pins327e9b3a9d45a08665fa9468f0f9741e972d8e3e. Runtime qualification passed736 Linux tests and its vulnerability scan. GitOps PR269 deployed the qualified runtime and exact workflow/GAV enrollment; ArgoCD adoption and the scheduled publisher startup are verified. The signed application tag and resulting Central artifact still remain. The existing tag, package.json and POM version agreement, typecheck/lint/tests/static export and signed Central delivery are preserved. Neither the local JAR nor source merge proves publication.
|
||||
- The full application acceptance matrix across all browsers and deployment-specific storage/printer/scanner behavior is not claimed by the scoped QBit integration checks.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Next UI QBit integration status
|
||||
|
||||
Updated: 2026-11-05. Source integration is prepared; no published release or deployment is claimed.
|
||||
|
||||
Current main `090f5febeaacf3f9c39a22952e5c93c859cdbeb7` (permission editor and companion Tools workspace) is integrated. The latest merge passed all 110 affected Authentik/widget/process tests, typecheck and the copyright-header gate. Both branches supplied the same worker limit; the merged configuration retains one setting. Prior focused Tools/UI-API checks covered 61 cases. These are integration checks, not completed release CI.
|
||||
|
||||
Core PR1343 merged as7825261ab after full CI4482 passed; promotion PR1345 merged as1bef44cf and immutable publication4629 is running. Collaboration must consume the resulting published version. Next publisher support merged in Praetor PR238 and platform-ci PR67. GitOps PR269 merged as883e1ce3; ArgoCD adopted the exact configuration and a scheduled publisher is running image sha256:ea068b2a02147280cacedd095fbc55114daa9e3db72f1b0ca8511bc43bfd12c7 with the exact Next enrollment. Its 736 Linux tests and vulnerability scan passed. The dashboard candidate version is 1.0.0-RC.38; this source preparation is not a published release.
|
||||
|
||||
Candidate CI4602 passed 3,800 tests but hit the unchanged five-second timeout in two `RecordQuery` filter tests. These also timed out on an earlier baseline; the newer main baseline's Node job passed. Profiling the focused cases identified repeated whole-page accessibility lookups. Scoping those lookups to the existing filter controls and confirmation dialog reduced their sampled CPU cost from 387 to 261 samples; the same assertions, interactions and timeouts are preserved. All 17 tests in the affected file and focused lint pass after the change. Release CI for this correction remains required; these focused results do not replace it.
|
||||
|
||||
## Changes
|
||||
|
||||
The canonical Next UI integration renders file preview/download adornments, retained-transfer receipts, isolated TypeScript editing and generic process results through QQQ metadata and UIUX APIs. It adds no table-specific application routes. The completion pass fixes Windows static-export execution, local PDF reads under dashboard CSP, and ordinary signed-storage PDF previews. Signed storage reads omit credentials, refuse redirects and enforce byte/time limits before canvas rendering. Retained transfers preserve their receipt/hash checks. Tests now use four workers by default to avoid high-core host contention; no timeout or coverage threshold was relaxed.
|
||||
|
||||
## Verified dependencies
|
||||
|
||||
- Core `feat/standalone-scripting-api`, code commit `d384d1c753cb0ac2ee4d0f6ccd564a3e47488a1c`, locally installed as `4.1.0-SNAPSHOT`.
|
||||
- Collaboration `feat/record-files-standalone`, `53bd8f00d8a180b0fa4eb6cd029dc31044b6d4b3`, locally installed as `0.1.0-SNAPSHOT`.
|
||||
- Next UI retains canonical Next 16.4.1, Zod 3 and existing dependencies. Local packaging explicitly overrides Maven revision to `1.0.0-SNAPSHOT`; it does not replace public RC.36.
|
||||
|
||||
## Completed verification
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Full `pnpm test:coverage` | 359 files; **3,784 passed**, 1 existing skip; all coverage gates passed |
|
||||
| Coverage | 93.14% statements, 87.00% branches, 94.57% functions, 94.95% lines; API/utility sub-gates also passed |
|
||||
| Lint and license headers | Zero errors, six existing warnings in unchanged files; Apache headers passed |
|
||||
| Typecheck | Passed |
|
||||
| Offline TypeScript worker in Chromium | Strict diagnostics, multi-file completion/hover/definition and formatting passed; no source execution or external fetch |
|
||||
| Windows production static export | Passed using `pnpm build:export` |
|
||||
| Local Maven snapshot JAR | Built successfully with PDF/TypeScript workers included |
|
||||
| Browser file/process regressions | **12 passed** against the packaged production UI; API fixtures exercise PDF canvas, separate audit receipts, incomplete-receipt refusal and keyboard ordering |
|
||||
| Live Core + Collaboration file workflow | Upload, thumbnail, preview, open, exact-byte download, rename/describe, reload and delete passed with synthetic data |
|
||||
| Live PDF and authorization | Signed PDF canvas/page controls and download passed; another session was denied access to the parent record |
|
||||
| Live UI API specification check | Bundled 1.8.0 compatible with running Core 1.10.0 |
|
||||
|
||||
The live tests used the existing disposable `CollaborationDevServer`, synthetic Alice/Bob sessions and the current UI JAR on loopback. No API mocks were used for those file/permission checks. The separate 12-case browser suite uses its existing API fixtures, not a deployed Record Files or scripting worker. Test-owned services and browsers were stopped after verification.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```shell
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test:coverage
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm scripting:worker:check
|
||||
pnpm build:export
|
||||
mvn -B -Drevision=1.0.0-SNAPSHOT package
|
||||
pnpm ui-api:check --server-url http://localhost:18098
|
||||
```
|
||||
|
||||
Start the matching Collaboration demo using its README and place the resulting UI JAR first on its classpath. The live check used port 18098. The standard `pnpm test:e2e` owns its frontend fixture for the browser scenarios. Maven was invoked through the sibling Collaboration wrapper during this Windows run.
|
||||
|
||||
Local JAR SHA-256: `0626B33DF49569476E17FA080E2777A7AD9C7AF717CB75DFABF9EB40BE0A46A8`.
|
||||
|
||||
## Release boundary
|
||||
|
||||
See [known issues and release requirements](QBIT-KNOWN-ISSUES.md). Compatible public artifact publication, the complete application acceptance matrix and deployed document/script worker qualification are not implied by these source and local integration results.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Record Files client integration
|
||||
|
||||
Updated: 2026-11-04. Current canonical integration branch: `feat/standalone-qbits-ui`. See [verification and dependencies](QBIT-RELEASE-STATUS.md).
|
||||
|
||||
## Standard controls and APIs
|
||||
|
||||
The Files tab uses standard themed buttons for Preview, Open, Download and Rename / describe. Navigation uses Next links with keyboard focus. Attachment metadata supplies independent `canEdit` and `canDelete` permissions; an explicitly denied edit capability never falls back to deletion permission. The server remains authoritative.
|
||||
|
||||
Every download requests a fresh owner-issued descriptor. `AUTHENTICATED_FETCH` deliveries use the configured native Record Files route, a new correlation identifier, and a separately verified completion receipt, byte count and SHA-256. Preview and save are separate attempts. A failed receipt never becomes a direct link or a successful save. Generic `FILE_DOWNLOAD` and `FILE_PREVIEW` adornments use the same controls; they do not require table-specific pages.
|
||||
|
||||
Tracked raster/PDF previews expose a local object URL only after verification. Ordinary signed PDF previews read bounded bytes with credentials omitted, redirects refused and a 30-second deadline. Both use the same canvas PDF renderer; uploaded PDFs are never framed. Closing the preview aborts work and revokes local URLs. Hosts serving signed files from another origin must allow that storage origin in CSP `connect-src` and configure storage CORS for the dashboard origin. This never forwards QQQ credentials to storage.
|
||||
|
||||
Open previews supported media; unsupported types use the authorized download action. The UI does not claim that a browser saved bytes or that a printer physically printed them. Retention, upload sessions, collections and durable audits belong to their server QBits.
|
||||
|
||||
## Dependency order
|
||||
|
||||
Install matching Core, then Collaboration, then Record Files and other feature QBits before the matching frontend. Public Core RC.21 and Collaboration RC.6 do not contain the new retention contracts. Local verification uses Core `4.1.0-SNAPSHOT` and Collaboration `0.1.0-SNAPSHOT`; see their branch status files for exact source commits. Core's dashboard CSP must include `blob:` in `connect-src` for local PDF-byte reads.
|
||||
|
||||
## Verification scope
|
||||
|
||||
The full component/API suite and coverage gates, production export, browser PDF/receipt/ordering scenarios and live Collaboration file workflow are recorded in [current status](QBIT-RELEASE-STATUS.md). Live tests use disposable synthetic data and real Core/Collaboration APIs. Browser mocks of the retained-transfer protocol do not establish production storage or worker qualification.
|
||||
@@ -1,11 +0,0 @@
|
||||
# 1.0.0-RC.38 — prepared candidate
|
||||
|
||||
This candidate is not yet released. Merge and publication follow the compatible Core and Collaboration releases.
|
||||
|
||||
- Record file controls support previews, opening and downloading through the documented delivery APIs, including PDF previews.
|
||||
- Script authoring includes the isolated TypeScript editor, server lifecycle actions and record hook controls.
|
||||
- Process results support file delivery, PDF preview and ordered record lists through standard metadata.
|
||||
- Record queries respect authorization-filtered pagination; record layouts preserve section metadata.
|
||||
- The current permission editor and companion Tools workspace remain integrated.
|
||||
|
||||
The release uses the standard UI API and extension documents. Worker execution, storage configuration and printer/scanner qualification remain responsibilities of the respective QBits and host application. See [verification and release status](../QBIT-RELEASE-STATUS.md) and [known requirements](../QBIT-KNOWN-ISSUES.md).
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
## Features
|
||||
|
||||
- **Tools:** the `QQQ_TOOLS` widget provides a shared setup workspace for CLI/TUI, mobile scanners and Print Client, with platform-specific install commands, copyable scripts and instance connection details. An account-menu shortcut follows visible Tools metadata. Requires the Tools Qbit; existing device enrollment and permissions remain unchanged.
|
||||
|
||||
- **UI API 1.6.0 (QQQ 4.1.0-RC.15).** Next reads the typed `clientCapabilities` of table metadata for saved views, sharing, bulk processes and audits, applies `field.validation` (required, length, inclusive and exclusive bounds, patterns) before a request, reads client-neutral `uiSettings` setting by setting with the deprecated `materialDashboard` values as fallback, and shows the process error `traceId`. The full `materialDashboardTheme` still wins over the `uiSettings.theme` subset. Error messages keep their order (`userFacingError`, `error`, then the new `message`). Audits fall back to the permission-filtered instance metadata when a table's capability is unavailable, since RC.15 marks unaudited tables unavailable although explicit audits can exist. Off-spec calls stay at 0.
|
||||
- **What's new:** a paged release history with new badges, safe links and screenshots, notification deep links, and an unread count in the user menu. Uses UI API extension `release-notes` 1.0.0; requires qbit-release-notes 0.1.0-RC.1+.
|
||||
- **Profile photo and avatars everywhere** (QQQ 4.1.0-RC.17, UI API 1.8.0 `avatars`):
|
||||
@@ -16,16 +14,8 @@
|
||||
|
||||
## Product fixes
|
||||
|
||||
- **A preference action that connects another service takes you there.** A provider that starts an OAuth connection (Connect Slack, for example) answers with the address of the service's consent page in the action result's `message`, since the UI API has no redirect field. That address was shown as the text of a success toast: long, not clickable and gone in seconds. A message that is nothing but one absolute `https:` address is now where the browser goes, in the same tab, and is never shown. A message that only mentions an address is still a message. Plain `http:`, addresses with credentials and every other scheme are not followed.
|
||||
- **Preferences says how a connection ended.** A flow that left the dashboard returns to `/app/preferences?section=<name>&result=<code>` when it succeeded or `&error=<code>` when it did not. The section now shows the outcome once, above its form, as the section's label and the code as words (`Slack: connected.`, `Slack: authorization denied.`), as a status or an alert, and takes it off the address so a reload does not repeat it. Only a fixed lowercase code is read from the address, never a sentence.
|
||||
- **Links in process HTML look like links.** An `HTML` process component rendered links in the text color with no underline, so a link the backend put on a screen (a bulk load template, a "Continue to Slack" link) could not be told from the words around it. They now use the link color and an underline; a link's own inline style still wins.
|
||||
|
||||
- **The Ask button's activity dot sits on its icon.** While a reply was being written, the dot was placed in the bottom-right corner of the button's hit area, which has no visible edge and, from 1440 px, extends past the "Ask" label, so the dot floated under and to the right of the label. The dot and the unread badge are now positioned against the sparkles icon, at every width and with the panel open or closed. The bell and Jobs badges use the same anchor and keep their place.
|
||||
|
||||
- **Logout no longer races an in-flight request.** Logout ends the API client's session generation first: every request still in flight (axios and the realtime and assistant streams) is aborted, and any response of the old generation that still arrives is discarded, so none can refresh the `sessionId` cookie the logout expires or change client auth state (SEC-037, SEC-048).
|
||||
|
||||
- **The error banner says what failed, and no longer stacks.** The banner for a failed request used to read "Something went wrong" for every failure without an expected status, and a polling request that kept failing added one banner per attempt. It now reads `No response from the server`, `The server took too long to respond`, the server's own message, or `Request failed (HTTP 409)`, and names the request below it (`POST /qqq/v1/table/order/query`, without the query string, with ids shown as `{id}`). A repeat of the same failure updates the banner on screen; a lost connection shows one banner whichever requests met it. The 403, 404 and 5xx banners keep their wording and gain the request line; a 403 without a server message reads `Permission denied`. A cancelled request, an aborted `fetch` and a 401 show no banner. Each banner logs its status, axios code, error name and request to the console once (`[API] Request failed:`), never a message, body, header or query string.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
None yet.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"q-sequential-workflow.bundle.js","mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACD,O;;;;;;;;;;;;;;ACVA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kDAAkD;AAClD;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;AClDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,gCAAgC,0CAA0C;AAC1E;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,0BAA0B;AACtD;AACA;AACA;AACA;AACA;AACA,4BAA4B,4BAA4B;AACxD;AACA;AACA;AACA;AACA;AACA,CAAC;AACkB;AACnB,mC;;;;;;;;;;;;;;AC/DA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb;AACA,yCAAyC,iBAAiB;AAC1D;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;;;;;ACzDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,qBAAqB,SAAI,IAAI,SAAI;AACjC,6EAA6E,OAAO;AACpF;AACA;AACA;AACA;AACA;AACA;AACA;AACmD;AACE;AACH;AACJ;AAC9C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,8BAA8B;AAC1D,yCAAyC,2DAAc;AACvD;AACA;AACA;AACA;AACA,4BAA4B,yGAAyG;AACrI,wDAAwD,2DAAc;AACtE;AACA;AACA,4BAA4B,uDAAY;AACxC;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6CAA6C,6DAAY;AACzD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,4BAA4B;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kEAAkE,gBAAgB;AAClF;AACA;AACA;AACA;AACA;AACA;AACA,mDAAmD,gBAAgB;AACnE;AACA;AACA;AACA;AACA;AACA;AACA,qDAAqD,gBAAgB;AACrE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAAS;AACT;AACA,2DAA2D,WAAW,iJAAiJ;AACvN;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;AC5JA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,yBAAyB;AACrD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,yBAAyB;AACrD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,oCAAoC,wBAAwB;AAC5D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAAS;AACT,gDAAgD;AAChD;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACuB;AACxB,wC;;;;;;;;;;;;;;AC3GA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,8CAA8C;AAC/C,6C;;;;;;;;;;;;;;AC9CA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,qBAAqB,SAAI,IAAI,SAAI;AACjC,6EAA6E,OAAO;AACpF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AAC0B;AAC3B,2C;;;;;;;;;;;;;;AClDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;;;ACvCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACoD;AACF;AAClD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,0CAA0C;AAC1C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,wBAAwB,6EAA6E;AACrG;AACA,8BAA8B,6DAAe;AAC7C;AACA,wBAAwB,6EAA6E;AACrG;AACA,8BAA8B,2DAAc;AAC5C;AACA,wBAAwB,+EAA+E;AACvG;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,iDAAiD,gBAAgB;AACjE;AACA;AACA;AACA;AACA;AACA;AACA,iDAAiD,gBAAgB;AACjE;AACA;AACA;AACA;AACA;AACA;AACA,mDAAmD,gBAAgB;AACnE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACuB;AACxB,wC;;;;;;;;;;;;;;;;;;ACjHA;AACgG;AACjB;AAC/E,8BAA8B,sEAA2B,CAAC,+EAAqC;AAC/F;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACALine truncated
|
||||
@@ -20,8 +20,6 @@ const BACKEND_PREFIXES: string[] = backendPrefixes
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Allow acceptance servers to run without sharing the developer server's lock/cache.
|
||||
distDir: process.env.QQQ_NEXT_DIST_DIR || '.next',
|
||||
// Reported to the backend as frontendVersion and shown on the developer page.
|
||||
env: { NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version },
|
||||
...(exportBuild
|
||||
|
||||
+19
-37
@@ -1,15 +1,12 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.38",
|
||||
"version": "1.0.0-RC.36",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
"scripting:worker": "node scripts/build-scripting-worker.mjs",
|
||||
"scripting:worker:check": "node scripts/build-scripting-worker.mjs && node scripts/check-scripting-worker.mjs",
|
||||
"dev": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && next dev --turbopack",
|
||||
"build": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && next build",
|
||||
"build:export": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && node scripts/build-export.mjs",
|
||||
"workflow:bundle": "node scripts/stage-dynamic-components.mjs",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"build:export": "QQQ_NEXT_OUTPUT=export NEXT_PUBLIC_MOCK_API=false next build",
|
||||
"build:jar": "pnpm build:export && mvn -B -q clean package",
|
||||
"start": "next start",
|
||||
"lint": "eslint src && node scripts/check-license-headers.mjs",
|
||||
@@ -25,20 +22,17 @@
|
||||
"test:acceptance": "node scripts/acceptance.mjs",
|
||||
"perf:budget": "node scripts/perf-budget.mjs",
|
||||
"ui-api:check": "node scripts/check-ui-api-spec.mjs",
|
||||
"tsc": "node node_modules/typescript/bin/tsc",
|
||||
"typecheck": "node node_modules/typescript/bin/tsc --noEmit",
|
||||
"tsc": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "git config core.hooksPath .githooks || true",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"pdf:worker": "node scripts/build-pdf-worker.mjs"
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-sql": "6.9.0",
|
||||
"@codemirror/language": "6.11.2",
|
||||
"@codemirror/lint": "6.8.5",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -60,11 +54,10 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.4.17",
|
||||
"dompurify": "^3.4.16",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.485.0",
|
||||
"next": "16.4.3",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"next": "16.3.8",
|
||||
"rapidoc": "10.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -77,16 +70,15 @@
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "15.6.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"typescript-editor": "npm:typescript@6.0.3",
|
||||
"zod": "^4.6.7"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.1",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@playwright/test": "1.64.0-alpha-2026-10-01",
|
||||
"@storybook/addon-docs": "10.6.2",
|
||||
"@storybook/nextjs-vite": "10.6.2",
|
||||
"@storybook/react": "10.6.2",
|
||||
"@storybook/addon-docs": "10.6.0",
|
||||
"@storybook/nextjs-vite": "10.6.0",
|
||||
"@storybook/react": "10.6.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -97,11 +89,11 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"@vitest/ui": "^4.1.12",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/ui": "^4.1.11",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "16.4.1",
|
||||
"eslint-config-next": "16.3.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsdoc": "^62.7.1",
|
||||
"jest-axe": "^10.0.0",
|
||||
@@ -109,11 +101,10 @@
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.5.28",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "10.6.2",
|
||||
"storybook": "10.6.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "7.3.7",
|
||||
"vitest": "^4.1.12"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
@@ -122,19 +113,10 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/language": "6.11.2",
|
||||
"@codemirror/lint": "6.8.5",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"@humanfs/node": ">=0.16.8",
|
||||
"@babel/core": ">=7.29.6 <8",
|
||||
"source-map-js": ">=1.2.2",
|
||||
"follow-redirects": ">=1.16.2",
|
||||
"undici@>=7 <8": ">=7.30.1 <8",
|
||||
"fast-uri@>=3 <4": ">=3.1.9 <4"
|
||||
"source-map-js": ">=1.2.2"
|
||||
},
|
||||
"ignoredOptionalDependencies": [
|
||||
"electron"
|
||||
|
||||
@@ -34,7 +34,7 @@ export default defineConfig({
|
||||
webServer: {
|
||||
command: `pnpm dev --port ${port}`,
|
||||
url: baseURL,
|
||||
env: { NEXT_PUBLIC_MOCK_API: 'true', QQQ_NEXT_DIST_DIR: '.next-collaboration' },
|
||||
env: { NEXT_PUBLIC_MOCK_API: 'true' },
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
||||
Generated
+262
-407
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.38</revision>
|
||||
<revision>1.0.0-RC.36</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
@@ -89,10 +89,8 @@
|
||||
<files>
|
||||
<file>${project.basedir}/out/index.html</file>
|
||||
<file>${project.basedir}/out/app/_/_/index.html</file>
|
||||
<file>${project.basedir}/out/dynamic-qfmd-components/q-sequential-workflow.bundle.js</file>
|
||||
<file>${project.basedir}/out/dynamic-qfmd-components/q-sequential-workflow.bundle.js.map</file>
|
||||
</files>
|
||||
<message>Run pnpm build:export (or pnpm build:jar) before packaging; the export must contain the QSequentialWorkflow editor bundle staged from dynamic-components/.</message>
|
||||
<message>Run "QQQ_NEXT_OUTPUT=export pnpm build" (or pnpm build:jar) before packaging.</message>
|
||||
</requireFilesExist>
|
||||
</rules>
|
||||
</configuration>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
// Pass build settings through the child environment on Windows and POSIX alike.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const result = spawnSync(process.execPath, [require.resolve('next/dist/bin/next'), 'build'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, QQQ_NEXT_OUTPUT: 'export', NEXT_PUBLIC_MOCK_API: 'false' },
|
||||
})
|
||||
if (result.error) console.error(result.error.message)
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Copy the pinned trusted PDF worker; never copy uploaded files. */
|
||||
import { createRequire } from 'node:module'
|
||||
import { readFile, mkdir, copyFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
const require = createRequire(import.meta.url)
|
||||
const entry = require.resolve('pdfjs-dist/package.json')
|
||||
const root = path.dirname(entry)
|
||||
const pkg = JSON.parse(await readFile(entry, 'utf8'))
|
||||
if (pkg.version !== '6.4.299') throw new Error('Pinned PDF.js 6.4.299 is required')
|
||||
const output = new URL('../public/pdf-generated/', import.meta.url)
|
||||
await mkdir(output, { recursive: true })
|
||||
await copyFile(path.join(root, 'build/pdf.worker.min.mjs'), new URL('pdf-6.4.299.worker.js', output))
|
||||
await copyFile(path.join(root, 'LICENSE'), new URL('LICENSE', output))
|
||||
console.log('Prepared pinned PDF.js 6.4.299 worker and Apache-2.0 license')
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/** @file Build the offline editor worker from pinned trusted dependencies; never reads a script project. */
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const ts = require('typescript-editor')
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
if (ts.version !== '6.0.3') throw new Error('Pinned editor TypeScript 6.0.3 is required')
|
||||
const libraryDirectory = path.dirname(require.resolve('typescript-editor'))
|
||||
const libraries = Object.create(null)
|
||||
|
||||
/** Include only the standard ES2022 library closure, never DOM, Node or project packages. */
|
||||
async function includeLibrary(name) {
|
||||
if (!/^lib\.[a-z0-9.]+\.d\.ts$/.test(name)) throw new Error('Invalid shipped library name')
|
||||
if (libraries['/lib/' + name] !== undefined) return
|
||||
const source = await readFile(path.join(libraryDirectory, name), 'utf8')
|
||||
libraries['/lib/' + name] = source
|
||||
for (const reference of source.matchAll(/<reference lib="([a-z0-9.]+)"\s*\/>/g)) await includeLibrary('lib.' + reference[1] + '.d.ts')
|
||||
}
|
||||
await includeLibrary('lib.es2022.d.ts')
|
||||
const compiler = (await readFile(require.resolve('typescript-editor'), 'utf8')).replace(/\/\/# sourceMappingURL=.*$/gm, '')
|
||||
const source = await readFile(path.join(root, 'src/lib/scripting/typescript-session.ts'), 'utf8')
|
||||
const engine = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||
if (/require\s*\(/.test(engine)) throw new Error('Editor engine must have no runtime module imports')
|
||||
const worker = compiler + '\n;(() => {\nconst exports = {};\n' + engine + '\n' +
|
||||
'const session = new exports.TypeScriptSession(ts, ' + JSON.stringify(libraries) + ');\n' +
|
||||
`self.onmessage = (event) => {
|
||||
const id = event.data?.id;
|
||||
try {
|
||||
const result = session.run(event.data);
|
||||
if (new TextEncoder().encode(JSON.stringify(result)).length > 262144) throw new Error("Result limit");
|
||||
self.postMessage({ protocol: "qqq-typescript-ide/1", id, version: "6.0.3", result });
|
||||
} catch {
|
||||
self.postMessage({ protocol: "qqq-typescript-ide/1", id, version: "6.0.3", error: "Editor analysis could not be completed" });
|
||||
}
|
||||
};\n})();\n`
|
||||
const destination = path.join(root, 'public/scripting-generated')
|
||||
await mkdir(destination, { recursive: true })
|
||||
await writeFile(path.join(destination, 'typescript-6.0.3.worker.js'), worker)
|
||||
await writeFile(path.join(destination, 'LICENSE.txt'), await readFile(path.join(libraryDirectory, '../LICENSE.txt'), 'utf8'))
|
||||
await writeFile(path.join(destination, 'ThirdPartyNoticeText.txt'), await readFile(path.join(libraryDirectory, '../ThirdPartyNoticeText.txt'), 'utf8'))
|
||||
console.log('Generated offline TypeScript 6.0.3 worker (' + Buffer.byteLength(worker) + ' bytes, ' + Object.keys(libraries).length + ' standard libraries)')
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/** @file Real Chromium smoke test for the generated offline worker, served only on loopback. */
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import http from 'node:http'
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const workerBytes = await readFile(new URL('../public/scripting-generated/typescript-6.0.3.worker.js', import.meta.url))
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/worker.js') {
|
||||
response.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-store' }); response.end(workerBytes)
|
||||
} else if (request.url === '/') {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html', 'Content-Security-Policy': "default-src 'none'; script-src 'self'; worker-src 'self'; connect-src 'none'" })
|
||||
response.end('<!doctype html><title>Offline TypeScript fixture</title>')
|
||||
} else { response.writeHead(404); response.end() }
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
let browser
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage()
|
||||
const requested = []
|
||||
page.on('request', (request) => requested.push(new URL(request.url()).pathname))
|
||||
await page.goto('http://127.0.0.1:' + server.address().port)
|
||||
const result = await page.evaluate(async () => {
|
||||
const worker = new Worker('/worker.js')
|
||||
let id = 0
|
||||
const request = (operation, files, position = 0) => new Promise((resolve, reject) => {
|
||||
const requestId = ++id
|
||||
const timer = setTimeout(() => { worker.terminate(); reject(new Error('Worker deadline exceeded')) }, 10_000)
|
||||
worker.onmessage = (event) => { clearTimeout(timer); if (event.data.id !== requestId || event.data.error) reject(new Error('Worker protocol failed')); else resolve(event.data.result) }
|
||||
worker.onerror = () => { clearTimeout(timer); reject(new Error('Worker failed')) }
|
||||
worker.postMessage({ id: requestId, operation, files, file: 'src/main.ts', position, declarations: '' })
|
||||
})
|
||||
try {
|
||||
const diagnostics = await request('diagnostics', [{ path: 'src/main.ts', content: 'export const count: number = "wrong";' }])
|
||||
const source = 'import { order } from "./order.js";\norder.status;\norder.'
|
||||
const files = [{ path: 'src/main.ts', content: source }, { path: 'src/order.ts', content: 'export const order = { status: "READY" };' }]
|
||||
const completion = await request('completion', files, source.length)
|
||||
const hover = await request('hover', files, source.indexOf('status') + 1)
|
||||
const definition = await request('definition', files, source.indexOf('order.status') + 1)
|
||||
const formatting = await request('format', [{ path: 'src/main.ts', content: 'export const value={a:1,b:2};' }])
|
||||
await request('diagnostics', [{ path: 'src/main.ts', content: 'fetch("https://example.invalid/leak"); globalThis.__qqqNeverExecute = true;' }])
|
||||
return { diagnostics, completion, hover, definition, formatting, executed: globalThis.__qqqNeverExecute === true }
|
||||
} finally { worker.terminate() }
|
||||
})
|
||||
assert(result.diagnostics.diagnostics.some((item) => item.code === 2322))
|
||||
assert(result.completion.completions.some((item) => item.label === 'status'))
|
||||
assert.match(result.hover.hover.text, /string/)
|
||||
assert.equal(result.definition.definitions[0].file, 'src/order.ts')
|
||||
assert(result.formatting.edits.length > 0)
|
||||
assert.equal(result.executed, false)
|
||||
assert.deepEqual(requested.sort(), ['/', '/worker.js'])
|
||||
console.log('PASS real Chromium: strict diagnostics, multi-file completion/hover/definition, formatting, no source execution or external requests')
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"component": {
|
||||
"repository": "QRun-IO/qqq-frontend-component-sequential-workflow",
|
||||
"sourceCommit": "e6036d2a474a90d80c72bc4c5f242e666b3415f6",
|
||||
"sourceTree": "9efc09210a81dc4121aa53e156443b7f4fcb6dd7",
|
||||
"mainCommit": "ede4eba875ca17c5921ad4caf61a08bdff6f794d",
|
||||
"build": {
|
||||
"node": "v22.20.0",
|
||||
"npm": "10.9.3",
|
||||
"image": "node@sha256:b21fe589dfbe5cc39365d0544b9be3f1f33f55f3c86c87a76ff65a02f8f5848e",
|
||||
"commands": [
|
||||
"npm ci --ignore-scripts",
|
||||
"npm test (existing 3 suites, 62 tests)",
|
||||
"npx webpack"
|
||||
],
|
||||
"packageLockSha256": "737cc1a3e416d942c050076dfae36345e9ba3f62d679dec71f04c2a2382b1a21",
|
||||
"qualifiedUtc": "2026-11-05T12:31Z"
|
||||
},
|
||||
"mainTree": "9efc09210a81dc4121aa53e156443b7f4fcb6dd7",
|
||||
"mainMerge": "normal merge of PR 5 (parents a32a7b4d437f09c09b03eb8828fccfd7de4eae7a, e6036d2a474a90d80c72bc4c5f242e666b3415f6); its tree equals the qualified source tree, so the qualified assets are the canonical main build output"
|
||||
},
|
||||
"assetDirectory": "dynamic-components/q-sequential-workflow",
|
||||
"files": {
|
||||
"q-sequential-workflow.bundle.js": "9f8358b105d0c157cb32a476313a7750281549f8e9423dd7d125ceaaafe8f32d",
|
||||
"q-sequential-workflow.bundle.js.map": "776c716690a357213e00d109652ab7eb9c9025073b362c995564e11ca5c5ee92"
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Stage the versioned, precompiled QSequentialWorkflow editor bundle (the qbit-workflows `QSequentialWorkflow` editor)
|
||||
* under `public/dynamic-qfmd-components/`, which is where qbit-workflows' widget metadata points
|
||||
* (`/dynamic-qfmd-components/q-sequential-workflow.bundle.js`) and where the single-origin host serves the UI from.
|
||||
* The bundle and its map are tracked library assets in `dynamic-components/q-sequential-workflow/`, compiled from the
|
||||
* canonical component repository (source commit and toolchain recorded in `dynamic-components.lock.json`). Nothing is
|
||||
* downloaded or built here, and a file is refused unless its sha256 matches the lock. A missing or altered bundle fails the
|
||||
* build, so a release can never silently omit the workflow editor.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFile, mkdir, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
|
||||
/** Copies every locked file from `assetDir` to `outDir`; throws on a missing file or a hash mismatch. */
|
||||
export async function stageDynamicComponents({ assetDir, lock, outDir }) {
|
||||
await mkdir(outDir, { recursive: true })
|
||||
const staged = []
|
||||
for (const [name, expected] of Object.entries(lock.files)) {
|
||||
if (path.basename(name) !== name) throw new Error(`Locked file name must be a plain file name: ${name}`)
|
||||
if (!/^[0-9a-f]{64}$/.test(expected ?? '')) throw new Error(`${name} has no sha256 in the dynamic component lock`)
|
||||
const source = path.join(assetDir, name)
|
||||
const actual = sha256(await readFile(source).catch(() => { throw new Error(`Missing ${name} in ${assetDir}`) }))
|
||||
if (actual !== expected) throw new Error(`${name} sha256 ${actual} does not match the lock ${expected}`)
|
||||
await copyFile(source, path.join(outDir, name))
|
||||
staged.push(name)
|
||||
}
|
||||
return staged
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const lock = JSON.parse(await readFile(path.join(here, 'dynamic-components.lock.json'), 'utf8'))
|
||||
const staged = await stageDynamicComponents({ assetDir: path.join(here, '..', lock.assetDirectory), lock, outDir: path.join(here, '../public/dynamic-qfmd-components') })
|
||||
console.log(`Staged dynamic components compiled from ${lock.component.repository}@${lock.component.sourceCommit.slice(0, 8)}: ${staged.join(', ')}`)
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import { QContextProvider, useQContext } from '@/lib/context/q-context'
|
||||
@@ -113,9 +113,6 @@ function renderPage() {
|
||||
}
|
||||
|
||||
describe('RecordDeveloperViewPage', () => {
|
||||
// The page lazy-loads this view; transforming its module graph cold must not count against the first test's waits
|
||||
beforeAll(async () => { await import('@/components/developer/RecordDeveloperView') }, 120_000)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getRecord).mockResolvedValue(record)
|
||||
vi.mocked(getRecordDeveloperData).mockResolvedValue({
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
@@ -54,8 +54,6 @@ function renderPage(page: React.ReactNode) {
|
||||
}
|
||||
|
||||
describe('Create and base edit use full metadata and actual write contracts', () => {
|
||||
// The pages lazy-load the form; transforming its module graph cold must not count against the first test's waits
|
||||
beforeAll(async () => { await import('@/components/forms/EntityForm') }, 120_000)
|
||||
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
|
||||
|
||||
it('edits the base without requesting denied children or replacing associations', async () => {
|
||||
|
||||
@@ -43,7 +43,6 @@ import { loadMetaData } from '@/lib/api/metadata'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { CreateIsolatedScript } from '@/components/scripts/CreateIsolatedScript'
|
||||
|
||||
/** Duration in milliseconds before the "copied!" feedback resets to the copy icon. */
|
||||
const COPY_RESET_MS = 2000
|
||||
@@ -253,8 +252,6 @@ export default function DeveloperPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CreateIsolatedScript />
|
||||
|
||||
{/* Session Info */}
|
||||
<SectionCard
|
||||
title="Session Info"
|
||||
|
||||
@@ -180,8 +180,10 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Sync user ID to QContext
|
||||
useEffect(() => {
|
||||
setUserId(user?.id ?? user?.email)
|
||||
}, [user?.id, user?.email, setUserId])
|
||||
if (user?.email) {
|
||||
setUserId(user.email)
|
||||
}
|
||||
}, [user?.email, setUserId])
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
|
||||
@@ -35,7 +35,7 @@ import { getAlertsInboxSummary } from '@/lib/api/alerts'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import type { AlertsInboxSummary } from '@/types/alerts'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
|
||||
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
|
||||
|
||||
const AlertsBellPopover = lazy(() => import('./AlertsBellPopover'))
|
||||
|
||||
@@ -132,18 +132,16 @@ export function AlertsBell(_props: { metaData: QInstance | undefined }) {
|
||||
aria-expanded={open}
|
||||
data-qqq-id="button-notifications"
|
||||
>
|
||||
<HeaderIconAnchor>
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
className={`${HEADER_BADGE_CLASS} ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
|
||||
aria-hidden="true"
|
||||
data-qqq-id="alerts-unread-badge"
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</HeaderIconAnchor>
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
className={`absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
|
||||
aria-hidden="true"
|
||||
data-qqq-id="alerts-unread-badge"
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="sr-only" aria-live="polite" data-qqq-id="alerts-unread-announcer">{label}</span>
|
||||
{open && (
|
||||
|
||||
@@ -198,12 +198,7 @@ describe('AlertsBell', () => {
|
||||
expect(bell).toHaveAttribute('aria-expanded', 'false')
|
||||
// severity is in the name, not only in the badge color
|
||||
expect(bell).toHaveAccessibleName('Notifications, 2 unread, critical')
|
||||
const badge = screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })
|
||||
expect(badge).toHaveClass('bg-destructive')
|
||||
// the badge is positioned against the icon (the header's shared anchor), inside the button
|
||||
expect(badge.parentElement).toHaveAttribute('data-qqq-id', 'header-icon-anchor')
|
||||
expect(badge.parentElement).toHaveClass('relative')
|
||||
expect(badge.parentElement?.parentElement).toBe(bell)
|
||||
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
|
||||
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
|
||||
first.unmount()
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// The header's Ask button: its busy dot and unread badge stay on the sparkles icon in every state.
|
||||
// jsdom has no layout, so these tests assert what decides the layout: which element is each
|
||||
// indicator's containing block, what gives that element its size, and the indicator's offsets.
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HEADER_ICON_ANCHOR_CLASS, HEADER_STATUS_DOT_CLASS } from '@/components/layout/HeaderMenu'
|
||||
import { assistantKeys, type AssistantController } from './assistant-context'
|
||||
import { AssistantHeaderButton } from './AssistantHeaderButton'
|
||||
import { AssistantProvider } from './AssistantProvider'
|
||||
|
||||
const ANCHOR = '[data-qqq-id="header-icon-anchor"]'
|
||||
const BUSY = '[data-qqq-id="assistant-busy"]'
|
||||
const UNREAD = '[data-qqq-id="assistant-unread-badge"]'
|
||||
|
||||
/**
|
||||
* The class tokens of an element (an SVG element's `className` is not a string).
|
||||
*
|
||||
* @param element - The element.
|
||||
* @returns Its classes.
|
||||
*/
|
||||
function classes(element: Element): string[] {
|
||||
return (element.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* The containing block of an absolutely positioned element: its nearest positioned ancestor.
|
||||
*
|
||||
* @param element - The absolutely positioned element.
|
||||
* @returns The ancestor its offsets are measured from, or null (the viewport).
|
||||
*/
|
||||
function containingBlock(element: Element): Element | null {
|
||||
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
||||
if (classes(parent).some((name) => ['relative', 'absolute', 'fixed', 'sticky'].includes(name))) return parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The children that give an element its size: every child that is not absolutely positioned.
|
||||
*
|
||||
* @param element - The element.
|
||||
* @returns Its in-flow children.
|
||||
*/
|
||||
function inFlowChildren(element: Element): Element[] {
|
||||
return Array.from(element.children).filter((child) => !classes(child).includes('absolute'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the button in a given state.
|
||||
*
|
||||
* @param state - The state.
|
||||
* @param state.busy - Whether a reply is being written.
|
||||
* @param state.unreadCount - Unread replies.
|
||||
* @param state.open - Whether the panel is open.
|
||||
* @param state.enabled - Whether the assistant is available.
|
||||
* @returns The button, or null when it renders nothing.
|
||||
*/
|
||||
function renderButton({ busy = false, unreadCount = 0, open = false, enabled = true }: { busy?: boolean; unreadCount?: number; open?: boolean; enabled?: boolean }): HTMLElement | null {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
client.setQueryData(assistantKeys.busy(), busy)
|
||||
const controller: AssistantController = {
|
||||
config: null,
|
||||
enabled,
|
||||
open,
|
||||
setOpen: vi.fn(),
|
||||
toggle: vi.fn(),
|
||||
activeConversationId: null,
|
||||
setActiveConversationId: vi.fn(),
|
||||
unreadCount,
|
||||
openWith: vi.fn(),
|
||||
pendingRequest: null,
|
||||
consumeRequest: vi.fn(),
|
||||
triggerRef: React.createRef<HTMLButtonElement>(),
|
||||
}
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AssistantProvider value={controller}>
|
||||
<header><AssistantHeaderButton /></header>
|
||||
</AssistantProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
return screen.queryByRole('button', { name: /^Ask the assistant/ })
|
||||
}
|
||||
|
||||
describe('AssistantHeaderButton', () => {
|
||||
it('renders nothing when the assistant is unavailable', () => {
|
||||
expect(renderButton({ enabled: false, busy: true, unreadCount: 2 })).toBeNull()
|
||||
})
|
||||
|
||||
it('idle: the icon anchor holds only the icon, and the label sits beside it', () => {
|
||||
const button = renderButton({})!
|
||||
const anchor = button.querySelector(ANCHOR)!
|
||||
expect(anchor.parentElement).toBe(button)
|
||||
expect(Array.from(anchor.children).map((child) => child.tagName.toLowerCase())).toEqual(['svg'])
|
||||
expect(button.querySelector(BUSY)).toBeNull()
|
||||
expect(button.querySelector(UNREAD)).toBeNull()
|
||||
const label = screen.getByText('Ask')
|
||||
expect(label.parentElement).toBe(button)
|
||||
expect(anchor.contains(label)).toBe(false)
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
const states = [
|
||||
{ name: 'a reply being written', busy: true, unreadCount: 0 },
|
||||
{ name: 'unread replies', busy: false, unreadCount: 3 },
|
||||
{ name: 'a reply being written and unread replies', busy: true, unreadCount: 12 },
|
||||
]
|
||||
const cases = states.flatMap((state) => [{ ...state, open: false, panel: 'closed' }, { ...state, open: true, panel: 'open' }])
|
||||
|
||||
it.each(cases)('$name, panel $panel: every indicator is positioned against the icon, never the button', ({ busy, unreadCount, open }) => {
|
||||
const button = renderButton({ busy, unreadCount, open })!
|
||||
expect(button).toHaveAttribute('aria-pressed', String(open))
|
||||
const anchor = button.querySelector(ANCHOR)!
|
||||
const indicators = Array.from(button.querySelectorAll(`${BUSY}, ${UNREAD}`))
|
||||
expect(indicators).toHaveLength((busy ? 1 : 0) + (unreadCount > 0 ? 1 : 0))
|
||||
for (const indicator of indicators) {
|
||||
expect(classes(indicator)).toContain('absolute')
|
||||
// the button is positioned too (its focus ring and the Jobs ring need that), so only an
|
||||
// indicator inside the anchor is measured from the icon
|
||||
expect(containingBlock(indicator)).toBe(anchor)
|
||||
expect(containingBlock(indicator)).not.toBe(button)
|
||||
}
|
||||
// the anchor is exactly the icon: nothing else in flow, so the label and the button's
|
||||
// padding cannot move an indicator
|
||||
expect(classes(anchor)).toContain('relative')
|
||||
expect(inFlowChildren(anchor).map((child) => child.tagName.toLowerCase())).toEqual(['svg'])
|
||||
expect(anchor.contains(screen.getByText('Ask'))).toBe(false)
|
||||
})
|
||||
|
||||
it('places the dot on the icon\'s bottom-right corner and the badge over its top-right corner', () => {
|
||||
const button = renderButton({ busy: true, unreadCount: 3 })!
|
||||
const dot = screen.getByRole('status', { name: 'Assistant is answering' })
|
||||
expect(dot).toBe(button.querySelector(BUSY))
|
||||
expect(classes(dot)).toEqual(expect.arrayContaining(['-bottom-0.5', '-right-0.5', 'h-2', 'w-2', 'rounded-full']))
|
||||
const badge = button.querySelector(UNREAD)!
|
||||
expect(badge).toHaveTextContent('3')
|
||||
expect(classes(badge)).toEqual(expect.arrayContaining(['-right-2', '-top-2', 'h-4', 'min-w-4']))
|
||||
// the offsets of the button's own corner (the defect: past the label, under its baseline)
|
||||
for (const indicator of [dot, badge]) {
|
||||
for (const offset of ['bottom-0', 'right-0', 'right-0.5', 'top-0.5']) expect(classes(indicator)).not.toContain(offset)
|
||||
}
|
||||
})
|
||||
|
||||
it('caps the badge at 9+ and names the unread count', () => {
|
||||
const button = renderButton({ unreadCount: 12 })!
|
||||
expect(button.querySelector(UNREAD)).toHaveTextContent('9+')
|
||||
expect(button).toHaveAccessibleName('Ask the assistant, 12 unread (Ctrl+J or ⌘J)')
|
||||
})
|
||||
|
||||
it('shares one anchor and one badge geometry with the bell and Jobs', () => {
|
||||
expect(HEADER_ICON_ANCHOR_CLASS.split(' ')).toEqual(expect.arrayContaining(['relative', 'shrink-0']))
|
||||
expect(HEADER_BADGE_CLASS.split(' ')).toEqual(expect.arrayContaining(['absolute', '-right-2', '-top-2']))
|
||||
expect(HEADER_STATUS_DOT_CLASS.split(' ')).toEqual(expect.arrayContaining(['absolute', '-bottom-0.5', '-right-0.5']))
|
||||
// an 8 px offset from a 20 px icon stays inside the 10 px padding of the 40 px button
|
||||
expect(HEADER_BUTTON_CLASS.split(' ')).toEqual(expect.arrayContaining(['h-10', 'min-w-10', 'px-2.5']))
|
||||
})
|
||||
})
|
||||
@@ -25,14 +25,13 @@ import React from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HEADER_STATUS_DOT_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
|
||||
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { assistantKeys, useAssistant } from './assistant-context'
|
||||
|
||||
/**
|
||||
* Toggles the assistant panel ("Ask"); the badge counts unread replies and the dot shows a reply
|
||||
* being written. The sparkles icon alone is ambiguous, so from 1440 px it carries the text label
|
||||
* "Ask" (its tooltip says so below that).
|
||||
* Toggles the assistant panel ("Ask"); the badge counts unread replies. The sparkles icon alone is
|
||||
* ambiguous, so from 1440 px it carries the text label "Ask" (its tooltip says so below that).
|
||||
*
|
||||
* @returns The button, or null when the assistant is unavailable.
|
||||
*/
|
||||
@@ -51,23 +50,24 @@ export function AssistantHeaderButton() {
|
||||
event.currentTarget.focus({ preventScroll: true })
|
||||
toggle()
|
||||
}}
|
||||
className={cn(HEADER_BUTTON_CLASS, 'gap-2')}
|
||||
className={cn(HEADER_BUTTON_CLASS, 'gap-1.5')}
|
||||
aria-label={label}
|
||||
title="Ask the assistant (Ctrl+J or ⌘J)"
|
||||
aria-pressed={open}
|
||||
data-qqq-id="button-assistant"
|
||||
>
|
||||
{/* the indicators sit on the icon: the button's corner is past the label from 1440 px */}
|
||||
<HeaderIconAnchor>
|
||||
<Sparkles className="h-5 w-5" aria-hidden="true" />
|
||||
{busy && <span className={cn(HEADER_STATUS_DOT_CLASS, 'bg-primary motion-safe:animate-pulse')} role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
|
||||
{unreadCount > 0 && (
|
||||
<span className={cn(HEADER_BADGE_CLASS, 'bg-primary text-primary-foreground')} aria-hidden="true" data-qqq-id="assistant-unread-badge">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</HeaderIconAnchor>
|
||||
<Sparkles className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="hidden text-sm font-medium min-[1440px]:inline" aria-hidden="true">Ask</span>
|
||||
{busy && <span className="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-primary motion-safe:animate-pulse" role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
data-qqq-id="assistant-unread-badge"
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Access editor behavior: stable selections, grant sources and API failures. */
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { QWidgetMetaData } from '@/types'
|
||||
import { fetchWidgetData } from '@/lib/api/widgets'
|
||||
import { fetchPossibleValues } from '@/lib/api/possible-values'
|
||||
import AuthentikAccessEditorWidget from './AuthentikAccessEditorWidget'
|
||||
|
||||
vi.mock('@/lib/api/widgets', () => ({ fetchWidgetData: vi.fn() }))
|
||||
vi.mock('@/lib/api/possible-values', () => ({ fetchPossibleValues: vi.fn() }))
|
||||
const widget = { name: 'access', type: 'AUTHENTIK_ACCESS_EDITOR' } as QWidgetMetaData
|
||||
|
||||
/**
|
||||
* Render with an isolated query cache.
|
||||
* @param fields - Editor fields.
|
||||
* @returns Process value callback.
|
||||
*/
|
||||
function setup(fields: Record<string, unknown>[]) {
|
||||
const changed = vi.fn()
|
||||
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<AuthentikAccessEditorWidget widgetMetaData={widget} data={{ fields }} onWidgetData={changed} />
|
||||
</QueryClientProvider>)
|
||||
return changed
|
||||
}
|
||||
|
||||
describe('Access editor', () => {
|
||||
it('preserves unknown grants and displays inherited access without making it editable', async () => {
|
||||
const changed = setup([{ name: 'permissions', label: 'Direct permissions', selected: [{ id: 'legacy.read', label: 'legacy.read' }],
|
||||
choices: [{ id: 'posts.edit', label: 'Edit posts' }], inherited: { 'posts.read': ['Readers'] } }])
|
||||
expect(screen.getByLabelText('legacy.read')).toBeChecked()
|
||||
await userEvent.click(screen.getByLabelText('Edit posts'))
|
||||
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\nposts.edit' })
|
||||
expect(screen.queryByRole('checkbox', { name: /posts.read/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('posts.read — Readers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('searches the server and preserves selected IDs when results change', async () => {
|
||||
vi.mocked(fetchPossibleValues).mockImplementation(async (_source, request) => request?.searchTerm === 'alex'
|
||||
? [{ id: 42, label: 'Alex' }] : [{ id: 3, label: 'Sam' }])
|
||||
const changed = setup([{ name: 'userIds', label: 'Users', source: 'identityUsers', selected: [] }])
|
||||
await userEvent.click(await screen.findByLabelText('Sam'))
|
||||
await userEvent.type(screen.getByLabelText('Search users'), 'alex')
|
||||
await userEvent.click(await screen.findByLabelText('Alex'))
|
||||
expect(screen.getByLabelText('Sam')).toBeChecked()
|
||||
expect(changed).toHaveBeenLastCalledWith({ userIds: '3\n42' })
|
||||
expect(fetchPossibleValues).toHaveBeenLastCalledWith('identityUsers', { searchTerm: 'alex', useCase: 'form' })
|
||||
})
|
||||
|
||||
it('keeps existing assignments when the directory search fails', async () => {
|
||||
vi.mocked(fetchPossibleValues).mockRejectedValue(new Error('Offline'))
|
||||
const changed = setup([{ name: 'parentIds', label: 'Roles', source: 'roles', selected: [{ id: 'role-1', label: 'Reader' }] }])
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not load choices'))
|
||||
expect(screen.getByLabelText('Reader')).toBeChecked()
|
||||
expect(changed).toHaveBeenCalledWith({ parentIds: 'role-1' })
|
||||
})
|
||||
|
||||
it('rejects malformed payloads without submitting an empty permission list', () => {
|
||||
const changed = setup([{ name: 'permissions', label: 'Permissions', selected: 'broken' }])
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('unavailable')
|
||||
expect(changed).toHaveBeenCalledWith({ __accessEditorReady: false })
|
||||
expect(changed).not.toHaveBeenCalledWith({ permissions: '' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Paged permission matrix', () => {
|
||||
it('accepts the live QQQ payload that omits empty application names and IDs', async () => {
|
||||
vi.mocked(fetchWidgetData).mockResolvedValue({ type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{
|
||||
total: 1, page: 0, pageSize: 25, apps: [{ label: 'Other' }],
|
||||
rows: [{ name: 'documents', label: 'Documents', kind: 'TABLE', permissions: [{ id: 'documents.read', label: 'read' }] }],
|
||||
}] })
|
||||
const changed = setup([{ name: 'permissions', label: 'Direct permissions', catalogWidget: 'grantEditor', selected: [] }])
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: 'documents.read' }))
|
||||
expect(changed).toHaveBeenLastCalledWith({ permissions: 'documents.read' })
|
||||
await userEvent.selectOptions(screen.getByLabelText('Application'), '')
|
||||
await waitFor(() => expect(fetchWidgetData).toHaveBeenLastCalledWith('grantEditor', expect.objectContaining({ app: '' })))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps unknown and off-page grants across bulk page edits and failed searches', async () => {
|
||||
vi.mocked(fetchWidgetData).mockImplementation(async (_widget, params) => {
|
||||
if (params?.search === 'offline') throw new Error('Offline')
|
||||
const page = Number(params?.page ?? 0)
|
||||
return { type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{ total: 1000, page, pageSize: 25, apps: [{ id: 'sales', label: 'Sales' }],
|
||||
rows: Array.from({ length: 25 }, (_, i) => ({ name: 'table' + (page * 25 + i), label: 'Table ' + (page * 25 + i), kind: 'TABLE', app: 'sales', permissions: [{ id: 'table' + (page * 25 + i) + '.read', label: 'read' }] })) }] }
|
||||
})
|
||||
const changed = setup([{ name: 'permissions', label: 'Direct permissions', catalogWidget: 'grantEditor', selected: [{ id: 'legacy.read', label: 'legacy.read' }], inherited: { 'table999.read': ['Readers'] } }])
|
||||
await screen.findByLabelText('table0.read')
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(25)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Select 25 permissions on this page' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Next' }))
|
||||
await screen.findByLabelText('table25.read')
|
||||
await userEvent.click(screen.getByLabelText('table25.read'))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Previous' }))
|
||||
await screen.findByLabelText('table0.read')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear 25 permissions on this page' }))
|
||||
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\ntable25.read' })
|
||||
await userEvent.type(screen.getByLabelText('Search permissions or resources'), 'offline')
|
||||
await screen.findByRole('alert')
|
||||
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\ntable25.read' })
|
||||
await userEvent.clear(screen.getByLabelText('Search permissions or resources'))
|
||||
await userEvent.selectOptions(screen.getByLabelText('Show'), 'inherited')
|
||||
expect(screen.getByText('table999.read — Readers')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Structured named grants and searchable membership assignments. */
|
||||
'use client'
|
||||
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import type { WidgetComponentProps } from '@/components/widgets/widget-types'
|
||||
import { fetchPossibleValues } from '@/lib/api/possible-values'
|
||||
import { PermissionMatrix } from './PermissionMatrix'
|
||||
import { INPUT_CLASS, ALERT_CLASS } from '@/components/ui/controls'
|
||||
|
||||
const optionSchema = z.object({ id: z.string(), label: z.string() })
|
||||
const fieldSchema = z.object({
|
||||
catalogWidget: z.string().optional(), name: z.string().min(1), label: z.string(), source: z.string().optional(),
|
||||
selected: z.array(optionSchema).default([]), choices: z.array(optionSchema).optional(),
|
||||
inherited: z.record(z.string(), z.array(z.string())).optional(), policyPermissions: z.array(z.string()).optional(),
|
||||
})
|
||||
const payloadSchema = z.object({ fields: z.array(fieldSchema) })
|
||||
type Field = z.infer<typeof fieldSchema>
|
||||
type Option = z.infer<typeof optionSchema>
|
||||
|
||||
/**
|
||||
* Edit one structured field while keeping selection independent of search results.
|
||||
* @param props - Server metadata and the process value callback.
|
||||
* @returns A checkbox list and, for assignments, a server-backed search box.
|
||||
*/
|
||||
function AccessField({ field, onChange }: { field: Field; onChange: (values: Record<string, unknown>) => void }) {
|
||||
const [selected, setSelected] = useState<Option[]>(field.selected)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const id = useId()
|
||||
useEffect(() => { const timer = setTimeout(() => setDebounced(search), 250); return () => clearTimeout(timer) }, [search])
|
||||
const options = useQuery({
|
||||
queryKey: ['access-editor-options', field.source, debounced],
|
||||
queryFn: () => fetchPossibleValues(field.source!, { searchTerm: debounced, useCase: 'form' }),
|
||||
enabled: Boolean(field.source),
|
||||
})
|
||||
useEffect(() => { onChange({ [field.name]: selected.map(option => option.id).join('\n') }) }, [selected, field.name, onChange])
|
||||
const choices: Option[] = field.source
|
||||
? (options.data ?? []).map(option => ({ id: String(option.id), label: option.label }))
|
||||
: (field.choices ?? []).filter(option => option.label.toLowerCase().includes(search.toLowerCase()))
|
||||
const selectedIds = new Set(selected.map(option => option.id))
|
||||
const displayed = [...selected, ...choices.filter(option => !selectedIds.has(option.id))].filter(option => Boolean(field.source) || option.label.toLowerCase().includes(search.toLowerCase()))
|
||||
const currentPage = Math.min(page, Math.max(0, Math.ceil(displayed.length / 25) - 1))
|
||||
return <fieldset className="space-y-3 rounded-lg border border-border p-4">
|
||||
<legend className="px-2 font-semibold">{field.label}</legend>
|
||||
<label htmlFor={id} className="block text-sm">Search {field.label.toLowerCase()}</label>
|
||||
<input id={id} data-qqq-id={'access-search-' + field.name} className={INPUT_CLASS} value={search} onChange={event => { setSearch(event.target.value); setPage(0) }} />
|
||||
{options.isFetching && field.source && <p role="status" className="text-sm">Searching…</p>}
|
||||
{options.isError && <p role="alert" className={ALERT_CLASS}>Could not load choices. Your selections are preserved. Change the search to try again.</p>}
|
||||
<p className="text-sm text-muted-foreground">{selected.length} selected. Review validates every change before saving.</p>
|
||||
<div className="max-h-80 space-y-2 overflow-auto">
|
||||
{displayed.slice(currentPage * 25, currentPage * 25 + 25).map(option => <label key={option.id} className="flex items-start gap-2 text-sm">
|
||||
<input type="checkbox" data-qqq-id={'access-choice-' + field.name + '-' + option.id} checked={selectedIds.has(option.id)}
|
||||
onChange={event => setSelected(current => event.target.checked ? [...current, option] : current.filter(item => item.id !== option.id))} />
|
||||
<span>{option.label}</span>
|
||||
</label>)}
|
||||
{!options.isFetching && displayed.length === 0 && <p className="text-sm text-muted-foreground">No choices found.</p>}
|
||||
</div>
|
||||
{displayed.length > 25 && <div className="flex gap-4">
|
||||
<button type="button" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous choices</button>
|
||||
<span>Page {currentPage + 1} of {Math.ceil(displayed.length / 25)}</span>
|
||||
<button type="button" disabled={(currentPage + 1) * 25 >= displayed.length} onClick={() => setPage(currentPage + 1)}>Next choices</button>
|
||||
</div>}
|
||||
{field.inherited && Object.keys(field.inherited).length > 0 && <details>
|
||||
<summary className="cursor-pointer text-sm font-medium">Inherited access — edit the source to remove it</summary>
|
||||
<ul className="mt-2 space-y-1 text-sm">{Object.entries(field.inherited).map(([permission, sources]) => <li key={permission}>{permission} — {sources.join(', ')}</li>)}</ul>
|
||||
</details>}
|
||||
{Boolean(field.policyPermissions?.length) && <details>
|
||||
<summary className="cursor-pointer text-sm font-medium">Access from this item’s policy — use Edit Policy to change it</summary>
|
||||
<ul className="mt-2 text-sm">{field.policyPermissions?.map(permission => <li key={permission}>{permission}</li>)}</ul>
|
||||
</details>}
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate server-provided metadata before rendering editable process values.
|
||||
* @param props - Standard widget payload and process callback.
|
||||
* @returns The access editor or a contained error.
|
||||
*/
|
||||
export default function AuthentikAccessEditorWidget({ data, onWidgetData }: WidgetComponentProps<Record<string, unknown>>) {
|
||||
const payload = payloadSchema.safeParse(data)
|
||||
const ready = payload.success
|
||||
useEffect(() => { onWidgetData?.({ __accessEditorReady: ready }) }, [ready, onWidgetData])
|
||||
if (!payload.success || !onWidgetData) return <p role="alert" className={ALERT_CLASS}>The access editor is unavailable. Reopen the action before continuing.</p>
|
||||
return <div className="space-y-4">{payload.data.fields.map(field => field.catalogWidget ? <PermissionMatrix key={field.name} field={field} onChange={onWidgetData} /> : <AccessField key={field.name} field={field} onChange={onWidgetData} />)}</div>
|
||||
}
|
||||
@@ -17,8 +17,6 @@
|
||||
// Tests for the AUTHENTIK_POLICY_EDITOR widget: matrix, select-all, search, unknowns, JSON output
|
||||
|
||||
import React from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fetchWidgetData } from '@/lib/api/widgets'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { axe } from 'jest-axe'
|
||||
@@ -29,8 +27,6 @@ import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
|
||||
import { AuthentikPolicyEditorWidget, isPolicyPermission } from './AuthentikPolicyEditorWidget'
|
||||
import type { PolicyEditorPayload } from './AuthentikPolicyEditorWidget'
|
||||
|
||||
vi.mock('@/lib/api/widgets', () => ({ fetchWidgetData: vi.fn() }))
|
||||
|
||||
const widget = { name: 'authentikPolicyEditor', type: 'AUTHENTIK_POLICY_EDITOR', label: 'Policy' } as unknown as QWidgetMetaData
|
||||
|
||||
const payload = (overrides: Partial<PolicyEditorPayload> = {}): PolicyEditorPayload => ({
|
||||
@@ -78,7 +74,7 @@ const documentFor = (actions: string[], resources = ['person']) => JSON.stringif
|
||||
*/
|
||||
function setup(data: PolicyEditorPayload) {
|
||||
const onWidgetData = vi.fn()
|
||||
const view = render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}><AuthentikPolicyEditorWidget widgetMetaData={widget} data={data as unknown as Record<string, unknown>} onWidgetData={onWidgetData} /></QueryClientProvider>)
|
||||
const view = render(<AuthentikPolicyEditorWidget widgetMetaData={widget} data={data as unknown as Record<string, unknown>} onWidgetData={onWidgetData} />)
|
||||
const last = () => String(onWidgetData.mock.calls.at(-1)?.[0]?.policyDocument ?? '')
|
||||
return { onWidgetData, last, ...view }
|
||||
}
|
||||
@@ -379,41 +375,3 @@ describe('AuthentikPolicyEditorWidget', () => {
|
||||
expect(isPolicyPermission('a.b.read')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Large policy catalogs', () => {
|
||||
it('bounds rendering and preserves off-page grants when selecting one page', async () => {
|
||||
const { last } = setup(payload({ catalog: [{ app: 'large', label: 'Large', resources: Array.from({ length: 1000 }, (_, i) => ({ name: 'resource' + i, label: 'Resource ' + i, kind: 'TABLE', actions: ['read'] })) }] }))
|
||||
expect(screen.getByLabelText('Read: Resource 0')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Read: Resource 25')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByLabelText('Everything in Large (this page)'))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Next resources' }))
|
||||
expect(screen.queryByLabelText('Read: Resource 0')).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByLabelText('Read: Resource 25'))
|
||||
const doc = JSON.parse(last())
|
||||
const resources = doc.Statement.flatMap((statement: { Resource: string[] }) => statement.Resource)
|
||||
expect(resources).toHaveLength(26)
|
||||
expect(resources).toContain('resource0')
|
||||
expect(resources).toContain('resource25')
|
||||
await userEvent.type(screen.getByLabelText('Find a resource'), 'Resource 999')
|
||||
expect(screen.getByLabelText('Read: Resource 999')).toBeInTheDocument()
|
||||
expect(JSON.parse(last())).toEqual(doc)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('Server-paged policies', () => {
|
||||
it('keeps controlled selection synchronized with unknown removal and clear all', async () => {
|
||||
vi.mocked(fetchWidgetData).mockResolvedValue({ type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{ total: 1, page: 0, pageSize: 25, apps: [], rows: [{ name: 'cms_page', label: 'Pages', kind: 'TABLE', app: '', permissions: [{ id: 'cms_page.read', label: 'read' }, { id: 'cms_page.edit', label: 'edit' }] }] }] })
|
||||
const { last } = setup(payload({ catalog: [], catalogWidget: 'pagedPolicy', unknownPermissions: ['legacy.read'], document: documentFor(['read'], ['legacy']) }))
|
||||
await userEvent.click(await screen.findByLabelText('cms_page.read'))
|
||||
expect(last()).toContain('legacy')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Remove legacy.read' }))
|
||||
await userEvent.click(screen.getByLabelText('cms_page.edit'))
|
||||
expect(last()).not.toContain('legacy')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear all' }))
|
||||
expect(screen.getByLabelText('cms_page.read')).not.toBeChecked()
|
||||
expect(screen.getByLabelText('cms_page.edit')).not.toBeChecked()
|
||||
expect(JSON.parse(last()).Statement).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,6 @@ import React, { lazy, Suspense, useEffect, useId, useMemo, useRef, useState } fr
|
||||
import type { WidgetComponentProps } from '@/components/widgets/widget-types'
|
||||
import { asList, isPlainObject } from '@/components/widgets/widget-types'
|
||||
import { ALERT_CLASS, INPUT_CLASS, WARNING_CLASS, buttonClass } from '@/components/ui/controls'
|
||||
import { PermissionMatrix } from './PermissionMatrix'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import type { PolicyAction, PolicyFormat } from './policy-document'
|
||||
import { ACTION_LABELS, FULL_ACCESS_PERMISSION, POLICY_ACTIONS, generatePolicy, parsePolicy, permissionName, roundTrips, splitPermission } from './policy-document'
|
||||
@@ -66,10 +65,6 @@ export interface PolicyEditorPayload {
|
||||
/** The group the policy belongs to (display only). */
|
||||
groupLabel?: string
|
||||
catalog: PolicyCatalogGroup[]
|
||||
/** Server-paged catalog source; older hosts send the full catalog. */
|
||||
catalogWidget?: string
|
||||
/** Unknown grants identified against the complete server catalog. */
|
||||
unknownPermissions?: string[]
|
||||
/** The group's current document (JSON text), or empty for none. */
|
||||
document?: string
|
||||
/** Whether the current document grants full access; older servers omit this. */
|
||||
@@ -177,12 +172,11 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set(initial.permissions))
|
||||
const fullAccess = selected.has(FULL_ACCESS_PERMISSION)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [memberUsername, setMemberUsername] = useState('')
|
||||
const member = payload.members?.find((candidate) => candidate.username === memberUsername)
|
||||
|
||||
const generated = useMemo(() => generatePolicy(selected, format), [format, selected])
|
||||
const unknown = useMemo(() => fullAccess ? [] : [...selected].filter((permission) => payload.catalogWidget ? payload.unknownPermissions?.includes(permission) : !known.has(permission)).sort(), [fullAccess, known, selected, payload.catalogWidget, payload.unknownPermissions])
|
||||
const unknown = useMemo(() => fullAccess ? [] : [...selected].filter((permission) => !known.has(permission)).sort(), [fullAccess, known, selected])
|
||||
const valid = useMemo(() => roundTrips(selected, format), [format, selected])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -200,16 +194,6 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
|
||||
}))
|
||||
.filter((group) => group.resources.length > 0), [catalog, term])
|
||||
|
||||
const matchingCount = visibleGroups.reduce((sum, group) => sum + group.resources.length, 0)
|
||||
const currentPage = Math.min(page, Math.max(0, Math.ceil(matchingCount / 25) - 1))
|
||||
const pageResources = visibleGroups.flatMap((group) => group.resources.map((resource) => ({ group, resource })))
|
||||
.slice(currentPage * 25, (currentPage + 1) * 25)
|
||||
const pageGroups = visibleGroups.map((group) => ({
|
||||
...group,
|
||||
resources: pageResources.filter((entry) => entry.group === group).map((entry) => entry.resource),
|
||||
label: matchingCount > 25 ? group.label + ' (this page)' : group.label,
|
||||
})).filter((group) => group.resources.length > 0)
|
||||
|
||||
const set = (permissions: string[], on: boolean) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current)
|
||||
@@ -251,7 +235,7 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
|
||||
|
||||
<fieldset disabled={fullAccess} aria-disabled={fullAccess} hidden={fullAccess} className="min-w-0">
|
||||
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-end @xl:justify-between">
|
||||
<div hidden={Boolean(payload.catalogWidget)} className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label htmlFor={searchId} className="text-sm font-medium text-foreground">Find a resource</label>
|
||||
<input
|
||||
id={searchId}
|
||||
@@ -260,7 +244,7 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
|
||||
placeholder="App, table, process or report"
|
||||
value={search}
|
||||
aria-disabled={fullAccess}
|
||||
onChange={(event) => { setSearch(event.target.value); setPage(0) }}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
data-qqq-id="policy-editor-search"
|
||||
/>
|
||||
</div>
|
||||
@@ -278,18 +262,11 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
|
||||
{fullAccess && <p className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">Individual resource selection is disabled while full access is on.</p>}
|
||||
<div hidden={fullAccess}>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{payload.catalogWidget ? <PermissionMatrix field={{ name: 'policyPermissions', label: 'Policy permissions', catalogWidget: payload.catalogWidget, selected: [] }} selection={selected} onSelectionChange={setSelected} /> : <>
|
||||
{resourceCount === 0 && <p className="text-sm text-muted-foreground">This app has no resources that a policy can grant.</p>}
|
||||
{resourceCount > 0 && visibleGroups.length === 0 && <p className="text-sm text-muted-foreground" role="status">No resource matches the search.</p>}
|
||||
{matchingCount > 25 && <div className="flex items-center gap-3 text-sm">
|
||||
<button type="button" className={buttonClass('secondary', 'sm')} disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous resources</button>
|
||||
<span role="status">{matchingCount} resources · Page {currentPage + 1} of {Math.ceil(matchingCount / 25)}. Bulk controls affect only this page.</span>
|
||||
<button type="button" className={buttonClass('secondary', 'sm')} disabled={(currentPage + 1) * 25 >= matchingCount} onClick={() => setPage(currentPage + 1)}>Next resources</button>
|
||||
</div>}
|
||||
{pageGroups.map((group) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<PolicyGroupSection key={group.app ?? '(other)'} group={group} selected={selected} onSet={set} />
|
||||
))}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Bounded permission catalog and grant source views. */
|
||||
'use client'
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { fetchWidgetData } from '@/lib/api/widgets'
|
||||
import { INPUT_CLASS, ALERT_CLASS } from '@/components/ui/controls'
|
||||
|
||||
const optionSchema = z.object({ id: z.string(), label: z.string() })
|
||||
// QQQ omits empty strings on the wire. Only application membership uses the
|
||||
// empty string as a valid identity (the Other/unassigned application bucket).
|
||||
const appOptionSchema = optionSchema.extend({ id: z.string().default('') })
|
||||
const pageSchema = z.object({ fields: z.array(z.object({
|
||||
rows: z.array(z.object({ name: z.string(), label: z.string(), kind: z.string(), app: z.string().default(''), permissions: z.array(optionSchema) })),
|
||||
total: z.number().int().nonnegative(), page: z.number().int().nonnegative(), pageSize: z.number().int().positive(), apps: z.array(appOptionSchema),
|
||||
})).min(1) })
|
||||
type Option = z.infer<typeof optionSchema>
|
||||
interface Field {
|
||||
name: string; label: string; catalogWidget?: string; selected: Option[]
|
||||
inherited?: Record<string, string[]>; policyPermissions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged resource matrix. Selection is independent of catalog pages and requests.
|
||||
* @param props - Initial grants, catalog source, and process output callback.
|
||||
* @returns A searchable matrix with page-scoped bulk actions.
|
||||
*/
|
||||
export function PermissionMatrix({ field, onChange, selection, onSelectionChange }: {
|
||||
field: Field; onChange?: (values: Record<string, unknown>) => void
|
||||
selection?: Set<string>; onSelectionChange?: (selection: Set<string>) => void
|
||||
}) {
|
||||
const [localSelected, setSelected] = useState(() => new Map(field.selected.map(option => [option.id, option])))
|
||||
const selected = useMemo(() => selection ? new Map([...selection].map(id => [id, { id, label: id }])) : localSelected, [selection, localSelected])
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [app, setApp] = useState('*')
|
||||
const [kind, setKind] = useState('*')
|
||||
const [page, setPage] = useState(0)
|
||||
const [view, setView] = useState('catalog')
|
||||
const id = useId()
|
||||
useEffect(() => { const timer = setTimeout(() => { setDebounced(search); setPage(0) }, 250); return () => clearTimeout(timer) }, [search])
|
||||
useEffect(() => { if (!selection) onChange?.({ [field.name]: [...selected.keys()].join('\n') }) }, [selected, field.name, onChange, selection])
|
||||
const catalog = useQuery({
|
||||
queryKey: ['access-catalog', field.catalogWidget, debounced, app, kind, page],
|
||||
queryFn: async () => pageSchema.parse(await fetchWidgetData(field.catalogWidget!, { accessCatalog: true, search: debounced, app, kind, page })).fields[0],
|
||||
enabled: view === 'catalog',
|
||||
})
|
||||
const data = catalog.data
|
||||
const busy = catalog.isFetching || search !== debounced
|
||||
const visible = data?.rows.flatMap(row => row.permissions) ?? []
|
||||
const actions = [...new Set(visible.map(option => option.label))]
|
||||
const change = (options: Option[], checked: boolean) => {
|
||||
const next = new Map(selected)
|
||||
options.forEach(option => { if (checked) next.set(option.id, option); else next.delete(option.id) })
|
||||
if (selection) onSelectionChange?.(new Set(next.keys()))
|
||||
else setSelected(next)
|
||||
}
|
||||
const localEntries = (view === 'selected' ? [...selected.values()].map(option => [option.id, 'Direct grant'] as const)
|
||||
: [...Object.entries(field.inherited ?? {}).map(([permission, sources]) => [permission, sources.join(', ')] as const),
|
||||
...(field.policyPermissions ?? []).map(permission => [permission, 'This item’s policy'] as const)])
|
||||
.filter(([permission, source]) => (permission + ' ' + source).toLowerCase().includes(search.toLowerCase()))
|
||||
const localPage = Math.min(page, Math.max(0, Math.ceil(localEntries.length / 25) - 1))
|
||||
const currentPage = view === 'catalog' ? (data?.page ?? 0) : localPage
|
||||
const total = view === 'catalog' ? (data?.total ?? 0) : localEntries.length
|
||||
return <fieldset className="space-y-3 rounded-lg border border-border p-4" data-qqq-id="access-permission-matrix">
|
||||
<legend className="px-2 font-semibold">{field.label}</legend>
|
||||
<p className="text-sm">{selected.size} direct permissions selected. Changes affect named permissions on existing resources. Future resources are not included.</p>
|
||||
<label htmlFor={id + '-view'}>Show</label>
|
||||
<select id={id + '-view'} className={INPUT_CLASS} value={view} onChange={event => { setView(event.target.value); setPage(0) }}>
|
||||
<option value="catalog">Permission catalog</option><option value="selected">Selected direct grants</option><option value="inherited">Why access is granted</option>
|
||||
</select>
|
||||
<label htmlFor={id + '-search'}>Search permissions or resources</label>
|
||||
<input id={id + '-search'} className={INPUT_CLASS} value={search} onChange={event => setSearch(event.target.value)} />
|
||||
{view === 'catalog' && <>
|
||||
<label htmlFor={id + '-app'}>Application</label>
|
||||
<select id={id + '-app'} className={INPUT_CLASS} value={app} onChange={event => { setApp(event.target.value); setPage(0) }}>
|
||||
<option value="*">All applications</option>{data?.apps.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor={id + '-kind'}>Resource type</label>
|
||||
<select id={id + '-kind'} className={INPUT_CLASS} value={kind} onChange={event => { setKind(event.target.value); setPage(0) }}>
|
||||
<option value="*">All resource types</option>{['APP', 'TABLE', 'PROCESS', 'REPORT', 'WIDGET', 'OTHER'].map(value => <option key={value}>{value}</option>)}
|
||||
</select>
|
||||
{busy && <p role="status">Loading permission page…</p>}
|
||||
{catalog.isError && <p role="alert" className={ALERT_CLASS}>Could not load permissions. Your selections are preserved. <button type="button" onClick={() => void catalog.refetch()}>Retry</button></p>}
|
||||
<div className="flex gap-4">
|
||||
<button type="button" disabled={busy || !visible.length || catalog.isError} onClick={() => change(visible, true)}>Select {visible.length} permissions on this page</button>
|
||||
<button type="button" disabled={busy || !visible.length || catalog.isError} onClick={() => change(visible, false)}>Clear {visible.length} permissions on this page</button>
|
||||
</div>
|
||||
<div className="overflow-auto"><table className="w-full text-sm"><caption className="sr-only">Direct permissions by resource and action</caption>
|
||||
<thead><tr><th scope="col" className="text-left">Resource</th>{actions.map(action => <th scope="col" key={action}>{action === 'insert' ? 'Create' : action === 'hasAccess' ? 'Access' : action}</th>)}</tr></thead>
|
||||
<tbody>{data?.rows.map(row => <tr key={row.name}><th scope="row" className="text-left"><span>{row.label}</span><small className="block font-normal">{row.name} · {row.kind}</small></th>
|
||||
{actions.map(action => { const option = row.permissions.find(item => item.label === action); return <td key={action} className="p-2 text-center">{option ? <input type="checkbox" aria-label={option.id} data-qqq-id={'access-choice-permissions-' + option.id} checked={selected.has(option.id)} disabled={busy} onChange={event => change([option], event.target.checked)} /> : '—'}</td> })}
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
</>}
|
||||
{view !== 'catalog' && <ul className="space-y-2">{localEntries.slice(localPage * 25, localPage * 25 + 25).map(([permission, source]) => <li key={permission + source}>
|
||||
{view === 'selected' ? <label><input type="checkbox" checked aria-label={permission} onChange={() => change([{ id: permission, label: permission }], false)} /> {permission}</label> : <span>{permission} — {source}</span>}
|
||||
</li>)}</ul>}
|
||||
<p role="status">{total} {view === 'catalog' ? 'resources' : 'permissions'} · Page {currentPage + 1} of {Math.max(1, Math.ceil(total / 25))}</p>
|
||||
<div className="flex gap-4"><button type="button" disabled={currentPage === 0 || (view === 'catalog' && busy)} onClick={() => setPage(currentPage - 1)}>Previous</button>
|
||||
<button type="button" disabled={(currentPage + 1) * 25 >= total || (view === 'catalog' && busy)} onClick={() => setPage(currentPage + 1)}>Next</button></div>
|
||||
{view === 'inherited' && <p className="text-sm">Inherited grants must be changed at their source. Policy grants use Edit Policy.</p>}
|
||||
</fieldset>
|
||||
}
|
||||
@@ -167,7 +167,6 @@ export function CollaborationEdit({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PresenceAvatars viewers={presence.viewers} />
|
||||
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
|
||||
{conflict && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -49,9 +49,11 @@ vi.mock('@/lib/hooks/use-metadata', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/api/tables', () => ({
|
||||
getRecord: vi.fn(),
|
||||
queryRecords: vi.fn().mockResolvedValue({
|
||||
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
|
||||
}),
|
||||
queryRecords: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
searchPeople: vi.fn(),
|
||||
@@ -64,7 +66,6 @@ vi.mock('@/lib/api/collaboration', () => ({
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
getMyWork: vi.fn(),
|
||||
markMentionsRead: vi.fn().mockResolvedValue({ success: true }),
|
||||
listMyTasks: vi.fn(),
|
||||
}))
|
||||
const record = { table: 'person', pk: '1' }
|
||||
@@ -301,16 +302,6 @@ it('renders My work with overdue task, assigned table and unread mentions links'
|
||||
)
|
||||
expect(screen.getByText(/Overdue:/)).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: '3 unread mentions' })).toBeVisible()
|
||||
vi.mocked(api.getMyWork).mockResolvedValue({
|
||||
openTaskCount: 1,
|
||||
tasks: [],
|
||||
assignedRecordsByTable: { person: 1 },
|
||||
unreadMentionCount: 0,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Mark all mentions read' }))
|
||||
await waitFor(() => expect(api.markMentionsRead).toHaveBeenCalledTimes(1))
|
||||
expect(await screen.findByRole('link', { name: '0 unread mentions' })).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Mark all mentions read' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /People \(1\)|Person \(1\)/ })).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('CurrentUser')
|
||||
|
||||
@@ -18,24 +18,16 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import type { Attachment, CollaborationRecord, RecordFeaturesV1 } from '@/types/collaboration'
|
||||
import type { FilePreviewKind } from '@/lib/utils/file-preview'
|
||||
import { formatBytes } from '@/lib/utils/file-preview'
|
||||
import { attachmentUrl } from '@/lib/api/collaboration'
|
||||
import { saveAttachmentDownload } from '@/lib/api/attachment-download'
|
||||
import { useAttachmentPreview } from '@/lib/hooks/use-attachment-preview'
|
||||
import { attachmentRecoveryView } from '@/lib/utils/attachment-recovery-view'
|
||||
import {
|
||||
useAttachments,
|
||||
useAttachmentActions,
|
||||
useAttachmentUrl,
|
||||
} from '@/lib/hooks/use-collaboration-record'
|
||||
import { FileKindIcon } from '@/components/preview/FileKindIcon'
|
||||
import { INPUT_CLASS, LABEL_CLASS, buttonClass } from '@/components/ui/controls'
|
||||
/** Standard in-text link: themed color, underline on hover, visible keyboard focus. */
|
||||
const LINK_CLASS =
|
||||
'inline-flex min-h-11 items-center rounded-sm font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
const ROW_ACTION = buttonClass('secondary', 'sm')
|
||||
const Lightbox = dynamic(
|
||||
() => import('@/components/preview/FilePreviewLightbox').then((m) => m.FilePreviewLightbox),
|
||||
{ ssr: false }
|
||||
@@ -62,35 +54,20 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
|
||||
const [description, setDescription] = useState(file.description ?? '')
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const kind = kindFor(file.contentType)
|
||||
const canEdit = Object.prototype.hasOwnProperty.call(file, 'canEdit') ? file.canEdit === true : file.canDelete === true
|
||||
const url = useAttachmentPreview(file.id, kind === 'image' || preview, kind === 'pdf')
|
||||
const download = async () => {
|
||||
setError('')
|
||||
setDownloading(true)
|
||||
try {
|
||||
const result = await attachmentUrl(file.id, 'download')
|
||||
await saveAttachmentDownload(result, file.fileName ?? 'Attachment')
|
||||
} catch {
|
||||
// Backend/storage diagnostics are not display messages. The lightbox and
|
||||
// direct action share this safe error and the same verified download path.
|
||||
throw new Error('Download unavailable. Please try again.')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
const url = useAttachmentUrl(file.id, 'preview', kind === 'image' || preview)
|
||||
const open = async () => {
|
||||
setError('')
|
||||
try {
|
||||
if (kind === 'other') {
|
||||
await download()
|
||||
const result = await attachmentUrl(file.id, 'download')
|
||||
window.location.assign(result.url)
|
||||
} else {
|
||||
setPreview(true)
|
||||
void url.refetch()
|
||||
}
|
||||
} catch {
|
||||
setError('Download unavailable. Please try again.')
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Could not open file.')
|
||||
}
|
||||
}
|
||||
return (
|
||||
@@ -99,8 +76,7 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
|
||||
data-qqq-id={`attachment-${file.id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-start gap-3 rounded-md text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex w-full min-w-0 items-start gap-3 text-left"
|
||||
data-qqq-id={`attachment-open-${file.id}`}
|
||||
onClick={() => void open()}
|
||||
>
|
||||
@@ -124,40 +100,10 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
|
||||
{file.description && !editing && (
|
||||
<p className="break-words text-xs text-muted-foreground">{file.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{kind !== 'other' && <button
|
||||
type="button"
|
||||
className={ROW_ACTION}
|
||||
data-qqq-id={`attachment-preview-${file.id}`}
|
||||
onClick={() => void open()}
|
||||
>
|
||||
Preview
|
||||
</button>}
|
||||
<button
|
||||
type="button"
|
||||
className={ROW_ACTION}
|
||||
data-qqq-id={`attachment-open-action-${file.id}`}
|
||||
disabled={downloading}
|
||||
onClick={() => void open()}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ROW_ACTION}
|
||||
data-qqq-id={`attachment-download-${file.id}`}
|
||||
disabled={downloading}
|
||||
onClick={() => void download().catch(() => setError('Download unavailable. Please try again.'))}
|
||||
>
|
||||
{downloading ? 'Downloading…' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
{(canEdit || file.canDelete) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit && <button
|
||||
type="button"
|
||||
className={ROW_ACTION}
|
||||
data-qqq-id={`attachment-edit-${file.id}`}
|
||||
{file.canDelete && (
|
||||
<div className="flex gap-3 text-xs">
|
||||
<button
|
||||
data-qqq-id={`attachment-edit-${file.id}`}
|
||||
onClick={() => {
|
||||
setName(file.fileName ?? '')
|
||||
setDescription(file.description ?? '')
|
||||
@@ -165,10 +111,8 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
|
||||
}}
|
||||
>
|
||||
Rename / describe
|
||||
</button>}
|
||||
{file.canDelete && <button
|
||||
type="button"
|
||||
className={buttonClass('destructive', 'sm')}
|
||||
</button>
|
||||
<button
|
||||
data-qqq-id={`attachment-delete-${file.id}`}
|
||||
disabled={actions.remove.isPending}
|
||||
onClick={() => {
|
||||
@@ -177,10 +121,10 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editing && canEdit && (
|
||||
{editing && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => {
|
||||
@@ -191,37 +135,38 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
|
||||
)
|
||||
}}
|
||||
>
|
||||
<label className={`block ${LABEL_CLASS}`}>
|
||||
<label className="block text-xs">
|
||||
File name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={`mt-1 ${INPUT_CLASS}`}
|
||||
className="mt-1 w-full rounded border border-border bg-background p-2"
|
||||
data-qqq-id="attachment-name"
|
||||
/>
|
||||
</label>
|
||||
<label className={`block ${LABEL_CLASS}`}>
|
||||
<label className="block text-xs">
|
||||
Description
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className={`mt-1 ${INPUT_CLASS}`}
|
||||
className="mt-1 w-full rounded border border-border bg-background p-2"
|
||||
data-qqq-id="attachment-description"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
disabled={actions.patch.isPending || !name.trim()}
|
||||
className={buttonClass('primary', 'md')}
|
||||
className="rounded border border-border px-3 py-2 text-xs"
|
||||
data-qqq-id="attachment-save"
|
||||
>
|
||||
Save file details
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{url.isError && <p role="alert" className="text-sm text-destructive">Preview unavailable. Try again or use Download.</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{actions.patch.isError && <p role="alert" className="text-sm text-destructive">Could not save file details. Please try again.</p>}
|
||||
{actions.remove.isError && <p role="alert" className="text-sm text-destructive">Could not delete this file. Please try again.</p>}
|
||||
{(error || actions.patch.isError || actions.remove.isError || (preview && url.isError)) && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error || 'Could not update or open this file. Please try again.'}
|
||||
</p>
|
||||
)}
|
||||
{preview && url.data && (
|
||||
<Lightbox
|
||||
items={[
|
||||
@@ -239,7 +184,6 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
|
||||
index={0}
|
||||
onIndexChange={() => undefined}
|
||||
onClose={() => setPreview(false)}
|
||||
onDownload={download}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
@@ -266,11 +210,6 @@ export function FilesTab({
|
||||
const setting = features.settings?.ATTACHMENTS?.maxBytes
|
||||
const maxBytes = typeof setting === 'number' && setting > 0 ? setting : 100 * 1024 * 1024
|
||||
const files = query.data?.pages.flatMap((page) => page.items) ?? []
|
||||
const recovery = attachmentRecoveryView(features.settings?.ATTACHMENTS?.recoveryView, record)
|
||||
const relatedSettings = features.settings?.ATTACHMENTS?.relatedViews
|
||||
const relatedViews = Array.isArray(relatedSettings) && relatedSettings.length <= 10
|
||||
? relatedSettings.map((value) => attachmentRecoveryView(value, record)).filter((value) => value !== null)
|
||||
: []
|
||||
const upload = async (selection: File[]) => {
|
||||
if (!canAttach) return
|
||||
for (const file of selection) {
|
||||
@@ -311,27 +250,6 @@ export function FilesTab({
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4" data-qqq-id="collaboration-files">
|
||||
{relatedViews.length > 0 && (
|
||||
<div className="flex flex-wrap gap-4 text-sm" data-qqq-id="files-related-views">
|
||||
{relatedViews.map((view, index) => (
|
||||
<Link key={index} href={view.href} className={LINK_CLASS} data-qqq-id={`files-related-view-${index}`}>
|
||||
{view.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{recovery && (
|
||||
<div className="space-y-1 text-sm">
|
||||
<Link href={recovery.href} className={LINK_CLASS} data-qqq-id="files-upload-recovery">
|
||||
{recovery.label}
|
||||
</Link>
|
||||
{uploads.some((item) => item.error) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
An interrupted upload may have saved the file. Review it before uploading again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canAttach ? (
|
||||
<div
|
||||
className={`rounded-lg border-2 border-dashed p-4 text-center ${dragging ? 'border-primary bg-accent' : 'border-border'}`}
|
||||
@@ -349,7 +267,7 @@ export function FilesTab({
|
||||
>
|
||||
<p className="text-sm font-medium">Drop files here</p>
|
||||
{/* the native picker's "Choose Files / No file chosen" text ignores the theme; a styled label opens it */}
|
||||
<label className={`mt-3 min-h-11 cursor-pointer focus-within:ring-2 focus-within:ring-ring ${buttonClass('secondary', 'md')}`}>
|
||||
<label className="mt-3 inline-flex min-h-11 cursor-pointer items-center rounded-md border border-border bg-background px-4 text-sm font-medium hover:bg-accent focus-within:ring-2 focus-within:ring-ring">
|
||||
Choose files
|
||||
<input
|
||||
type="file"
|
||||
@@ -398,7 +316,7 @@ export function FilesTab({
|
||||
{query.isError && (
|
||||
<p role="alert">
|
||||
Could not load files.{' '}
|
||||
<button type="button" className={buttonClass('secondary', 'sm')} data-qqq-id="files-retry" onClick={() => void query.refetch()}>
|
||||
<button data-qqq-id="files-retry" onClick={() => void query.refetch()}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
@@ -413,10 +331,9 @@ export function FilesTab({
|
||||
</ul>
|
||||
{query.hasNextPage && (
|
||||
<button
|
||||
type="button"
|
||||
data-qqq-id="files-more"
|
||||
disabled={query.isFetchingNextPage}
|
||||
className={`w-full ${buttonClass('secondary', 'md')}`}
|
||||
className="w-full rounded border border-border py-2 text-sm"
|
||||
onClick={() => void query.fetchNextPage()}
|
||||
>
|
||||
Load more
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
/** @file Personal tasks, assigned record links and unread mentions. */
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { useCollaborationAvailable } from '@/lib/hooks/use-collaboration-core'
|
||||
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
|
||||
import { getMyWork, listMyTasks, markMentionsRead } from '@/lib/api/collaboration'
|
||||
import { getMyWork, listMyTasks } from '@/lib/api/collaboration'
|
||||
import { queryRecords } from '@/lib/api/tables'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { collaborationButton } from './CollaborationDialog'
|
||||
@@ -78,11 +78,6 @@ function AssignedTable({ name, label, count }: { name: string; label: string; co
|
||||
* @returns Work page.
|
||||
*/
|
||||
export function MyWork() {
|
||||
const cache = useQueryClient()
|
||||
const read = useMutation({
|
||||
mutationFn: markMentionsRead,
|
||||
onSuccess: () => cache.invalidateQueries({ queryKey: queryKeys.collaborationMyWork() }),
|
||||
})
|
||||
const available = useCollaborationAvailable()
|
||||
const metadata = useMetaData()
|
||||
const work = useQuery({
|
||||
@@ -163,17 +158,6 @@ export function MyWork() {
|
||||
</section>
|
||||
<section className="rounded-xl border border-border bg-card p-4">
|
||||
<h2 className="text-lg font-semibold">Unread mentions</h2>
|
||||
{!!work.data.unreadMentionCount && (
|
||||
<button
|
||||
type="button"
|
||||
className={collaborationButton}
|
||||
disabled={read.isPending}
|
||||
onClick={() => read.mutate()}
|
||||
>
|
||||
Mark all mentions read
|
||||
</button>
|
||||
)}
|
||||
{read.isError && <p role="alert">Could not mark mentions read.</p>}
|
||||
<Link
|
||||
href="/app/notifications?status=unread"
|
||||
className="text-sm underline"
|
||||
|
||||
@@ -72,35 +72,6 @@ beforeEach(() => {
|
||||
})
|
||||
vi.mocked(api.putRecordTags).mockResolvedValue({ items: [], nextCursor: null })
|
||||
})
|
||||
it('shows metadata recovery navigation pinned to the source after an interrupted upload', async () => {
|
||||
vi.mocked(api.uploadAttachment).mockRejectedValue(new Error('Registration interrupted'))
|
||||
const recoveryView = {
|
||||
tableName: 'owner_pending_uploads', label: 'Review interrupted uploads',
|
||||
filter: { booleanOperator: 'AND', criteria: [
|
||||
{ fieldName: 'createdByUserId', operator: 'EQUALS', values: [{ type: 'CurrentUser' }] },
|
||||
{ fieldName: 'state', operator: 'IN', values: ['PREPARED', 'REGISTERED'] },
|
||||
] },
|
||||
}
|
||||
const source = { table: 'purchase_order', pk: 'a/b & 雪' }
|
||||
const { container } = mount(<FilesTab record={source} features={{ ...features, settings: { ATTACHMENTS: { maxBytes: 10, recoveryView } } }} />)
|
||||
const link = screen.getByRole('link', { name: recoveryView.label })
|
||||
const target = new URL(link.getAttribute('href')!, 'https://app.example')
|
||||
expect(target.pathname).toBe('/app/owner_pending_uploads')
|
||||
expect(JSON.parse(target.searchParams.get('filter')!).criteria).toEqual([
|
||||
...recoveryView.filter.criteria,
|
||||
{ fieldName: 'tableName', operator: 'EQUALS', values: [source.table] },
|
||||
{ fieldName: 'recordId', operator: 'EQUALS', values: [source.pk] },
|
||||
])
|
||||
fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: [new File(['a'], 'bol.txt')] } })
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Registration interrupted')
|
||||
expect(screen.getByText('An interrupted upload may have saved the file. Review it before uploading again.')).toBeVisible()
|
||||
expect(screen.queryByText('bol.txt Uploaded')).not.toBeInTheDocument()
|
||||
})
|
||||
it('preserves ordinary Files tabs when no recovery metadata is supplied', async () => {
|
||||
mount(<FilesTab record={record} features={features} />)
|
||||
expect(await screen.findByText('No files yet.')).toBeVisible()
|
||||
expect(document.querySelector('[data-qqq-id="files-upload-recovery"]')).toBeNull()
|
||||
})
|
||||
it('inserts a mention with the keyboard, warns and submits the literal token', async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined)
|
||||
mount(<CommentComposer record={record} onSubmit={submit} />)
|
||||
@@ -202,76 +173,6 @@ it('does not offer file uploads without attach permission', async () => {
|
||||
expect(document.querySelector('[data-qqq-id="files-picker"]')).toBeNull()
|
||||
expect(screen.getByText(/permission to attach/)).toBeVisible()
|
||||
})
|
||||
it.each([
|
||||
{ canEdit: true, canDelete: false, edit: true, remove: false },
|
||||
{ canEdit: false, canDelete: true, edit: false, remove: true },
|
||||
{ canEdit: null, canDelete: true, edit: false, remove: true },
|
||||
{ canEdit: undefined, canDelete: true, edit: false, remove: true },
|
||||
{ canDelete: true, edit: true, remove: true },
|
||||
])('keeps attachment editing separate from deletion: %j', async (permissions) => {
|
||||
const { edit, remove, ...capabilities } = permissions
|
||||
vi.mocked(api.listAttachments).mockResolvedValue({ items: [{ id: 9, fileName: 'retained.txt', ...capabilities }], nextCursor: null })
|
||||
mount(<FilesTab record={record} features={features} />)
|
||||
await screen.findByText('retained.txt')
|
||||
expect(screen.queryByRole('button', { name: 'Rename / describe' }) !== null).toBe(edit)
|
||||
expect(screen.queryByRole('button', { name: 'Delete' }) !== null).toBe(remove)
|
||||
})
|
||||
|
||||
it('resolves a fresh download action from an attachment preview', async () => {
|
||||
vi.mocked(api.listAttachments).mockResolvedValue({ items: [{ id: 9, fileName: 'retained.png', contentType: 'image/png' }], nextCursor: null })
|
||||
vi.mocked(api.attachmentUrl).mockImplementation(async (_id, mode) => ({ url: mode === 'preview' ? '/preview.png' : '/authorized-download.png' }))
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
try {
|
||||
mount(<FilesTab record={record} features={features} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /retained.png/ }))
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Download retained.png' }))
|
||||
await waitFor(() => expect(api.attachmentUrl).toHaveBeenCalledWith(9, 'download'))
|
||||
await waitFor(() => expect(click).toHaveBeenCalledTimes(1))
|
||||
expect(click.mock.instances[0]).toHaveAttribute('href', '/authorized-download.png')
|
||||
expect(dialog.querySelector('[data-qqq-id="file-preview-download"]')).not.toHaveAttribute('href')
|
||||
} finally {
|
||||
click.mockRestore()
|
||||
}
|
||||
})
|
||||
it('reports a failed thumbnail as preview unavailable and still offers a separate native download', async () => {
|
||||
vi.mocked(api.listAttachments).mockResolvedValue({ items: [{ id: 9, fileName: 'scan.jpg', contentType: 'image/jpeg', canEdit: false, canDelete: false }], nextCursor: null })
|
||||
vi.mocked(api.attachmentUrl).mockImplementation(async (_id, mode) => {
|
||||
if (mode === 'preview') throw new Error('HTTP 500: private storage diagnostics')
|
||||
return { url: '/authorized-original.jpg' }
|
||||
})
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
try {
|
||||
mount(<FilesTab record={record} features={features} />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Preview unavailable. Try again or use Download.')
|
||||
expect(screen.queryByText(/private storage|update or open/i)).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Preview' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }))
|
||||
await waitFor(() => expect(api.attachmentUrl).toHaveBeenCalledWith(9, 'download'))
|
||||
await waitFor(() => expect(click).toHaveBeenCalledTimes(1))
|
||||
expect(click.mock.instances[0]).toHaveAttribute('href', '/authorized-original.jpg')
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
} finally {
|
||||
click.mockRestore()
|
||||
}
|
||||
})
|
||||
it('keeps a denied direct download separate from preview and editing errors without exposing server details', async () => {
|
||||
vi.mocked(api.listAttachments).mockResolvedValue({ items: [{ id: 9, fileName: 'original.bin', contentType: 'application/octet-stream' }], nextCursor: null })
|
||||
vi.mocked(api.attachmentUrl).mockRejectedValue(new Error('private backend path'))
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
try {
|
||||
mount(<FilesTab record={record} features={features} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Download' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Download unavailable. Please try again.')
|
||||
expect(screen.queryByText(/private backend|update or open/i)).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull()
|
||||
expect(click).not.toHaveBeenCalled()
|
||||
expect(api.attachmentUrl).toHaveBeenCalledExactlyOnceWith(9, 'download')
|
||||
} finally {
|
||||
click.mockRestore()
|
||||
}
|
||||
})
|
||||
it('pages activity with the returned cursor and shows audit details', async () => {
|
||||
vi.mocked(api.listTimeline)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -384,18 +285,3 @@ it('follows and exposes notification preferences and follower count', async () =
|
||||
expect(await screen.findByText('1 follower')).toBeVisible()
|
||||
expect(within(screen.getByRole('dialog')).getByLabelText('Notify me about changes')).toBeChecked()
|
||||
})
|
||||
|
||||
it('renders only valid server-defined related views pinned to this source record', async () => {
|
||||
mount(<FilesTab record={record} features={{ ...features, settings: { ATTACHMENTS: { relatedViews: [
|
||||
{ tableName: 'evidence_history', label: 'Retained evidence', filter: { booleanOperator: 'AND', criteria: [{ fieldName: 'tableName', operator: 'EQUALS', values: [record.table] }] } },
|
||||
{ tableName: '../outside', label: 'Unsafe', filter: { booleanOperator: 'AND', criteria: [] } },
|
||||
] } } }} />)
|
||||
const link = screen.getByRole('link', { name: 'Retained evidence' })
|
||||
const url = new URL(link.getAttribute('href')!, 'https://app.example')
|
||||
expect(url.pathname).toBe('/app/evidence_history')
|
||||
expect(JSON.parse(url.searchParams.get('filter')!).criteria).toEqual(expect.arrayContaining([
|
||||
{ fieldName: 'tableName', operator: 'EQUALS', values: [record.table] },
|
||||
{ fieldName: 'recordId', operator: 'EQUALS', values: [record.pk] },
|
||||
]))
|
||||
expect(screen.queryByRole('link', { name: 'Unsafe' })).toBeNull()
|
||||
})
|
||||
@@ -24,8 +24,6 @@ import { useCollaborationPresence } from '@/lib/hooks/use-collaboration-presence
|
||||
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
|
||||
import { getRecordFeatures, putAssignment } from '@/lib/api/collaboration'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { updateRecord } from '@/lib/api/tables'
|
||||
import { recordVersion } from '@/lib/utils/record-version'
|
||||
import { pluralize } from '@/lib/utils/string-utils'
|
||||
import { PresenceAvatars } from './PresenceAvatars'
|
||||
import { CollaborationDialog, collaborationButton } from './CollaborationDialog'
|
||||
@@ -68,23 +66,7 @@ export function RecordCollaborationHeader({
|
||||
enabled: available && !!features,
|
||||
})
|
||||
const assignment = useMutation({
|
||||
mutationFn: async () => {
|
||||
const nativeField =
|
||||
role === 'ownerUserId'
|
||||
? features?.ownership?.ownerFieldName
|
||||
: features?.ownership?.assigneeFieldName
|
||||
if (nativeField) {
|
||||
await updateRecord(
|
||||
table.name,
|
||||
pk,
|
||||
{ [nativeField]: selected[0] ?? null },
|
||||
undefined,
|
||||
recordVersion(table, record)
|
||||
)
|
||||
} else {
|
||||
await putAssignment(target, { [role]: selected[0] ?? null })
|
||||
}
|
||||
},
|
||||
mutationFn: () => putAssignment(target, { [role]: selected[0] ?? null }),
|
||||
onSuccess: () => {
|
||||
setAssign(false)
|
||||
void cache.invalidateQueries({ queryKey: queryKeys.collaborationRecord(table.name, pk) })
|
||||
@@ -107,7 +89,6 @@ export function RecordCollaborationHeader({
|
||||
data-qqq-id="record-collaboration-header"
|
||||
>
|
||||
{presence.viewers.length > 0 && <PresenceAvatars viewers={presence.viewers} />}
|
||||
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
|
||||
{(owner != null || assignee != null) && (
|
||||
<dl className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{owner != null && (
|
||||
@@ -170,11 +151,11 @@ export function RecordCollaborationHeader({
|
||||
<PeoplePicker record={target} selected={selected} onChange={setSelected} requireRead />
|
||||
<button
|
||||
className={collaborationButton}
|
||||
disabled={assignment.isPending}
|
||||
disabled={assignment.isPending || !selected.length}
|
||||
onClick={() => assignment.mutate()}
|
||||
data-qqq-id="assignment-save"
|
||||
>
|
||||
{selected.length ? 'Assign' : 'Clear assignment'}
|
||||
Assign
|
||||
</button>
|
||||
{assignment.isError && <p role="alert">{assignment.error.message}</p>}
|
||||
</CollaborationDialog>
|
||||
@@ -195,11 +176,7 @@ export function TablePresence({ table, available }: { table: QTableMetaData; ava
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm" data-qqq-id="table-presence">
|
||||
<PresenceAvatars viewers={presence.viewers} />
|
||||
<span role={presence.unavailable ? 'status' : undefined}>
|
||||
{presence.unavailable
|
||||
? 'Presence temporarily unavailable'
|
||||
: `${pluralize(presence.viewers.length, 'person', 'people')} viewing`}
|
||||
</span>
|
||||
<span>{pluralize(presence.viewers.length, 'person', 'people')} viewing</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -57,8 +57,6 @@ export function RecordCollaborationPanel({
|
||||
{ id: 'TASKS', label: 'Tasks' },
|
||||
{ id: 'TIMELINE', label: 'Activity' },
|
||||
].filter((tab) => enabled.includes(tab.id as NonNullable<RecordFeaturesV1['features']>[number]))
|
||||
// A panel whose only feature is attachments is a file list, not a collaboration surface.
|
||||
const title = tabs.length === 1 && tabs[0].id === 'ATTACHMENTS' ? 'Files' : 'Collaboration'
|
||||
const [selected, setSelected] = useState(tabs[0]?.id)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
useEffect(() => {
|
||||
@@ -89,15 +87,15 @@ export function RecordCollaborationPanel({
|
||||
const active = tabs.some((tab) => tab.id === selected) ? selected : tabs[0]?.id
|
||||
return (
|
||||
<aside
|
||||
aria-label={title}
|
||||
aria-label="Collaboration"
|
||||
className={`min-w-0 self-start rounded-xl border border-border bg-card ${collapsed ? 'xl:w-12' : 'xl:w-[360px]'}`}
|
||||
data-qqq-id="record-collaboration-panel"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border p-3">
|
||||
<h2 className={`text-sm font-semibold ${collapsed ? 'xl:hidden' : ''}`}>{title}</h2>
|
||||
<h2 className={`text-sm font-semibold ${collapsed ? 'xl:hidden' : ''}`}>Collaboration</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? `Expand ${title.toLowerCase()}` : `Collapse ${title.toLowerCase()}`}
|
||||
aria-label={collapsed ? 'Expand collaboration' : 'Collapse collaboration'}
|
||||
aria-expanded={!collapsed}
|
||||
className="hidden rounded p-1 hover:bg-accent xl:inline-flex"
|
||||
data-qqq-id="collaboration-collapse"
|
||||
|
||||
@@ -35,7 +35,7 @@ import { cn } from '@/lib/utils/cn'
|
||||
import { CodeEditor } from '@/components/scripts/CodeEditor'
|
||||
|
||||
/** Languages the editor badge names. */
|
||||
export type ScriptEditorLanguage = 'javascript' | 'typescript' | 'groovy' | 'java' | 'python' | 'sql' | 'json' | 'velocity' | 'html' | 'text'
|
||||
export type ScriptEditorLanguage = 'javascript' | 'groovy' | 'java' | 'python' | 'sql' | 'json' | 'velocity' | 'html' | 'text'
|
||||
|
||||
/**
|
||||
* Props for the {@link ScriptEditor} component.
|
||||
@@ -71,7 +71,6 @@ export interface ScriptEditorProps {
|
||||
/** Human-readable display names for each supported language. */
|
||||
const LANGUAGE_LABELS: Record<ScriptEditorLanguage, string> = {
|
||||
javascript: 'JavaScript',
|
||||
typescript: 'TypeScript',
|
||||
groovy: 'Groovy',
|
||||
java: 'Java',
|
||||
python: 'Python',
|
||||
@@ -91,7 +90,6 @@ const LANGUAGE_LABELS: Record<ScriptEditorLanguage, string> = {
|
||||
export function scriptEditorLanguage(mode: string | null | undefined): ScriptEditorLanguage {
|
||||
const normalized = (mode ?? '').trim().toLowerCase()
|
||||
if (normalized === 'js') return 'javascript'
|
||||
if (normalized === 'ts') return 'typescript'
|
||||
if (normalized === 'xml') return 'html'
|
||||
return normalized in LANGUAGE_LABELS ? normalized as ScriptEditorLanguage : 'text'
|
||||
}
|
||||
|
||||
@@ -106,12 +106,6 @@ describe('JobsHeaderButton and JobsPopover', () => {
|
||||
it('shows an active badge, both lists, keyboard trap and restores focus on Escape', async () => {
|
||||
renderConnected(<JobsHeaderButton />)
|
||||
const button = await screen.findByRole('button', { name: 'Jobs, 1 active' })
|
||||
// the badge is positioned against the icon (the header's shared anchor), inside the button
|
||||
const badge = button.querySelector('[data-qqq-id="jobs-active-badge"]')
|
||||
expect(badge).toHaveTextContent('1')
|
||||
expect(badge?.parentElement).toHaveAttribute('data-qqq-id', 'header-icon-anchor')
|
||||
expect(badge?.parentElement).toHaveClass('relative')
|
||||
expect(badge?.parentElement?.parentElement).toBe(button)
|
||||
fireEvent.click(button)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Jobs' })
|
||||
expect(await screen.findByText('Import')).toBeInTheDocument()
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
'use client'
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BriefcaseBusiness } from 'lucide-react'
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
|
||||
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
|
||||
import { useJobsInstalled, useMyJobs } from '@/lib/hooks/use-my-jobs'
|
||||
import './jobs.css'
|
||||
|
||||
@@ -76,18 +76,16 @@ export function JobsHeaderButton() {
|
||||
className="qqq-job-ring absolute inset-1 rounded-full border border-primary"
|
||||
/>
|
||||
)}
|
||||
<HeaderIconAnchor>
|
||||
<BriefcaseBusiness aria-hidden="true" className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${HEADER_BADGE_CLASS} bg-primary text-primary-foreground`}
|
||||
data-qqq-id="jobs-active-badge"
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</HeaderIconAnchor>
|
||||
<BriefcaseBusiness aria-hidden="true" className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
|
||||
data-qqq-id="jobs-active-badge"
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span aria-live="polite" className="sr-only" data-qqq-id="jobs-announcer">
|
||||
{announcement}
|
||||
|
||||
@@ -25,10 +25,7 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Bug, ChevronDown, LogOut, Moon, Settings, Wrench } from 'lucide-react'
|
||||
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { toolsTarget } from '@/components/tools/tools-catalog'
|
||||
import { Bug, ChevronDown, LogOut, Moon, Settings } from 'lucide-react'
|
||||
|
||||
import { getPreferences } from '@/lib/api/preferences'
|
||||
import { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
@@ -110,8 +107,6 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
|
||||
compactItems?: (close: (then?: () => void) => void) => React.ReactNode
|
||||
}) {
|
||||
const { theme, isDarkMode, setDarkMode } = useTheme()
|
||||
const metadata = useMetaData()
|
||||
const tools = toolsTarget(metadata.data)
|
||||
const capability = useQaCaptureCapability()
|
||||
const preferences = useQuery({ queryKey: queryKeys.preferencesIndex(), queryFn: getPreferences, retry: false })
|
||||
const qaSection = capability?.enabled && capability.reason === 'QA_MODE' ? capability.qaMode.preferenceSection : undefined
|
||||
@@ -132,9 +127,6 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
|
||||
<HeaderMenuSeparator />
|
||||
</div>
|
||||
)}
|
||||
{tools && <Link href={tools.path} role="menuitem" onClick={() => close()} className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-tools">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden="true" />{tools.label}
|
||||
</Link>}
|
||||
<Link href="/app/preferences" role="menuitem" onClick={() => close()} className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-preferences">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Preferences
|
||||
|
||||
@@ -33,34 +33,6 @@ export const HEADER_BUTTON_CLASS = cn(
|
||||
'hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)
|
||||
|
||||
/**
|
||||
* The positioning context of a header button's indicators: a box exactly the size of its icon.
|
||||
* The button's own box is the hit area (padding, and a text label from 1440 px on Ask) with no
|
||||
* visible edge at rest, so an indicator placed against it drifts away from the icon.
|
||||
*/
|
||||
export const HEADER_ICON_ANCHOR_CLASS = 'relative flex shrink-0'
|
||||
|
||||
/**
|
||||
* A count badge over the icon's top-right corner (unread, active). On a 40 px icon-only button this
|
||||
* is 2 px inside the button's top-right corner. Add the fill and text colors.
|
||||
*/
|
||||
export const HEADER_BADGE_CLASS = 'absolute -right-2 -top-2 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none'
|
||||
|
||||
/** A status dot on the icon's bottom-right corner (activity). Add the fill color. */
|
||||
export const HEADER_STATUS_DOT_CLASS = 'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full'
|
||||
|
||||
/**
|
||||
* A header button's icon together with its indicators ({@link HEADER_BADGE_CLASS},
|
||||
* {@link HEADER_STATUS_DOT_CLASS}), which stay on the icon whatever else the button holds.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.children - The icon, then its indicators.
|
||||
* @returns The anchor.
|
||||
*/
|
||||
export function HeaderIconAnchor({ children }: { children: React.ReactNode }) {
|
||||
return <span className={HEADER_ICON_ANCHOR_CLASS} data-qqq-id="header-icon-anchor">{children}</span>
|
||||
}
|
||||
|
||||
/** A menu row: full width, 44 px tall on touch. */
|
||||
export const HEADER_MENU_ITEM_CLASS = cn(
|
||||
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-foreground pointer-coarse:min-h-11',
|
||||
|
||||
@@ -32,15 +32,8 @@ vi.mock('@/lib/hooks/use-container-width', () => ({
|
||||
useContainerWidth: () => [undefined, layout.width],
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
|
||||
const navigation = vi.hoisted(() => ({ leaveTo: vi.fn() }))
|
||||
vi.mock('@/lib/utils/external-navigation', async (original) => ({
|
||||
...(await original<typeof import('@/lib/utils/external-navigation')>()),
|
||||
leaveTo: navigation.leaveTo,
|
||||
}))
|
||||
beforeEach(() => {
|
||||
layout.width = 500
|
||||
navigation.leaveTo.mockClear()
|
||||
vi.mocked(toast.success).mockClear()
|
||||
})
|
||||
const tokens = preferencesFixture.sections.find((section) => section.name === 'access-tokens')!
|
||||
function renderForm(section: PreferenceSection = structuredClone(tokens)) {
|
||||
@@ -394,36 +387,3 @@ it('shows each returned secret and restores focus to the returned row after ackn
|
||||
expect(screen.queryByDisplayValue('recovery-test-only')).not.toBeInTheDocument()
|
||||
persist.mockRestore()
|
||||
})
|
||||
|
||||
it('sends the browser to an address-only result and never shows the address', async () => {
|
||||
const authorize =
|
||||
'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&redirect_uri=https%3A%2F%2Fapp.example.test%2Fqqq%2Fhooks%2Fslack%2Foauth%2Fcallback&state=abc.def'
|
||||
const section = structuredClone(tokens)
|
||||
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
|
||||
server.use(
|
||||
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
|
||||
HttpResponse.json({ section, message: authorize })
|
||||
)
|
||||
)
|
||||
renderForm(section)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
|
||||
await waitFor(() => expect(navigation.leaveTo).toHaveBeenCalledWith(authorize))
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/slack\.com/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows a result that only mentions an address as a message', async () => {
|
||||
const section = structuredClone(tokens)
|
||||
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
|
||||
server.use(
|
||||
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
|
||||
HttpResponse.json({ section, message: 'Connected. See https://example.test/help for more.' })
|
||||
)
|
||||
)
|
||||
renderForm(section)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith('Connected. See https://example.test/help for more.')
|
||||
)
|
||||
expect(navigation.leaveTo).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -32,7 +32,6 @@ import type {
|
||||
} from '@/types/preferences'
|
||||
import { runPreferenceAction } from '@/lib/api/preferences'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { continuationUrl, leaveTo } from '@/lib/utils/external-navigation'
|
||||
import { BUTTON_CLASS, buttonClass } from '@/components/ui/controls'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { PreferenceField } from './PreferenceField'
|
||||
@@ -382,11 +381,7 @@ export function usePreferenceActions({
|
||||
showingSecrets.current = Boolean(result.secrets?.length)
|
||||
onSaved(result.section)
|
||||
setSelected(null)
|
||||
// An answer that is only an address (the consent page of a service being connected) is
|
||||
// where the browser goes next. It is never shown: nobody can use an address in a toast.
|
||||
const destination = continuationUrl(result.message)
|
||||
if (destination) leaveTo(destination)
|
||||
else if (result.message) toast.success(result.message)
|
||||
if (result.message) toast.success(result.message)
|
||||
setSecrets(result.secrets ?? [])
|
||||
} catch (error) {
|
||||
if (mounted.current) setErrors(preferenceMessages(error))
|
||||
|
||||
@@ -22,7 +22,7 @@ import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
import { server } from '@/mocks/node'
|
||||
import { ThemeProvider } from '@/lib/theme/theme-provider'
|
||||
import { PreferencesPage, sectionOutcome } from './PreferencesPage'
|
||||
import { PreferencesPage } from './PreferencesPage'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
search: '',
|
||||
@@ -107,42 +107,3 @@ it('offers retry on errors and keeps local display settings accessible', async (
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('says once that a flow that returned to a section succeeded, and takes the outcome off the address', async () => {
|
||||
state.search = 'section=regional&result=connected'
|
||||
renderPage()
|
||||
await screen.findByRole('heading', { name: 'Regional settings' })
|
||||
const outcome = screen.getByText('Regional settings: connected.')
|
||||
expect(outcome).toHaveAttribute('role', 'status')
|
||||
expect(outcome).toHaveAttribute('data-qqq-id', 'preferences-section-outcome')
|
||||
expect(state.replace).toHaveBeenCalledWith('/app/preferences?section=regional', { scroll: false })
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /Notifications What reaches/ }))
|
||||
expect(screen.queryByText('Regional settings: connected.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the reason a flow that returned to a section failed', async () => {
|
||||
state.search = 'section=regional&error=authorization_denied'
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Regional settings: authorization denied.'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads only fixed outcome codes from the address, never a sentence', () => {
|
||||
const read = (search: string) => sectionOutcome(new URLSearchParams(search))
|
||||
expect(read('section=slack&result=connected')).toEqual({
|
||||
section: 'slack',
|
||||
failed: false,
|
||||
words: 'connected',
|
||||
})
|
||||
expect(read('section=slack&error=sign_in_required&result=connected')).toEqual({
|
||||
section: 'slack',
|
||||
failed: true,
|
||||
words: 'sign in required',
|
||||
})
|
||||
expect(read('section=slack')).toBeNull()
|
||||
expect(read('result=connected')).toBeNull()
|
||||
expect(read('section=slack&error=Call+555-0100+now')).toBeNull()
|
||||
expect(read('section=slack&error=%3Cb%3Ex%3C%2Fb%3E')).toBeNull()
|
||||
expect(read(`section=slack&error=${'a_'.repeat(20)}a`)).toBeNull()
|
||||
})
|
||||
@@ -17,14 +17,13 @@
|
||||
/** @file Preferences route content, with device display settings and server-owned sections. */
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getPreferences } from '@/lib/api/preferences'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { BUTTON_CLASS } from '@/components/ui/controls'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { DisplayPreferencesPanel } from '@/components/layout/DisplayPreferencesPanel'
|
||||
@@ -44,30 +43,6 @@ export function PreferencesSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
/** What a flow that left the dashboard reports when it returns to a section. */
|
||||
export interface SectionOutcome {
|
||||
section: string
|
||||
failed: boolean
|
||||
/** The provider's outcome code as words: `authorization_denied` reads "authorization denied". */
|
||||
words: string
|
||||
}
|
||||
|
||||
/** Reads the outcome a flow that left the dashboard (a consent page of a connected service)
|
||||
* puts on the address when it returns: `?section=<name>&result=<code>` when it succeeded,
|
||||
* `&error=<code>` when it did not. The code is one of the provider's fixed lowercase names
|
||||
* (`connected`, `authorization_denied`). It is shown as words after the section's own label;
|
||||
* anything else on the address is ignored, so a link cannot put its own sentence on the page.
|
||||
* @param params - The page's query string.
|
||||
* @returns The outcome, or `null` when the address reports none.
|
||||
*/
|
||||
export function sectionOutcome(params: URLSearchParams): SectionOutcome | null {
|
||||
const section = params.get('section')
|
||||
const error = params.get('error')
|
||||
const code = error ?? params.get('result')
|
||||
if (!section || !code || !/^[a-z]+(?:_[a-z]+){0,7}$/.test(code)) return null
|
||||
return { section, failed: error !== null, words: code.replace(/_/g, ' ') }
|
||||
}
|
||||
|
||||
/** Every registered section and a client-only Display section, selected through the URL.
|
||||
* @returns Preferences navigation and selected section.
|
||||
*/
|
||||
@@ -84,13 +59,6 @@ export function PreferencesPage() {
|
||||
})
|
||||
// A client-only key cannot collide with the backend's [a-z] section names.
|
||||
const requested = params.get('section')
|
||||
const [outcome, setOutcome] = useState(() => sectionOutcome(params))
|
||||
const reported = params.has('result') || params.has('error')
|
||||
// An outcome is reported once: off the address it goes, so a reload does not repeat it.
|
||||
useEffect(() => {
|
||||
if (reported && requested)
|
||||
router.replace(`/app/preferences?section=${encodeURIComponent(requested)}`, { scroll: false })
|
||||
}, [reported, requested, router])
|
||||
const selected =
|
||||
requested === 'display' && !query.data?.sections.some((section) => section.name === 'display')
|
||||
? '__display'
|
||||
@@ -105,15 +73,13 @@ export function PreferencesPage() {
|
||||
...(query.data?.sections ?? []),
|
||||
]
|
||||
const section = query.data?.sections.find((section) => section.name === selected)
|
||||
const navigate = (name: string) => {
|
||||
setOutcome(null)
|
||||
const navigate = (name: string) =>
|
||||
router.replace(
|
||||
name === '__display'
|
||||
? '/app/preferences'
|
||||
: `/app/preferences?section=${encodeURIComponent(name)}`,
|
||||
{ scroll: false }
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl space-y-6" data-qqq-id="preferences-page">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Preferences</h1>
|
||||
@@ -193,20 +159,6 @@ export function PreferencesPage() {
|
||||
<PreferencesSkeleton />
|
||||
) : section ? (
|
||||
<>
|
||||
{outcome?.section === section.name && (
|
||||
<p
|
||||
role={outcome.failed ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
outcome.failed
|
||||
? 'border-destructive text-destructive'
|
||||
: 'border-border bg-muted text-foreground'
|
||||
)}
|
||||
data-qqq-id="preferences-section-outcome"
|
||||
>
|
||||
{`${section.label}: ${outcome.words}.`}
|
||||
</p>
|
||||
)}
|
||||
<PreferenceSectionForm key={section.name} section={section} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DeliveredFileActions } from './DeliveredFileActions'
|
||||
import * as delivery from '@/lib/api/attachment-download'
|
||||
|
||||
vi.mock('@/lib/api/attachment-download', () => ({ fetchTrackedAttachment: vi.fn(), safeAttachmentPreviewBlob: vi.fn(), saveAttachmentDownload: vi.fn() }))
|
||||
vi.mock('./FilePreviewLightbox', () => ({ FilePreviewLightbox: ({ items, onClose }: { items: { url: string }[]; onClose: () => void }) =>
|
||||
<div role="dialog"><span>{items[0].url}</span><button onClick={onClose}>Close preview</button></div> }))
|
||||
const URL_VALUE = 'https://api.example/credential-bound-transfer'
|
||||
|
||||
describe('metadata authenticated delivery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('URL', Object.assign(class extends URL {}, { createObjectURL: vi.fn(() => 'blob:verified'), revokeObjectURL: vi.fn() }))
|
||||
vi.mocked(delivery.fetchTrackedAttachment).mockResolvedValue(new Blob(['verified']))
|
||||
vi.mocked(delivery.safeAttachmentPreviewBlob).mockResolvedValue(new Blob(['%PDF-1.7'], { type: 'application/pdf' }))
|
||||
vi.mocked(delivery.saveAttachmentDownload).mockResolvedValue()
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('never renders an authenticated capability in href/src and verifies only on request', async () => {
|
||||
const result = render(<DeliveredFileActions url={URL_VALUE} fileName="Packing.pdf" allowPreview dataQqqId="delivery" />)
|
||||
expect(delivery.fetchTrackedAttachment).not.toHaveBeenCalled()
|
||||
expect(result.container.querySelector('a, img, iframe')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
|
||||
await screen.findByRole('dialog')
|
||||
expect(delivery.fetchTrackedAttachment).toHaveBeenCalledWith(URL_VALUE, expect.any(AbortSignal))
|
||||
expect(delivery.safeAttachmentPreviewBlob).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('blob:verified')
|
||||
expect(result.container.innerHTML).not.toContain(URL_VALUE)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close preview' }))
|
||||
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:verified'))
|
||||
})
|
||||
|
||||
it('shows receipt refusal and never opens a preview', async () => {
|
||||
vi.mocked(delivery.fetchTrackedAttachment).mockRejectedValue(new Error('No durable receipt'))
|
||||
render(<DeliveredFileActions url={URL_VALUE} fileName="Packing.pdf" allowPreview dataQqqId="delivery" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('No durable receipt')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves through the shared receipt gate and does not invent a plain link fallback', async () => {
|
||||
vi.mocked(delivery.saveAttachmentDownload).mockRejectedValue(new Error('Access revoked'))
|
||||
const result = render(<DeliveredFileActions url={URL_VALUE} fileName="Label.zpl" allowPreview={false} dataQqqId="delivery" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download file' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Access revoked')
|
||||
expect(delivery.saveAttachmentDownload).toHaveBeenCalledWith({ url: URL_VALUE, delivery: 'AUTHENTICATED_FETCH' }, 'Label.zpl', expect.any(AbortSignal))
|
||||
expect(result.container.querySelector('a')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Preview file' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not allocate a blob when its view unmounts during verification', async () => {
|
||||
let finish!: (blob: Blob) => void
|
||||
vi.mocked(delivery.fetchTrackedAttachment).mockReturnValue(new Promise((resolve) => { finish = resolve }))
|
||||
const result = render(<DeliveredFileActions url={URL_VALUE} fileName="Packing.pdf" allowPreview dataQqqId="delivery" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
|
||||
const signal = vi.mocked(delivery.fetchTrackedAttachment).mock.calls[0][1]
|
||||
result.unmount(); finish(new Blob(['verified']))
|
||||
await waitFor(() => expect(signal?.aborted).toBe(true))
|
||||
expect(delivery.safeAttachmentPreviewBlob).not.toHaveBeenCalled()
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
/* Copyright 2026 QRun.IO, Inc. Licensed under the Apache License, Version 2.0. */
|
||||
import React from 'react'
|
||||
import { Blob as NodeBlob } from 'node:buffer'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { DeliveredFileActions } from './DeliveredFileActions'
|
||||
|
||||
vi.mock('@/lib/api/client', () => ({ default: { get: vi.fn(), getInstance: () => ({ defaults: { baseURL: 'https://api.example/qqq/v1' } }) } }))
|
||||
vi.mock('./FilePreviewLightbox', () => ({ FilePreviewLightbox: () => <div role="dialog" /> }))
|
||||
const url = 'https://api.example/qqq/v1/qbit/record-files/files/12/downloads/10000000-0000-0000-0000-000000000001'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.stubGlobal('Blob', NodeBlob)
|
||||
vi.stubGlobal('URL', Object.assign(class extends URL {}, { createObjectURL: vi.fn(), revokeObjectURL: vi.fn() }))
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it.each(['unmount', 'source change'])('aborts an actual deferred receipt on %s without creating a preview', async (change) => {
|
||||
let receiptSignal: AbortSignal | undefined
|
||||
let rejectReceipt!: (error: Error) => void
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce(new Blob(['%PDF-1.7\nfixture'])).mockImplementationOnce((_route, options) => {
|
||||
receiptSignal = options?.signal as AbortSignal
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectReceipt = reject
|
||||
receiptSignal?.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')), { once: true })
|
||||
})
|
||||
})
|
||||
const view = render(<DeliveredFileActions url={url} fileName="packing.pdf" allowPreview dataQqqId="delivery" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
|
||||
await waitFor(() => expect(apiClient.get).toHaveBeenCalledTimes(2))
|
||||
expect(vi.mocked(apiClient.get).mock.calls[1][0]).toMatch(/\/receipt$/)
|
||||
expect(receiptSignal?.aborted).toBe(false)
|
||||
if (change === 'unmount') view.unmount()
|
||||
else view.rerender(<DeliveredFileActions url={url.replace('/12/', '/13/')} fileName="other.pdf" allowPreview dataQqqId="delivery" />)
|
||||
await waitFor(() => expect(receiptSignal?.aborted).toBe(true))
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
rejectReceipt(new Error('Late response cannot revive the preview'))
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Receipt-gated actions for metadata-declared authenticated file delivery. */
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { fetchTrackedAttachment, safeAttachmentPreviewBlob, saveAttachmentDownload } from '@/lib/api/attachment-download'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { kindOfMimeType, type FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
import { FilePreviewLightbox } from './FilePreviewLightbox'
|
||||
|
||||
/** Props supplied by generic FILE_DOWNLOAD field metadata. */
|
||||
interface Props {
|
||||
url: string
|
||||
fileName: string
|
||||
allowPreview: boolean
|
||||
dataQqqId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated URLs are never href/src values. Only verified inert blobs can preview.
|
||||
* @param props - Current authorized delivery and display-only name.
|
||||
* @returns Explicit download/preview controls and any visible audit failure.
|
||||
*/
|
||||
export function DeliveredFileActions({ url, fileName, allowPreview, dataQqqId }: Props) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const [preview, setPreview] = useState<FilePreviewItem>()
|
||||
const alive = useRef(true)
|
||||
const activeOperation = useRef<AbortController | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
setPreview(undefined); setPending(false); setError(undefined)
|
||||
return () => { alive.current = false; activeOperation.current?.abort() }
|
||||
}, [url])
|
||||
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview.url) }, [preview])
|
||||
|
||||
/** Download always goes through the existing authenticated receipt verifier.
|
||||
* @param signal - Current operation lifetime, including source changes/unmount.
|
||||
*/
|
||||
async function download(signal: AbortSignal) {
|
||||
await saveAttachmentDownload({ url, delivery: 'AUTHENTICATED_FETCH' }, fileName, signal)
|
||||
}
|
||||
|
||||
/** Load an authorized bounded body, then restrict preview to verified raster/PDF.
|
||||
* @param signal - Current operation lifetime, including source changes/unmount.
|
||||
*/
|
||||
async function openPreview(signal: AbortSignal) {
|
||||
const original = await fetchTrackedAttachment(url, signal)
|
||||
signal.throwIfAborted()
|
||||
const blob = await safeAttachmentPreviewBlob(original)
|
||||
signal.throwIfAborted()
|
||||
if (!alive.current) return
|
||||
setPreview({ url: URL.createObjectURL(blob), fileName, mimeType: blob.type, kind: kindOfMimeType(blob.type),
|
||||
alt: fileName, sizeBytes: blob.size, previewable: true, thumbnailSize: 160 })
|
||||
}
|
||||
|
||||
/** Keep all failures visible; an audit failure never becomes an ordinary link.
|
||||
* @param action - Receipt-gated operation selected by the user.
|
||||
*/
|
||||
async function perform(action: (signal: AbortSignal) => Promise<void>) {
|
||||
activeOperation.current?.abort()
|
||||
const controller = new AbortController()
|
||||
activeOperation.current = controller
|
||||
setPending(true); setError(undefined)
|
||||
try { await action(controller.signal) }
|
||||
catch (failure) {
|
||||
if (alive.current && activeOperation.current === controller && !controller.signal.aborted) setError(getErrorMessage(failure))
|
||||
throw failure
|
||||
} finally {
|
||||
if (activeOperation.current === controller) {
|
||||
activeOperation.current = undefined
|
||||
if (alive.current) setPending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <span className="inline-flex flex-wrap items-center gap-2" data-qqq-id={dataQqqId}>
|
||||
<span>{fileName}</span>
|
||||
<button type="button" className="text-link underline disabled:opacity-50 pointer-coarse:inline-flex pointer-coarse:min-h-11 pointer-coarse:items-center" disabled={pending}
|
||||
data-qqq-id={dataQqqId + '-download'} onClick={() => { void perform(download).catch(() => {}) }}>Download file</button>
|
||||
{allowPreview && <button type="button" className="text-link underline disabled:opacity-50 pointer-coarse:inline-flex pointer-coarse:min-h-11 pointer-coarse:items-center" disabled={pending}
|
||||
data-qqq-id={dataQqqId + '-preview'} onClick={() => { void perform(openPreview).catch(() => {}) }}>Preview file</button>}
|
||||
{pending && <span role="status">Verifying file delivery…</span>}
|
||||
{error && <span role="alert" className="text-destructive">{error}</span>}
|
||||
{preview && <FilePreviewLightbox items={[preview]} index={0} onIndexChange={() => {}} onClose={() => setPreview(undefined)} onDownload={() => perform(download)} />}
|
||||
</span>
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/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'
|
||||
@@ -29,8 +29,6 @@ import { FileThumbnail } from './FileThumbnail'
|
||||
import { FilePreviewLightbox } from './FilePreviewLightbox'
|
||||
import type { FilePreviewItem } from '@/lib/utils/file-preview'
|
||||
|
||||
vi.mock('./PdfPreview', () => ({ PdfPreview: ({ url }: { url: string }) => <div data-qqq-id="file-preview-pdf" data-source={url} /> }))
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
function field(name: string, values: Record<string, unknown> = {}, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
|
||||
@@ -141,9 +139,7 @@ describe('FilePreviewGallery', () => {
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(within(dialog).getByRole('heading', { name: 'two.pdf' })).toBeInTheDocument()
|
||||
expect(dialog).toHaveAttribute('data-preview-kind', 'pdf')
|
||||
await waitFor(() => expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toHaveAttribute('data-source', '/qqq/v1/table/media/2/blob/two.pdf'))
|
||||
expect(document.querySelector('iframe')).toBeNull()
|
||||
expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toBeNull()
|
||||
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()
|
||||
@@ -186,21 +182,6 @@ describe('FilePreviewGallery', () => {
|
||||
})
|
||||
|
||||
describe('FilePreviewLightbox', () => {
|
||||
it('uses the authorized callback and never falls back to its preview URL on failure', async () => {
|
||||
let reject!: (reason: Error) => void
|
||||
const download = vi.fn(() => new Promise<void>((_resolve, rejectPromise) => { reject = rejectPromise }))
|
||||
const file = item()
|
||||
render(<FilePreviewLightbox items={[file]} index={0} onIndexChange={() => undefined} onClose={() => undefined} onDownload={download} />)
|
||||
const button = screen.getByRole('button', { name: 'Download a.png' })
|
||||
fireEvent.click(button)
|
||||
expect(download).toHaveBeenCalledWith(file)
|
||||
expect(button).toBeDisabled()
|
||||
expect(button).not.toHaveAttribute('href')
|
||||
await act(async () => reject(new Error('Audit unavailable')))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not authorize this download')
|
||||
await waitFor(() => expect(button).not.toBeDisabled())
|
||||
expect(button).not.toHaveAttribute('href')
|
||||
})
|
||||
function show(items: FilePreviewItem[], index = 0) {
|
||||
const onIndexChange = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
@@ -267,13 +248,13 @@ describe('FilePreviewLightbox', () => {
|
||||
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(/trusted file preview source/)).toBeInTheDocument()
|
||||
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(/trusted file preview source/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/not a file served by the application/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('plays video and audio, falling back when the player cannot load them', () => {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react'
|
||||
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'
|
||||
|
||||
@@ -31,12 +31,9 @@ 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 } from '@/lib/utils/file-preview'
|
||||
import { formatBytes, isBackendFileUrl, isSameOrigin } from '@/lib/utils/file-preview'
|
||||
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
|
||||
import { FileKindIcon } from './FileKindIcon'
|
||||
import { pdfPreviewSource } from '@/lib/api/pdf-preview'
|
||||
|
||||
const PdfPreview = lazy(() => import('./PdfPreview').then((module) => ({ default: module.PdfPreview })))
|
||||
|
||||
/** Zoom steps of an image preview (1 = fit to the window). */
|
||||
export const ZOOM_STEPS = [1, 1.5, 2, 3, 4]
|
||||
@@ -51,8 +48,6 @@ export interface FilePreviewLightboxProps {
|
||||
onIndexChange: (index: number) => void
|
||||
/** Called when the preview closes. */
|
||||
onClose: () => void
|
||||
/** Authorizes a fresh download; when supplied, the preview URL is never used to download. */
|
||||
onDownload?: (item: FilePreviewItem) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +58,7 @@ export interface FilePreviewLightboxProps {
|
||||
* @param props - See {@link FilePreviewLightboxProps}.
|
||||
* @returns The dialog.
|
||||
*/
|
||||
export function FilePreviewLightbox({ items, index, onIndexChange, onClose, onDownload }: FilePreviewLightboxProps) {
|
||||
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
|
||||
@@ -161,7 +156,7 @@ export function FilePreviewLightbox({ items, index, onIndexChange, onClose, onDo
|
||||
<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 key={item.url} item={item} onDownload={onDownload} />
|
||||
<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">
|
||||
@@ -251,10 +246,15 @@ function PreviewStage({ item, zoom, failed, onFailed, onNaturalSize, onToggleZoo
|
||||
}
|
||||
|
||||
if (item.kind === 'pdf') {
|
||||
try { pdfPreviewSource(item.url) } catch {
|
||||
return <Unavailable item={item} reason="This PDF is not available through a trusted file preview source." />
|
||||
// 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." />
|
||||
}
|
||||
return <Suspense fallback={<p role="status" className="p-4">Loading PDF viewer…</p>}><PdfPreview key={item.url} url={item.url} fileName={item.fileName} /></Suspense>
|
||||
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') {
|
||||
@@ -297,9 +297,7 @@ function Unavailable({ item, reason }: { item: FilePreviewItem; reason: string }
|
||||
* @param props - The item.
|
||||
* @returns The links.
|
||||
*/
|
||||
function FileActions({ item, onDownload }: { item: FilePreviewItem; onDownload?: (item: FilePreviewItem) => Promise<void> }) {
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
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/'))
|
||||
@@ -307,32 +305,14 @@ function FileActions({ item, onDownload }: { item: FilePreviewItem; onDownload?:
|
||||
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 (
|
||||
<>
|
||||
{item.kind !== 'pdf' && <a href={item.url} target="_blank" rel="noopener noreferrer" className={linkClass} title="Open original" data-qqq-id="file-preview-open">
|
||||
<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>}
|
||||
{onDownload ? (
|
||||
<button type="button" disabled={pending} aria-busy={pending} className={cn(linkClass, 'disabled:opacity-50')}
|
||||
title="Download" data-qqq-id="file-preview-download"
|
||||
onClick={async () => {
|
||||
setPending(true)
|
||||
setError(false)
|
||||
try {
|
||||
await onDownload(item)
|
||||
} catch {
|
||||
setError(true)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}}>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">{pending ? 'Preparing download' : `Download ${item.fileName}`}</span>
|
||||
</button>
|
||||
) : <a href={downloadHref} download={item.fileName} className={linkClass} title="Download" data-qqq-id="file-preview-download">
|
||||
</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>}
|
||||
{error && <p role="alert" className="max-w-xs text-xs text-destructive">Could not authorize this download. Please try again.</p>}
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
|
||||
import { PdfPreview } from './PdfPreview'
|
||||
const mocks = vi.hoisted(() => ({ load: vi.fn(), getDocument: vi.fn(), create: vi.fn(), terminate: vi.fn(), destroy: vi.fn(), render: vi.fn(), cancel: vi.fn(), reader: vi.fn(), cleanup: vi.fn() }))
|
||||
vi.mock('@/lib/api/pdf-preview', () => ({ loadPdfPreviewBytes: mocks.load, PDF_PAGE_LIMIT: 1000, PDF_PIXEL_LIMIT: 8_000_000 }))
|
||||
vi.mock('pdfjs-dist', () => ({ PDFWorker: { create: mocks.create }, getDocument: mocks.getDocument }))
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('Worker', class { terminate = mocks.terminate })
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({} as CanvasRenderingContext2D)
|
||||
mocks.load.mockResolvedValue(new Uint8Array([1]))
|
||||
mocks.destroy.mockResolvedValue(undefined)
|
||||
mocks.render.mockReturnValue({ promise: Promise.resolve(), cancel: mocks.cancel })
|
||||
mocks.reader.mockImplementation(() => {
|
||||
let read = false
|
||||
return { read: async () => read ? { done: true } : (read = true, { done: false, value: { items: [{ str: '<script>inert page text</script>' }] } }), cancel: async () => {}, releaseLock: () => {} }
|
||||
})
|
||||
mocks.getDocument.mockReturnValue({ destroy: mocks.destroy, promise: Promise.resolve({ numPages: 2, getPage: async () => ({
|
||||
getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
|
||||
render: mocks.render, cleanup: mocks.cleanup, streamTextContent: () => ({ getReader: mocks.reader }),
|
||||
}) }) })
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
it('renders pixels and inert text with explicit worker/data-only policy, then cancels on close', async () => {
|
||||
const user = userEvent.setup()
|
||||
const view = render(<PdfPreview url="blob:http://localhost/fixture" fileName="Packing slip.pdf" />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled())
|
||||
expect(mocks.getDocument).toHaveBeenCalledWith(expect.objectContaining({ data: expect.any(Uint8Array), useWorkerFetch: false, useWasm: false, enableXfa: false, maxImageSize: 8_000_000 }))
|
||||
expect(mocks.getDocument.mock.calls[0][0].url).toBeUndefined()
|
||||
expect(mocks.render).toHaveBeenCalledWith(expect.objectContaining({ annotationMode: 0 }))
|
||||
await waitFor(() => expect(mocks.cleanup).toHaveBeenCalledTimes(1))
|
||||
expect(document.querySelector('iframe,object,embed,script')).toBeNull()
|
||||
await waitFor(() => expect(screen.getByText('<script>inert page text</script>')).toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: 'Next page' }))
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAccessibleName('Packing slip.pdf, page 2'))
|
||||
view.unmount()
|
||||
expect(mocks.terminate).toHaveBeenCalled()
|
||||
expect(mocks.destroy).toHaveBeenCalled()
|
||||
})
|
||||
it('starts the packaged worker from a .js URL, because hosts serve .mjs as application/octet-stream and module workers refuse it', async () => {
|
||||
const urls: string[] = []
|
||||
vi.stubGlobal('Worker', class { terminate = mocks.terminate; constructor(url: string) { urls.push(url) } })
|
||||
render(<PdfPreview url="blob:http://localhost/fixture" fileName="Packing slip.pdf" />)
|
||||
await waitFor(() => expect(urls).toHaveLength(1))
|
||||
expect(urls[0]).toMatch(/^\/pdf-generated\/pdf-[\d.]+\.worker\.js$/)
|
||||
})
|
||||
it('rejects page counts outside the bound without rendering', async () => {
|
||||
mocks.getDocument.mockReturnValue({ destroy: mocks.destroy, promise: Promise.resolve({ numPages: 1001 }) })
|
||||
render(<PdfPreview url="blob:http://localhost/fixture" fileName="Huge.pdf" />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('preview limits')
|
||||
expect(mocks.render).not.toHaveBeenCalled()
|
||||
expect(mocks.terminate).toHaveBeenCalled()
|
||||
})
|
||||
it('stops before starting a parser when closed during input loading', async () => {
|
||||
let resolve!: (bytes: Uint8Array) => void
|
||||
mocks.load.mockImplementation(() => new Promise<Uint8Array>((done) => { resolve = done }))
|
||||
const view = render(<PdfPreview url="blob:http://localhost/fixture" fileName="Slow.pdf" />)
|
||||
view.unmount(); resolve(new Uint8Array([1]))
|
||||
await new Promise((done) => setTimeout(done, 0))
|
||||
expect(mocks.getDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds streamed text before materializing a full page string', async () => {
|
||||
const reader = { read: vi.fn().mockResolvedValue({ done: false, value: { items: [{ str: 'x'.repeat(200_000) }] } }), cancel: vi.fn().mockResolvedValue(undefined), releaseLock: vi.fn() }
|
||||
mocks.reader.mockReturnValue(reader)
|
||||
render(<PdfPreview url="blob:http://localhost/fixture" fileName="Text.pdf" />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled())
|
||||
expect(reader.read).toHaveBeenCalledTimes(1)
|
||||
expect(reader.cancel).toHaveBeenCalled()
|
||||
expect(document.querySelector('details p')?.textContent?.length).toBe(100_000)
|
||||
})
|
||||
it('keeps navigation busy during text transfer and cancels that transfer on close', async () => {
|
||||
let finish!: (value: { done: boolean }) => void
|
||||
const reader = { read: vi.fn(() => new Promise<{ done: boolean }>((resolve) => { finish = resolve })), cancel: vi.fn(async () => { finish({ done: true }) }), releaseLock: vi.fn() }
|
||||
mocks.reader.mockReturnValue(reader)
|
||||
const view = render(<PdfPreview url="blob:http://localhost/fixture" fileName="Text.pdf" />)
|
||||
await waitFor(() => expect(reader.read).toHaveBeenCalled())
|
||||
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
|
||||
view.unmount()
|
||||
await waitFor(() => expect(reader.cancel).toHaveBeenCalled())
|
||||
expect(mocks.terminate).toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Canvas-only PDF pages with a real parser worker, bounded resources and no document actions. */
|
||||
'use client'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PDFDocumentProxy, PDFDocumentLoadingTask, PDFPageProxy, RenderTask } from 'pdfjs-dist'
|
||||
import { loadPdfPreviewBytes, PDF_PAGE_LIMIT, PDF_PIXEL_LIMIT } from '@/lib/api/pdf-preview'
|
||||
|
||||
/** No document-selected auxiliary URL is fetched by the main thread. */
|
||||
class NoExternalPdfData {
|
||||
/** Reject an auxiliary request instead of resolving any document-selected URL.
|
||||
* @returns A rejected resource request.
|
||||
*/
|
||||
async fetch(): Promise<Uint8Array> { throw new Error('External PDF resources are disabled') }
|
||||
}
|
||||
|
||||
/** Render a PDF as pixels/text only; the browser never navigates to the uploaded content.
|
||||
* @param props - Authorized source URL and accessible document name.
|
||||
* @returns Bounded page controls, canvas and optional plain text.
|
||||
*/
|
||||
export function PdfPreview({ url, fileName }: { url: string; fileName: string }) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
const [document, setDocument] = useState<PDFDocumentProxy | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [error, setError] = useState(false)
|
||||
const [busy, setBusy] = useState(true)
|
||||
const [text, setText] = useState('')
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController()
|
||||
let task: PDFDocumentLoadingTask | undefined
|
||||
let worker: Worker | undefined
|
||||
let disposed = false
|
||||
setDocument(null); setPage(1); setError(false); setBusy(true)
|
||||
const timeout = setTimeout(() => { abort.abort(); worker?.terminate(); if (!disposed) { setError(true); setBusy(false) } }, 30_000)
|
||||
void (async () => {
|
||||
const bytes = await loadPdfPreviewBytes(url, abort.signal)
|
||||
const pdf = await import('pdfjs-dist')
|
||||
abort.signal.throwIfAborted()
|
||||
// Supplying a real port avoids PDF.js's main-thread fake-worker fallback.
|
||||
worker = new Worker('/pdf-generated/pdf-6.4.299.worker.js', { type: 'module', name: 'qqq-pdf-preview' })
|
||||
workerRef.current = worker
|
||||
const parser = pdf.PDFWorker.create({ port: worker })
|
||||
task = pdf.getDocument({ data: bytes, worker: parser, enableXfa: false,
|
||||
useWorkerFetch: false, useWasm: false, BinaryDataFactory: NoExternalPdfData,
|
||||
disableAutoFetch: true, disableRange: true, disableStream: true,
|
||||
maxImageSize: PDF_PIXEL_LIMIT, canvasMaxAreaInBytes: PDF_PIXEL_LIMIT * 4,
|
||||
disableFontFace: true, useSystemFonts: true, stopAtErrors: true, verbosity: 0,
|
||||
})
|
||||
const loaded = await task.promise
|
||||
if (loaded.numPages < 1 || loaded.numPages > PDF_PAGE_LIMIT) throw new Error('PDF page limit exceeded')
|
||||
if (!disposed && !abort.signal.aborted) setDocument(loaded)
|
||||
})().catch(() => { if (!disposed) { setError(true); setBusy(false); worker?.terminate() } }).finally(() => clearTimeout(timeout))
|
||||
return () => {
|
||||
disposed = true; abort.abort(); clearTimeout(timeout)
|
||||
worker?.terminate(); workerRef.current = null
|
||||
void task?.destroy().catch(() => {})
|
||||
}
|
||||
}, [url])
|
||||
|
||||
useEffect(() => {
|
||||
if (!document || !canvas.current || error) return
|
||||
let disposed = false
|
||||
let render: RenderTask | undefined
|
||||
let loadedPage: PDFPageProxy | undefined
|
||||
let cancelText: (() => void) | undefined
|
||||
setBusy(true); setText('')
|
||||
const target = canvas.current
|
||||
const timeout = setTimeout(() => { render?.cancel(); cancelText?.(); workerRef.current?.terminate(); if (!disposed) { setError(true); setBusy(false) } }, 15_000)
|
||||
void (async () => {
|
||||
const pdfPage = await document.getPage(page)
|
||||
loadedPage = pdfPage
|
||||
if (disposed) return
|
||||
const natural = pdfPage.getViewport({ scale: 1 })
|
||||
if (!Number.isFinite(natural.width * natural.height) || natural.width <= 0 || natural.height <= 0) throw new Error('Invalid page size')
|
||||
const desired = Math.min(2, window.devicePixelRatio || 1) * zoom * Math.min(1.5, 1000 / natural.width)
|
||||
const scale = Math.min(desired, 8192 / natural.width, 8192 / natural.height, Math.sqrt(PDF_PIXEL_LIMIT / (natural.width * natural.height)))
|
||||
const viewport = pdfPage.getViewport({ scale })
|
||||
target.width = Math.max(1, Math.floor(viewport.width)); target.height = Math.max(1, Math.floor(viewport.height))
|
||||
const context = target.getContext('2d', { alpha: false })
|
||||
if (!context) throw new Error('Canvas unavailable')
|
||||
// No annotation, form, action, link, HTML or scripting layer is created.
|
||||
render = pdfPage.render({ canvas: target, canvasContext: context, viewport, annotationMode: 0 })
|
||||
await render.promise
|
||||
if (disposed) return
|
||||
const reader = pdfPage.streamTextContent().getReader()
|
||||
cancelText = () => { void reader.cancel().catch(() => {}) }
|
||||
const parts: string[] = []
|
||||
let characters = 0
|
||||
let items = 0
|
||||
try {
|
||||
while (!disposed && characters < 100_000 && items < 10_000) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done || disposed) break
|
||||
for (const item of chunk.value.items) {
|
||||
if (++items > 10_000 || characters >= 100_000) break
|
||||
if ('str' in item) {
|
||||
const part = item.str.slice(0, 100_000 - characters)
|
||||
parts.push(part); characters += part.length + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {})
|
||||
reader.releaseLock()
|
||||
}
|
||||
if (!disposed) { setText(parts.join(' ')); setBusy(false) }
|
||||
})().catch(() => { if (!disposed) { setError(true); setBusy(false) } }).finally(() => {
|
||||
clearTimeout(timeout)
|
||||
// PDF.js defers cleanup if a cancelled render still has pending work.
|
||||
loadedPage?.cleanup()
|
||||
})
|
||||
return () => { disposed = true; clearTimeout(timeout); render?.cancel(); cancelText?.() }
|
||||
}, [document, page, zoom, error])
|
||||
|
||||
const button = 'min-h-11 rounded border border-border px-3 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring'
|
||||
return <section className="absolute inset-0 flex flex-col" aria-label={'PDF preview: ' + fileName} data-qqq-id="file-preview-pdf">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-card p-2" role="toolbar" aria-label="PDF page controls">
|
||||
<button type="button" className={button} disabled={!document || busy || error || page <= 1} onClick={() => setPage(page - 1)} data-qqq-id="pdf-previous">Previous page</button>
|
||||
<span role="status" aria-live="polite">{document ? 'Page ' + page + ' of ' + document.numPages : 'Loading PDF'}</span>
|
||||
<button type="button" className={button} disabled={!document || busy || error || page >= document.numPages} onClick={() => setPage(page + 1)} data-qqq-id="pdf-next">Next page</button>
|
||||
<button type="button" className={button} disabled={!document || busy || error || zoom <= 0.5} onClick={() => setZoom(zoom - 0.25)} data-qqq-id="pdf-zoom-out">Zoom out</button>
|
||||
<button type="button" className={button} disabled={!document || busy || error || zoom >= 2} onClick={() => setZoom(zoom + 0.25)} data-qqq-id="pdf-zoom-in">Zoom in</button>
|
||||
</div>
|
||||
{error ? <p role="alert" className="p-4">This PDF could not be safely previewed within the preview limits. Use Download to retain the original.</p>
|
||||
: <div className="min-h-0 flex-1 overflow-auto p-3" aria-busy={busy}>
|
||||
{busy && <p role="status">Rendering PDF page…</p>}
|
||||
<canvas ref={canvas} className="mx-auto max-w-full bg-white" role="img" aria-label={fileName + ', page ' + page} />
|
||||
<details className="mt-3 rounded border border-border bg-card p-3"><summary className="cursor-pointer" data-qqq-id="pdf-page-text">Page text</summary><p className="whitespace-pre-wrap">{text || 'No extractable text on this page.'}</p></details>
|
||||
</div>}
|
||||
</section>
|
||||
}
|
||||
@@ -56,10 +56,9 @@ export function HtmlComponent({ index }: HtmlComponentProps) {
|
||||
const raw = values[`${step.name}.html`]
|
||||
const html = useMemo(() => (typeof raw === 'string' && raw ? sanitizeProcessHtml(raw) : ''), [raw])
|
||||
if (!html) return null
|
||||
// links read as links: without it they inherit the text color and have no underline
|
||||
return (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-foreground [&_a]:text-link [&_a]:underline"
|
||||
className="prose prose-sm max-w-none text-foreground"
|
||||
data-qqq-id={`process-html-${index}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { QFieldMetaData, QFrontendStepMetaData, QProcessMetaData } from '@/types'
|
||||
import { processRecords } from '@/lib/api/processes'
|
||||
import { ProcessStepScreen } from './ProcessStepScreen'
|
||||
|
||||
vi.mock('@/lib/api/processes', async (original) => ({ ...await original<typeof import('@/lib/api/processes')>(), processRecords: vi.fn() }))
|
||||
const step: QFrontendStepMetaData = {
|
||||
name: 'order', label: 'Order files', components: [{ type: 'RECORD_LIST', values: { orderFieldName: 'orderedIds', identityFieldName: 'id' } }],
|
||||
recordListFields: [{ name: 'name', label: 'File', type: 'STRING', isRequired: false, isEditable: false, isHeavy: false, isHidden: false, adornments: [] } as QFieldMetaData],
|
||||
}
|
||||
const result: QFrontendStepMetaData = { name: 'done', label: 'Done', components: [] }
|
||||
const process: QProcessMetaData = { name: 'order', label: 'Order', tableName: '', isHidden: false, iconName: '', hasPermission: true, stepFlow: 'LINEAR', minInputRecords: 1, frontendSteps: [step, result] }
|
||||
/** Render the real process screen and collect submitted contributions.
|
||||
* @returns Submit spy.
|
||||
*/
|
||||
function mount() {
|
||||
const submit = vi.fn()
|
||||
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<ProcessStepScreen processName="order" processMetaData={process} processUUID="run" step={step} steps={process.frontendSteps} values={{}}
|
||||
backStep={null} isWorking={false} onSubmit={submit} onBack={vi.fn()} onCancel={vi.fn()} onReturn={vi.fn()} />
|
||||
</QueryClientProvider>)
|
||||
return submit
|
||||
}
|
||||
beforeEach(() => { vi.resetAllMocks() })
|
||||
it('moves rows with keyboard controls and submits the complete identity order automatically', async () => {
|
||||
vi.mocked(processRecords).mockResolvedValue({ totalRecords: 2, records: [
|
||||
{ tableName: 'items', values: { id: 11, name: 'Front photo' } }, { tableName: 'items', values: { id: 22, name: 'BOL scan' } },
|
||||
] })
|
||||
const submit = mount()
|
||||
const user = userEvent.setup()
|
||||
const down = await screen.findByRole('button', { name: 'Move Front photo down' })
|
||||
expect(screen.getByRole('button', { name: 'Move Front photo up' })).toBeDisabled()
|
||||
down.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('BOL scan')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Moved to position 2 of 2')
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledWith({ orderedIds: '22,11' }, undefined))
|
||||
})
|
||||
it('blocks submit when the complete identity set cannot be loaded', async () => {
|
||||
vi.mocked(processRecords).mockResolvedValue({ totalRecords: 2, records: [{ tableName: 'items', values: { id: 1, name: 'First' } }, { tableName: 'items', values: { id: 1, name: 'Duplicate' } }] })
|
||||
const submit = mount()
|
||||
await screen.findByRole('alert')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(submit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('loads all bounded pages before enabling ordering and never silently drops missing members', async () => {
|
||||
vi.mocked(processRecords).mockResolvedValueOnce({ totalRecords: 2, records: [{ tableName: 'items', values: { id: 1, name: 'First' } }] })
|
||||
.mockResolvedValueOnce({ totalRecords: 2, records: [] })
|
||||
const submit = mount()
|
||||
await screen.findByRole('alert')
|
||||
expect(processRecords).toHaveBeenCalledTimes(2)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(submit).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Optional native record-list ordering, with bounded complete-set loading. */
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { QFrontendComponent, QRecord } from '@/types'
|
||||
import { processRecords } from '@/lib/api/processes'
|
||||
import { isRestricted } from '@/lib/utils/secured-data'
|
||||
import { recordCellText } from './RecordListComponent'
|
||||
import { useProcessStep, useSubmitContributor } from './ProcessStepContext'
|
||||
|
||||
/** Render a metadata-enabled ordering list; ordinary record lists stay unchanged.
|
||||
* @param props - Component metadata and index.
|
||||
* @returns Accessible up/down controls and submit contribution.
|
||||
*/
|
||||
export function OrderedRecordListComponent({ component, index }: { component: QFrontendComponent; index: number }) {
|
||||
const { step, processName, processUUID, tableVariant, isWorking } = useProcessStep()
|
||||
const [ordered, setOrdered] = useState<{ scope: string; records: QRecord[] } | null>(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const orderField = component.values?.orderFieldName
|
||||
const identityField = component.values?.identityFieldName
|
||||
const valid = typeof orderField === 'string' && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(orderField) &&
|
||||
typeof identityField === 'string' && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(identityField)
|
||||
const scope = JSON.stringify([processName, processUUID, step.name, tableVariant, identityField])
|
||||
const fields = (step.recordListFields ?? []).filter((field) => !field.isHidden && !isRestricted(field))
|
||||
const query = useQuery({
|
||||
queryKey: ['qqq', 'processOrderRecords', processName, processUUID, step.name, tableVariant, identityField],
|
||||
enabled: valid && Boolean(processUUID), retry: false, staleTime: Infinity, refetchOnWindowFocus: false,
|
||||
queryFn: async ({ signal }) => {
|
||||
const records: QRecord[] = []
|
||||
let total = 1
|
||||
while (records.length < total) {
|
||||
signal.throwIfAborted()
|
||||
const page = await processRecords(processName, processUUID!, records.length, 100, tableVariant)
|
||||
signal.throwIfAborted()
|
||||
if (!Number.isInteger(page.totalRecords) || page.totalRecords < 1 || page.totalRecords > 5000 ||
|
||||
(records.length > 0 && page.totalRecords !== total) || page.records.length === 0 || page.records.length > 100) {
|
||||
throw new Error('The complete ordering list is unavailable or exceeds 5,000 records.')
|
||||
}
|
||||
total = page.totalRecords
|
||||
records.push(...page.records)
|
||||
}
|
||||
const ids = records.map((record) => record.values?.[identityField as string])
|
||||
if (records.length !== total || ids.some((id) => typeof id !== 'number' || !Number.isSafeInteger(id) || id < 1) || new Set(ids).size !== total) {
|
||||
throw new Error('The ordering list contains invalid or duplicate identities.')
|
||||
}
|
||||
return records
|
||||
},
|
||||
})
|
||||
const records = ordered?.scope === scope ? ordered.records : query.data ?? []
|
||||
const ready = valid && query.isSuccess && records.length > 0 && !isWorking
|
||||
useSubmitContributor('record-order-' + index, () => ({
|
||||
maySubmit: ready,
|
||||
values: ready ? { [orderField as string]: records.map((record) => record.values?.[identityField as string]).join(',') } : undefined,
|
||||
}))
|
||||
/** Move exactly one row, preserving its stable identity and keyboard focus.
|
||||
* @param position - Current row position.
|
||||
* @param direction - Adjacent movement.
|
||||
* @returns Nothing.
|
||||
*/
|
||||
const move = (position: number, direction: -1 | 1): void => {
|
||||
const next = [...records]
|
||||
const target = position + direction
|
||||
if (!ready || target < 0 || target >= next.length) return
|
||||
;[next[position], next[target]] = [next[target], next[position]]
|
||||
setOrdered({ scope, records: next })
|
||||
setAnnouncement('Moved to position ' + (target + 1) + ' of ' + next.length + '.')
|
||||
}
|
||||
if (!valid) return <p role="alert">This ordering control is not configured correctly.</p>
|
||||
if (query.isError) return <div role="alert"><p>{query.error.message}</p><button type="button" data-qqq-id="record-order-retry" onClick={() => { setOrdered(null); void query.refetch() }}>Retry loading</button></div>
|
||||
if (!query.isSuccess) return <p role="status">Loading the complete list for ordering…</p>
|
||||
return <section aria-label="Record order" data-qqq-id="record-order">
|
||||
<p className="text-sm text-muted-foreground">Use Move up and Move down to set the order, then submit.</p>
|
||||
<p role="status" className="sr-only">{announcement}</p>
|
||||
<ol className="space-y-2">
|
||||
{records.map((record, position) => {
|
||||
const label = fields.map((field) => recordCellText(record, field)).filter((value) => value !== '—').join(' — ') || 'Record'
|
||||
return <li key={String(record.values?.[identityField as string])} className="flex flex-wrap items-center gap-3 rounded border p-3">
|
||||
<span className="min-w-0 flex-1 break-words">{position + 1}. {label}</span>
|
||||
<button type="button" className="min-h-11 rounded border px-3" disabled={!ready || position === 0} aria-label={'Move ' + label + ' up'} data-qqq-id="record-order-up" onClick={() => move(position, -1)}>Move up</button>
|
||||
<button type="button" className="min-h-11 rounded border px-3" disabled={!ready || position === records.length - 1} aria-label={'Move ' + label + ' down'} data-qqq-id="record-order-down" onClick={() => move(position, 1)}>Move down</button>
|
||||
</li>
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
@@ -36,7 +36,6 @@ import { HelpTextComponent } from './HelpTextComponent'
|
||||
import { HtmlComponent } from './HtmlComponent'
|
||||
import { ProcessSummaryResultsComponent } from './ProcessSummaryResultsComponent'
|
||||
import { RecordListComponent } from './RecordListComponent'
|
||||
import { OrderedRecordListComponent } from './OrderedRecordListComponent'
|
||||
import { ValidationReviewComponent } from './ValidationReviewComponent'
|
||||
import { ViewFormComponent } from './ViewFormComponent'
|
||||
import { WidgetComponent } from './WidgetComponent'
|
||||
@@ -58,9 +57,7 @@ export function ProcessComponent({ component, index }: ProcessComponentProps) {
|
||||
case 'HELP_TEXT': return <HelpTextComponent component={component} index={index} />
|
||||
case 'EDIT_FORM': return <EditFormComponent component={component} index={index} />
|
||||
case 'VIEW_FORM': return <ViewFormComponent index={index} />
|
||||
case 'RECORD_LIST': return component.values?.orderFieldName !== undefined
|
||||
? <OrderedRecordListComponent key={index} component={component} index={index} />
|
||||
: <RecordListComponent index={index} />
|
||||
case 'RECORD_LIST': return <RecordListComponent index={index} />
|
||||
case 'VALIDATION_REVIEW_SCREEN': return <ValidationReviewComponent index={index} />
|
||||
case 'PROCESS_SUMMARY_RESULTS': return <ProcessSummaryResultsComponent index={index} />
|
||||
case 'DOWNLOAD_FORM': return <DownloadFormComponent index={index} />
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { QFieldMetaData } from '@/types'
|
||||
import { ViewFormComponent } from './ViewFormComponent'
|
||||
import { useProcessStep } from './ProcessStepContext'
|
||||
|
||||
vi.mock('./ProcessStepContext', () => ({ useProcessStep: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
function field(name: string, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
|
||||
return { name, label: name, type: 'STRING', isRequired: false, isEditable: false, isHeavy: false, isHidden: false, adornments: [], ...extra }
|
||||
}
|
||||
|
||||
function show(fields: QFieldMetaData[], values: Record<string, unknown>) {
|
||||
vi.mocked(useProcessStep).mockReturnValue({ step: { viewFields: fields }, values, processName: 'nativeAction' } as unknown as ReturnType<typeof useProcessStep>)
|
||||
return render(<QueryClientProvider client={new QueryClient()}><ViewFormComponent index={0} /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('native process result adornments', () => {
|
||||
it('opens actual record links and hides absent navigation fields', () => {
|
||||
show([field('request', { adornments: [{ type: 'LINK', values: { toRecordFromTable: 'request' } }] }),
|
||||
field('absent', { adornments: [{ type: 'LINK', values: { toRecordFromTable: 'other' } }] }), field('state')], { request: 42, state: 'RETAINED' })
|
||||
expect(screen.getByRole('link', { name: '42' })).toHaveAttribute('href', '/app/request/42')
|
||||
expect(screen.queryByText('absent:')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('RETAINED')).toBeVisible()
|
||||
})
|
||||
|
||||
it('uses hidden companion metadata without showing capability URLs as links', () => {
|
||||
const result = show([field('file', { adornments: [{ type: 'LINK', values: { toRecordFromTableField: 'table' } }] }),
|
||||
field('table', { isHidden: true }), field('url', { adornments: [{ type: 'FILE_DOWNLOAD', values: { deliveryModeField: 'mode', fileNameField: 'name' } }] }),
|
||||
field('mode', { isHidden: true }), field('name', { isHidden: true })],
|
||||
{ file: '7', table: 'retained', url: 'https://api.example/private-transfer', mode: 'AUTHENTICATED_FETCH', name: 'Packing.pdf' })
|
||||
expect(screen.getByRole('link', { name: '7' })).toHaveAttribute('href', '/app/retained/7')
|
||||
expect(screen.getByRole('button', { name: 'Download file' })).toBeEnabled()
|
||||
expect(result.container.innerHTML).not.toContain('https://api.example/private-transfer')
|
||||
expect(screen.queryByText('mode:')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not downgrade an unknown or missing delivery mode to a link', () => {
|
||||
const result = show([field('url', { adornments: [{ type: 'FILE_DOWNLOAD', values: { deliveryModeField: 'mode' } }, { type: 'FILE_PREVIEW' }] }),
|
||||
field('mode', { isHidden: true })], { url: 'https://api.example/private-transfer', mode: 'UNKNOWN' })
|
||||
expect(result.container.querySelector('a, img, iframe, button')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,6 @@ import { fetchProcessPossibleValues } from '@/lib/api/possible-values'
|
||||
|
||||
import { useProcessStep } from './ProcessStepContext'
|
||||
import { formatProcessValue } from './process-values'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
|
||||
/** Props for {@link ViewFormComponent}. */
|
||||
export interface ViewFormComponentProps {
|
||||
@@ -49,7 +48,6 @@ interface ViewFieldScope {
|
||||
* @returns The rendered line.
|
||||
*/
|
||||
function ViewField({ field, value, scope: { processName, values } }: { field: QFieldMetaData; value: unknown; scope: ViewFieldScope }) {
|
||||
const { instance, tableMetaData } = useProcessStep()
|
||||
const hasValue = value !== null && value !== undefined && value !== ''
|
||||
const { data: label } = useQuery({
|
||||
queryKey: ['qqq', 'processViewValueLabel', processName, field.name, String(value), values],
|
||||
@@ -60,8 +58,6 @@ function ViewField({ field, value, scope: { processName, values } }: { field: QF
|
||||
const text = formatProcessValue(field, value, label ?? undefined)
|
||||
const isError = field.adornments?.some((adornment) => adornment.type === 'ERROR')
|
||||
|
||||
if (!hasValue && field.adornments?.some((adornment) => ['LINK', 'FILE_DOWNLOAD', 'FILE_PREVIEW', 'CODE_EDITOR'].includes(adornment.type))) return null
|
||||
|
||||
if (isError) {
|
||||
if (!text) return null
|
||||
return (
|
||||
@@ -71,10 +67,7 @@ function ViewField({ field, value, scope: { processName, values } }: { field: QF
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-2 py-1 text-sm" data-qqq-id={`process-view-field-${field.name}`}>
|
||||
<span className="font-semibold text-foreground">{field.label}:</span>
|
||||
<div className="min-w-0 text-foreground" data-qqq-id={`process-view-value-${field.name}`}>{field.adornments?.length || (field.possibleValueSourceName && instance?.tables?.[field.possibleValueSourceName])
|
||||
? <FieldValue field={field} record={{ tableName: tableMetaData?.name ?? '', values,
|
||||
displayValues: label ? { [field.name]: label } : undefined }} allTables={instance?.tables} tableMetaData={tableMetaData} />
|
||||
: text || <span className="text-muted-foreground">—</span>}</div>
|
||||
<span className="text-foreground" data-qqq-id={`process-view-value-${field.name}`}>{text || <span className="text-muted-foreground">—</span>}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function NamedWidget({ widget, seeded, index }: { widget: QWidgetMetaData; seede
|
||||
produced.current = type === 'childRecordList' ? { frontendRecords: frontendRecordsJson(data) } : { ...(produced.current ?? {}), ...data }
|
||||
}, [type])
|
||||
|
||||
useSubmitContributor(`widget-${index}-${widget.name}`, () => ({ maySubmit: type !== 'AUTHENTIK_ACCESS_EDITOR' || produced.current?.__accessEditorReady === true, values: produced.current ?? undefined }))
|
||||
useSubmitContributor(`widget-${index}-${widget.name}`, () => ({ maySubmit: true, values: produced.current ?? undefined }))
|
||||
|
||||
return (
|
||||
<div data-qqq-id={`process-widget-${widget.name}`}>
|
||||
|
||||
@@ -205,24 +205,6 @@ describe('ProcessSummaryLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTML component', () => {
|
||||
it('shows a link the backend put on the last screen as a link that opens in this tab', () => {
|
||||
const address = 'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&state=abc.def'
|
||||
const step: QFrontendStepMetaData = { name: 'result', label: 'Continue to Slack', components: [{ type: 'HTML' }] }
|
||||
renderStep(step, {
|
||||
noMoreSteps: true,
|
||||
'result.html': `<p>Slack will ask you to approve the connection.</p><p><a href="${address.replace(/&/g, '&')}" style="display:inline-block;padding:8px 16px;background:#4A154B;color:#ffffff">Continue to Slack</a></p><p>This link expires in 10 minutes.</p>`,
|
||||
})
|
||||
const link = screen.getByRole('link', { name: 'Continue to Slack' })
|
||||
expect(link).toHaveAttribute('href', address)
|
||||
expect(link).not.toHaveAttribute('target')
|
||||
expect(link.getAttribute('style')).toContain('background')
|
||||
expect(link.closest('[data-qqq-id="process-html-0"]')).toHaveClass('[&_a]:text-link', '[&_a]:underline')
|
||||
expect(screen.getByText('This link expires in 10 minutes.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Return' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('process helpers', () => {
|
||||
it('sanitizes HTML but keeps CSV and text template downloads', () => {
|
||||
const html = sanitizeProcessHtml('<a href="data:text/csv;base64,QQ==" download="t.csv">t</a><a href="data:text/html,<b>x</b>">h</a><img src="x" onerror="alert(1)"><script>alert(1)</script>')
|
||||
|
||||
@@ -95,20 +95,6 @@ function instanceWith(...widgets: QWidgetMetaData[]): QInstance {
|
||||
describe('process screens: fields and chrome (#725)', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('submits TypeScript source unchanged through a native EDIT_FORM CODE_EDITOR field', async () => {
|
||||
const source = ' export async function main(): Promise<string> {\n return "日本語";\n}\n\n'
|
||||
const step: QFrontendStepMetaData = {
|
||||
name: 'source', label: 'Source', components: [{ type: 'EDIT_FORM' }],
|
||||
formFields: [field('source', 'TypeScript source', {
|
||||
type: 'TEXT', adornments: [{ type: 'CODE_EDITOR', values: { languageMode: 'typescript' } }],
|
||||
})],
|
||||
}
|
||||
const { onSubmit } = renderStep(step, { source: '// initial\n' })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'TypeScript source' }), { target: { value: source } })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ source }, undefined))
|
||||
})
|
||||
|
||||
it('adds inputFieldList fields to a screen with an edit form, searching their source, and submits them', async () => {
|
||||
const user = userEvent.setup()
|
||||
const step: QFrontendStepMetaData = { name: 'input', label: 'Input', components: [{ type: 'EDIT_FORM' }], formFields: [field('note', 'Note')] }
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { QTableMetaData } from '@/types'
|
||||
import { useRecordQuery } from '@/lib/hooks/use-record-query'
|
||||
import { authorizationFilteredPagination, paginationSourceTables } from '@/lib/utils/query-pagination'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import { RecordQueryContent } from './RecordQueryContent'
|
||||
|
||||
let client: QueryClient
|
||||
let table: QTableMetaData
|
||||
let sources: Record<string, QTableMetaData>
|
||||
|
||||
function Screen() {
|
||||
const rq = useRecordQuery({ tableName: table.name, tableMetaData: table, allTables: { [table.name]: table } })
|
||||
return <RecordQueryContent
|
||||
allTables={sources}
|
||||
tableName={table.name} tableMetaData={table} viewMode="card" activeFilterCount={0}
|
||||
{...rq.data} {...rq.pagination} {...rq.columns}
|
||||
sortOrder={rq.filter.sortOrder} onSortChange={rq.filter.setSort}
|
||||
onResetFilter={rq.filter.resetFilter} quickSearchTerm={rq.filter.quickSearchTerm}
|
||||
rowSelection={rq.selection.rowSelection} onRowSelectionChange={rq.selection.setRowSelection}
|
||||
onColumnWidthChange={rq.columns.setColumnWidth} density={rq.density}
|
||||
onPageChange={rq.pagination.setPage} onPageSizeChange={rq.pagination.setPageSize}
|
||||
/>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
table = { ...qInstance.tables.person, capabilities: ['TABLE_QUERY', 'TABLE_COUNT'], supplementalMetaData: {
|
||||
queryPagination: { authorizationFiltered: true },
|
||||
} }
|
||||
sources = { archive: { ...qInstance.tables.person, name: 'record_files_archive_source', label: 'Retained Source Archives', readPermission: true, isHidden: false,
|
||||
supplementalMetaData: { queryPagination: { sourceBrowser: true } } } }
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => { cleanup(); client.clear(); vi.restoreAllMocks() })
|
||||
|
||||
it('requests one later page after an empty authorized page, permits back navigation, and never counts', async () => {
|
||||
const offsets: number[] = []
|
||||
let countCalls = 0
|
||||
let revoked = false
|
||||
server.use(
|
||||
http.post('*/qqq/v1/table/person/query', async ({ request }) => {
|
||||
const body = await request.json() as { filter: { skip?: number; limit: number } }
|
||||
const skip = body.filter.skip ?? 0
|
||||
offsets.push(skip)
|
||||
expect(body.filter.limit).toBe(50)
|
||||
return HttpResponse.json({ records: skip === 50 && !revoked ? [{ tableName: 'person', recordLabel: 'Visible later', values: { id: 99, firstName: 'Visible later' } }] : [] })
|
||||
}),
|
||||
http.post('*/qqq/v1/table/person/count', () => { countCalls++; return HttpResponse.json({ count: 999 }) }),
|
||||
)
|
||||
render(<QueryClientProvider client={client}><Screen /></QueryClientProvider>)
|
||||
expect(await screen.findByText('No authorized records on this page')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled()
|
||||
expect(screen.getByText('0 visible records on page 1')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Retained Source Archives' })).toHaveAttribute('href', '/app/record_files_archive_source')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
|
||||
await waitFor(() => expect(screen.getByText('1 visible records on page 2')).toBeInTheDocument())
|
||||
expect(offsets).toEqual([0, 50])
|
||||
expect(countCalls).toBe(0)
|
||||
revoked = true
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous page' }))
|
||||
await waitFor(() => expect(screen.getByText('0 visible records on page 1')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
|
||||
await waitFor(() => expect(screen.getByText('0 visible records on page 2')).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled()
|
||||
expect(countCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves ordinary empty tables and rejects a truthy string opt-in', async () => {
|
||||
table = { ...table, supplementalMetaData: { queryPagination: { authorizationFiltered: 'true' } } }
|
||||
server.use(http.post('*/qqq/v1/table/person/query', () => HttpResponse.json({ records: [] })),
|
||||
http.post('*/qqq/v1/table/person/count', () => HttpResponse.json({ count: 0 })))
|
||||
render(<QueryClientProvider client={client}><Screen /></QueryClientProvider>)
|
||||
expect(await screen.findByText('No records found')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Next page' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Completeness is unknown/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not turn malformed navigation metadata into arbitrary links', () => {
|
||||
sources.denied = { ...sources.archive, name: 'denied', readPermission: false }
|
||||
sources.hidden = { ...sources.archive, name: 'hidden', isHidden: true }
|
||||
sources.bad = { ...sources.archive, name: '../secret' }
|
||||
sources.missing = { ...sources.archive, name: undefined } as unknown as QTableMetaData
|
||||
sources.nullName = { ...sources.archive, name: null } as unknown as QTableMetaData
|
||||
sources.truthyRead = { ...sources.archive, readPermission: 'true' } as unknown as QTableMetaData
|
||||
sources.truthySource = { ...sources.archive, supplementalMetaData: { queryPagination: { sourceBrowser: 'true' } } }
|
||||
expect(paginationSourceTables(sources)).toEqual([sources.archive])
|
||||
expect(authorizationFilteredPagination(undefined)).toBe(false)
|
||||
})
|
||||
@@ -31,8 +31,6 @@ import { PAGE_SIZE_OPTIONS, type PageSize } from '@/lib/hooks/use-record-query'
|
||||
* Props for the Pagination component.
|
||||
*/
|
||||
interface PaginationProps {
|
||||
/** Parent authorization filters pages after selection, so completeness is unknown. */
|
||||
authorizationFiltered?: boolean
|
||||
/** Current 1-based page number. */
|
||||
pageNum: number
|
||||
/** Number of records displayed per page (constrained to the PageSize union type). */
|
||||
@@ -68,7 +66,6 @@ interface PaginationProps {
|
||||
* @returns The rendered pagination controls.
|
||||
*/
|
||||
export function Pagination({
|
||||
authorizationFiltered = false,
|
||||
pageNum,
|
||||
pageSize,
|
||||
totalCount,
|
||||
@@ -124,9 +121,7 @@ export function Pagination({
|
||||
{/* Left: record count summary */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<span aria-live="polite" aria-atomic="true" data-qqq-id="pagination-summary">
|
||||
{authorizationFiltered ? (
|
||||
`${pageRowCount.toLocaleString()} visible records on page ${pageNum.toLocaleString()}`
|
||||
) : isCounting && !uncounted ? (
|
||||
{isCounting && !uncounted ? (
|
||||
'Counting...'
|
||||
) : shown === 0 ? (
|
||||
'No records'
|
||||
|
||||
@@ -211,20 +211,19 @@ describe('RecordQuery joined read permissions', () => {
|
||||
render(<RecordQuery {...options} />, { wrapper: createWrapper() })
|
||||
expect(await screen.findByText('Alice')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
|
||||
const filterMode = within(screen.getByRole('group', { name: 'Filter mode' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'First Name' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Filter value for First Name' }), 'Bob')
|
||||
await user.click(screen.getByRole('button', { name: 'Apply quick filter' }))
|
||||
await waitFor(() => expect(requests.at(-1)?.body.filter.criteria?.length).toBe(1))
|
||||
await user.selectOptions(filterMode.getByRole('combobox', { name: 'Sort field' }), 'lastName')
|
||||
await user.click(filterMode.getByRole('button', { name: 'Clear all filters' }))
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sort field' }), 'lastName')
|
||||
await user.click(screen.getByRole('button', { name: 'Clear all filters' }))
|
||||
expect(screen.getByRole('alertdialog')).toBeVisible()
|
||||
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(requests.at(-1)?.body.filter.criteria?.length).toBe(1)
|
||||
|
||||
await user.click(filterMode.getByRole('button', { name: 'Clear all filters' }))
|
||||
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Clear filters' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Clear all filters' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Clear filters' }))
|
||||
await waitFor(() => expect(requests.filter(({ action }) => action === 'query').at(-1)?.body.filter.criteria?.length).toBe(0))
|
||||
expect(requests.filter(({ action }) => action === 'query').at(-1)?.body.filter.orderBys).toEqual([{ fieldName: 'lastName', isAscending: false }])
|
||||
})
|
||||
@@ -239,12 +238,11 @@ describe('RecordQuery joined read permissions', () => {
|
||||
render(<RecordQuery {...options} />, { wrapper: createWrapper() })
|
||||
expect(await screen.findByText('Alice')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
|
||||
const filterMode = within(screen.getByRole('group', { name: 'Filter mode' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'First Name' }))
|
||||
await user.type(screen.getByRole('textbox', { name: 'Filter value for First Name' }), 'Bob')
|
||||
await user.click(screen.getByRole('button', { name: 'Apply quick filter' }))
|
||||
await user.click(filterMode.getByRole('button', { name: 'Advanced' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Advanced' }))
|
||||
expect(screen.queryByLabelText('Advanced filter preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Clear all filters' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Clear all' })).toBeVisible()
|
||||
|
||||
@@ -876,7 +876,6 @@ export function RecordQuery({ tableName, tableMetaData: sourceTableMetaData, all
|
||||
</div>
|
||||
) : (
|
||||
<RecordQueryContent
|
||||
allTables={allTables}
|
||||
tableName={tableName}
|
||||
tableMetaData={tableMetaData}
|
||||
viewMode={viewMode}
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authorizationFilteredPagination, paginationSourceTables } from '@/lib/utils/query-pagination'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
@@ -53,8 +51,6 @@ export interface RecordQueryContentProps {
|
||||
tableName: string
|
||||
/** Full table metadata from the QQQ backend. */
|
||||
tableMetaData: QTableMetaData
|
||||
/** Current authorized metadata, used only for source navigation hints. */
|
||||
allTables?: Record<string, QTableMetaData>
|
||||
/** Currently active view mode (grid or card). */
|
||||
viewMode: ViewMode
|
||||
/** Number of active filter criteria (used by the empty-state clear action). */
|
||||
@@ -143,7 +139,6 @@ export interface RecordQueryContentProps {
|
||||
export function RecordQueryContent({
|
||||
tableName,
|
||||
tableMetaData,
|
||||
allTables = {},
|
||||
viewMode,
|
||||
activeFilterCount,
|
||||
records,
|
||||
@@ -179,25 +174,11 @@ export function RecordQueryContent({
|
||||
}: RecordQueryContentProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const pagePolicy = authorizationFilteredPagination(tableMetaData)
|
||||
const sourceTables = paginationSourceTables(allTables)
|
||||
// Material's grid alert can be closed; a new error shows again
|
||||
const [dismissedError, setDismissedError] = useState<Error | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{pagePolicy && (
|
||||
<div className="rounded border border-border p-3 text-sm text-muted-foreground" data-qqq-id="query-page-completeness">
|
||||
<p>Completeness is unknown. Each page shows only records you can access. Later pages may contain records even when this page is empty. Next requests one page and remains available because the end cannot be determined.</p>
|
||||
{sourceTables.length > 0 && (
|
||||
<p className="mt-2">To narrow by source, choose a record and open its Files tab:{' '}
|
||||
{sourceTables.map((source) => (
|
||||
<Link key={source.name} href={`/app/${encodeURIComponent(source.name)}/`} className="mr-3 inline-flex min-h-[44px] items-center underline">{source.label || source.name}</Link>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* ============================================================
|
||||
Error state
|
||||
============================================================ */}
|
||||
@@ -254,7 +235,7 @@ export function RecordQueryContent({
|
||||
{/* ============================================================
|
||||
Data Grid / Card View
|
||||
============================================================ */}
|
||||
{(isLoading || records.length > 0 || (pagePolicy && !isError)) && (
|
||||
{(isLoading || records.length > 0) && (
|
||||
<ErrorBoundary className="rounded-xl" resetLabel="Reset saved query and reload" onReset={() => {
|
||||
clearStoredQueryState(tableName)
|
||||
if (window.location.pathname.includes('/savedView/')) router.replace(`/app/${encodeURIComponent(tableName)}/`)
|
||||
@@ -310,7 +291,6 @@ export function RecordQueryContent({
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
authorizationFiltered={pagePolicy}
|
||||
pageNum={pageNum}
|
||||
pageSize={pageSize}
|
||||
totalCount={totalCount}
|
||||
@@ -341,7 +321,6 @@ export function RecordQueryContent({
|
||||
* @returns EmptyState title, description and width.
|
||||
*/
|
||||
export function emptyStateText(tableMetaData: QTableMetaData, filtered: boolean): { title: string; description: string; wideDescription?: boolean } {
|
||||
if (authorizationFilteredPagination(tableMetaData)) return { title: 'No authorized records on this page', description: 'Later pages may contain records. Use Next or narrow your search to a source record.' }
|
||||
const help = filtered ? undefined : selectSlotHelp(tableMetaData.helpContents, 'help', QUERY_SCREEN_HELP_ROLES)
|
||||
const text = help ? helpPlainText(help) : ''
|
||||
if (!text) return { title: 'No records found', description: 'Try adjusting your filters or search term.' }
|
||||
|
||||
@@ -331,7 +331,7 @@ describe('AssociatedScriptViewer', () => {
|
||||
await user.type(input, 'Ada')
|
||||
await user.click(byQqqId(`button-test-script-${FIELD}`)!)
|
||||
|
||||
expect(testScript).toHaveBeenCalledWith({ scriptId: 101, files: { 'Script.js': "return 'Hello, friend';" }, inputValues: { name: 'Ada' } }, { signal: expect.any(AbortSignal) })
|
||||
expect(testScript).toHaveBeenCalledWith({ scriptId: 101, files: { 'Script.js': "return 'Hello, friend';" }, inputValues: { name: 'Ada' } })
|
||||
const output = byQqqId(`script-test-output-${FIELD}`)!
|
||||
await waitFor(() => expect(byQqqId(`script-test-output-${FIELD}-greeting`)).toHaveTextContent('Greeting: Hello, Ada! (22 characters)'))
|
||||
expect(within(output).queryByRole('alert')).toBeNull()
|
||||
|
||||
@@ -15,14 +15,11 @@
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FieldAdornment, QFieldMetaData, QRecord, QWidgetMetaData } from '@/types'
|
||||
import { FieldValue } from './FieldValue'
|
||||
import * as attachmentDelivery from '@/lib/api/attachment-download'
|
||||
|
||||
vi.mock('@/lib/api/attachment-download', () => ({ fetchTrackedAttachment: vi.fn(), safeAttachmentPreviewBlob: vi.fn(), saveAttachmentDownload: vi.fn() }))
|
||||
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
@@ -82,40 +79,6 @@ describe('FieldValue adornments use the backend value keys', () => {
|
||||
expect(document.querySelector('[data-qqq-id="field-value-doc-download"]')).toHaveAttribute('href', '/qqq/v1/table/lab/1/doc/a.txt?download=1')
|
||||
})
|
||||
|
||||
it.each(['preview', 'download'] as const)('uses the verified delivery gate for native attachment %s metadata', async (kind) => {
|
||||
const url = 'https://api.example/qqq/v1/qbit/record-files/files/7/downloads/00000000-0000-4000-8000-000000000001'
|
||||
const name = `${kind}Url`
|
||||
const adornments: FieldAdornment[] = [{ type: 'FILE_DOWNLOAD', values: { fileNameField: 'fileName', deliveryModeField: `${kind}Delivery`, ...(kind === 'download' ? { downloadUrlDynamic: true } : {}) } }]
|
||||
if (kind === 'preview') adornments.push({ type: 'FILE_PREVIEW', values: { fileNameField: 'fileName', mimeTypeField: 'contentType', sizeBytesField: 'sizeBytes', thumbnailMaxSize: 48 } })
|
||||
vi.mocked(attachmentDelivery.fetchTrackedAttachment).mockRejectedValue(new Error('Receipt unavailable'))
|
||||
vi.mocked(attachmentDelivery.saveAttachmentDownload).mockRejectedValue(new Error('Current access revoked'))
|
||||
const value = show(field(name, adornments), { [name]: url, [`${kind}Delivery`]: 'AUTHENTICATED_FETCH', fileName: 'Retained.pdf', contentType: 'application/pdf', sizeBytes: 13 },
|
||||
kind === 'download' ? { downloadUrl: 'Retained.pdf', 'downloadUrl:downloadUrlDynamic': url } : {})
|
||||
expect(value.querySelector('a,img,iframe')).toBeNull()
|
||||
expect(value.innerHTML).not.toContain(url)
|
||||
if (kind === 'preview') {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Receipt unavailable'))
|
||||
expect(attachmentDelivery.fetchTrackedAttachment).toHaveBeenCalledWith(url, expect.any(AbortSignal))
|
||||
expect(value.querySelector('a,img,iframe')).toBeNull()
|
||||
} else {
|
||||
expect(screen.queryByRole('button', { name: 'Preview file' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download file' }))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Current access revoked'))
|
||||
expect(attachmentDelivery.saveAttachmentDownload).toHaveBeenCalledWith({ url, delivery: 'AUTHENTICATED_FETCH' }, 'Retained.pdf', expect.any(AbortSignal))
|
||||
expect(value.querySelector('a')).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native signed downloads compatible and refuses a missing declared delivery mode', () => {
|
||||
const nativeField = field('downloadUrl', [{ type: 'FILE_DOWNLOAD', values: { fileNameField: 'fileName', deliveryModeField: 'downloadDelivery', downloadUrlDynamic: true } }])
|
||||
const url = 'https://storage.example/immutable'
|
||||
const value = show(nativeField, { downloadUrl: url, fileName: 'Original.pdf', downloadDelivery: 'SIGNED_URL' }, { 'downloadUrl:downloadUrlDynamic': url })
|
||||
expect(value.querySelector('a')).toHaveAttribute('href', url)
|
||||
const missing = show(field('missingUrl', [{ type: 'FILE_DOWNLOAD', values: { deliveryModeField: 'missingDelivery' } }]), { missingUrl: url })
|
||||
expect(missing.querySelector('a,button,img,iframe')).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals REVEAL values on request', () => {
|
||||
const secret = show(field('token', [{ type: 'REVEAL' }], { label: 'Token' }), { token: 'abc' })
|
||||
expect(secret).toHaveTextContent('••••••••')
|
||||
|
||||
@@ -47,7 +47,6 @@ import { HighlightedCode } from '@/components/scripts/HighlightedCode'
|
||||
import { SeededWidget } from '@/components/widgets/ConnectedWidget'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { FileThumbnail } from '@/components/preview/FileThumbnail'
|
||||
import { DeliveredFileActions } from '@/components/preview/DeliveredFileActions'
|
||||
import { RecordHoverCard } from './RecordHoverCard'
|
||||
|
||||
/**
|
||||
@@ -160,13 +159,6 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
|
||||
)
|
||||
}
|
||||
|
||||
const deliveryAdornment = findAdornment(field, 'FILE_DOWNLOAD')
|
||||
if (deliveryAdornment?.values?.deliveryModeField !== undefined) {
|
||||
const delivery = fileDownload(field, record)
|
||||
if (!delivery) return <EmptyValue fieldName={field.name} className={className} />
|
||||
if (delivery.delivery === 'AUTHENTICATED_FETCH') return <DeliveredFileActions key={delivery.url} url={delivery.url} fileName={delivery.fileName}
|
||||
allowPreview={!!findAdornment(field, 'FILE_PREVIEW')} dataQqqId={dataQqqId} />
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -567,11 +567,8 @@ function RecordViewContent({
|
||||
)
|
||||
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(orderedSections.flatMap((section) => section.fieldNames ?? []))]
|
||||
.map((name) => recordSectionField(tableMetaData, allTables, name))
|
||||
.filter((field): field is QFieldMetaData => Boolean(field && !field.isHidden))
|
||||
// Delivery-aware fields use their own verified transfer controls, never a raw gallery URL.
|
||||
.filter((field) => !field.adornments?.some((adornment) => adornment.type === 'FILE_DOWNLOAD' && adornment.values?.deliveryModeField !== undefined))
|
||||
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 (
|
||||
|
||||
@@ -38,18 +38,6 @@ const record = { tableName: 'lab', values: { id: 1, title: 'Visible title' } } a
|
||||
afterEach(() => window.localStorage.clear())
|
||||
|
||||
describe('RecordViewSection widget headings', () => {
|
||||
it('renders native virtual values while excluding hidden and absent fields', () => {
|
||||
const metadata = { ...table, virtualFields: {
|
||||
previewUrl: { name: 'previewUrl', label: 'Preview', type: 'STRING', isHidden: false },
|
||||
hiddenUrl: { name: 'hiddenUrl', label: 'Hidden', type: 'STRING', isHidden: true },
|
||||
} } as unknown as QTableMetaData
|
||||
const section = { name: 'file', label: 'File', fieldNames: ['previewUrl', 'hiddenUrl', 'absent'], isHidden: false } as QTableSection
|
||||
render(<RecordViewSection section={section} tableMetaData={metadata} record={{ ...record, values: { previewUrl: 'visible virtual value', hiddenUrl: 'must stay hidden' } }} />)
|
||||
expect(screen.getByText('visible virtual value')).toBeVisible()
|
||||
expect(screen.queryByText('must stay hidden')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('undefined')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the heading to the widget card when the card has the same label', () => {
|
||||
const section = { name: 'progress', label: 'Live Progress', fieldNames: [], widgetName: 'progressWidget', isHidden: false } as QTableSection
|
||||
const widget = { name: 'progressWidget', label: 'Live Progress', hasPermission: true } as QWidgetMetaData
|
||||
|
||||
@@ -39,7 +39,7 @@ import React, { useCallback, useLayoutEffect, useRef, useState, useSyncExternalS
|
||||
|
||||
import type { CodeLanguage } from '@/lib/utils/code-highlight'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { findCodeCompletions, type CodeCompletion, type CodeCompletionMatch } from '@/lib/utils/code-completions'
|
||||
import { findCodeCompletions, type CodeCompletionMatch } from '@/lib/utils/code-completions'
|
||||
import { HighlightedCode } from './HighlightedCode'
|
||||
|
||||
/** The text a Tab inserts. */
|
||||
@@ -101,8 +101,6 @@ export interface CodeEditorProps {
|
||||
language: CodeLanguage
|
||||
/** Enable local code suggestions for script files. */
|
||||
autocomplete?: boolean
|
||||
/** Runtime-specific helper suggestions; omitted for the existing trusted API. */
|
||||
apiCompletions?: readonly CodeCompletion[]
|
||||
/** Read-only: selectable and focusable, never edited, never captures Tab. */
|
||||
readOnly?: boolean
|
||||
/** Visible rows. Defaults to 12. */
|
||||
@@ -165,7 +163,6 @@ export function CodeEditor({
|
||||
language,
|
||||
readOnly = false,
|
||||
autocomplete = false,
|
||||
apiCompletions,
|
||||
rows = 12,
|
||||
ariaLabel,
|
||||
describedBy,
|
||||
@@ -203,7 +200,7 @@ export function CodeEditor({
|
||||
setCompletion(null)
|
||||
return
|
||||
}
|
||||
const match = findCodeCompletions(textarea.value, textarea.selectionStart, language, explicit, apiCompletions)
|
||||
const match = findCodeCompletions(textarea.value, textarea.selectionStart, language, explicit)
|
||||
if (!match) { setCompletion(null); return }
|
||||
const before = textarea.value.slice(0, textarea.selectionStart).split('\n')
|
||||
const metrics = metricsRef.current?.getBoundingClientRect()
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Creation stays separate from legacy workflows and avoids ambiguous retry. */
|
||||
|
||||
import React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { createScriptProject } from '@/lib/api/scripting'
|
||||
import { CreateIsolatedScript } from './CreateIsolatedScript'
|
||||
|
||||
vi.mock('@/lib/api/scripting', () => ({ createScriptProject: vi.fn() }))
|
||||
const discovery = vi.hoisted(() => ({ installed: true }))
|
||||
vi.mock('@/lib/api/ui-api-extensions', () => ({ useUiApiExtensionInstalled: () => discovery.installed }))
|
||||
vi.mock('./IsolatedScriptEditor', () => ({ IsolatedScriptEditor: ({ scriptId }: { scriptId: number }) => <div>Isolated project {scriptId}</div> }))
|
||||
|
||||
/** Mount a fresh query cache. */
|
||||
function mount() {
|
||||
return render(<QueryClientProvider client={new QueryClient()}><CreateIsolatedScript /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('isolated project creation', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); discovery.installed = true })
|
||||
it('opens the isolated editor only after successful semantic creation', async () => {
|
||||
vi.mocked(createScriptProject).mockResolvedValue(17)
|
||||
mount()
|
||||
fireEvent.change(screen.getByLabelText('Project name'), { target: { value: 'Receiving context' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create TypeScript project' }))
|
||||
await screen.findByText('Isolated project 17')
|
||||
expect(createScriptProject).toHaveBeenCalledWith('Receiving context')
|
||||
})
|
||||
it('blocks another creation after an uncertain transport result', async () => {
|
||||
vi.mocked(createScriptProject).mockRejectedValue(new AxiosError('timeout', 'ECONNABORTED'))
|
||||
mount()
|
||||
fireEvent.change(screen.getByLabelText('Project name'), { target: { value: 'Receiving context' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create TypeScript project' }))
|
||||
await screen.findByRole('status')
|
||||
expect(screen.getByRole('button', { name: 'Create TypeScript project' })).toBeDisabled()
|
||||
expect(createScriptProject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('does not offer source intake without the scripting extension', () => {
|
||||
discovery.installed = false
|
||||
mount()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Explicit isolated project creation through the semantic scripting API. */
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useId, useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { createScriptProject } from '@/lib/api/scripting'
|
||||
import { useUiApiExtensionInstalled } from '@/lib/api/ui-api-extensions'
|
||||
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { IsolatedScriptEditor } from './IsolatedScriptEditor'
|
||||
import { ALERT_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS } from './ScriptPanels'
|
||||
|
||||
/**
|
||||
* Create a separate source project and immediately open the shared IDE.
|
||||
* @returns Extension-gated creation panel or the newly created project editor.
|
||||
*/
|
||||
export function CreateIsolatedScript() {
|
||||
const installed = useUiApiExtensionInstalled('scripting')
|
||||
const inputId = useId()
|
||||
const [name, setName] = useState('')
|
||||
const [uncertain, setUncertain] = useState(false)
|
||||
const create = useMutation({
|
||||
mutationFn: () => createScriptProject(name), retry: false, meta: HANDLES_OWN_ERRORS,
|
||||
onError: (error) => { if (isAxiosError(error) && !error.response) setUncertain(true) },
|
||||
})
|
||||
if (!installed) return null
|
||||
if (create.data) return <IsolatedScriptEditor scriptId={create.data} label={name.trim()} />
|
||||
return <section aria-label="Create TypeScript project" className="space-y-3 rounded-xl border border-border bg-card p-6">
|
||||
<h2 className="text-base font-semibold">New TypeScript project</h2>
|
||||
<p className="text-sm text-muted-foreground">Create a source project, then save, build, test and publish each revision explicitly.</p>
|
||||
<form className="flex flex-wrap items-end gap-3" onSubmit={(event) => { event.preventDefault(); if (!uncertain && !create.isPending && name.trim()) create.mutate() }}>
|
||||
<div><label htmlFor={inputId} className="block text-sm">Project name</label><input id={inputId} className={INPUT_CLASS} required maxLength={200} value={name} disabled={uncertain || create.isPending} onChange={(event) => setName(event.target.value)} /></div>
|
||||
<button type="submit" className={PRIMARY_BUTTON_CLASS} disabled={!name.trim() || uncertain || create.isPending}>{create.isPending ? 'Creating…' : 'Create TypeScript project'}</button>
|
||||
</form>
|
||||
{uncertain ? <p role="status">The server may have created this project. Check the Scripts table before trying again.</p> : create.error && <p role="alert" className={ALERT_CLASS}>{getErrorMessage(create.error)}</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Isolated source workflow tests; no real compiler or legacy script process. */
|
||||
|
||||
import React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import * as api from '@/lib/api/scripting'
|
||||
import { IsolatedScriptEditor } from './IsolatedScriptEditor'
|
||||
|
||||
vi.mock('@/lib/api/scripting', async (original) => {
|
||||
const actual = await original<typeof import('@/lib/api/scripting')>()
|
||||
return { ...actual, getScriptProject: vi.fn(), getScriptDraft: vi.fn(), saveScriptDraft: vi.fn(),
|
||||
compileScriptRevision: vi.fn(), testScriptContext: vi.fn(), publishScriptBuild: vi.fn(), getScriptApiTypes: vi.fn(), importScriptDraft: vi.fn() }
|
||||
})
|
||||
const discovery = vi.hoisted(() => ({ installed: true }))
|
||||
vi.mock('@/lib/api/ui-api-extensions', () => ({ useUiApiExtensionInstalled: () => discovery.installed }))
|
||||
vi.mock('./TypeScriptSourceEditor', async () => {
|
||||
const { CodeEditor } = await import('./CodeEditor')
|
||||
return { TypeScriptSourceEditor: (props: import('./TypeScriptSourceEditor').TypeScriptSourceEditorProps) => <CodeEditor
|
||||
id="fixture-source" ariaLabel={`TypeScript source: ${props.active}`} language="typescript" autocomplete
|
||||
apiCompletions={[{ text: 'qqq.query(', description: 'Authorized reads' }]}
|
||||
value={props.files.find((file) => file.path === props.active)!.content} readOnly={props.readOnly} onChange={props.onChange} /> }
|
||||
})
|
||||
|
||||
const PROJECT: api.ScriptProject = {
|
||||
scriptId: 7, executionMode: 'ISOLATED', latestRevisionId: 10, revisions: [{ id: 10, sequence: 1, message: 'Initial', author: 'alice' }],
|
||||
builds: [], publications: [], actions: ['draft', 'check', 'test', 'publish'], workerAvailable: true, importAvailable: false,
|
||||
}
|
||||
|
||||
/** Mount with an isolated query cache for every editor test. */
|
||||
function mount() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><IsolatedScriptEditor scriptId={7} label="Context script" /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('isolated source editor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
discovery.installed = true
|
||||
vi.mocked(api.getScriptProject).mockResolvedValue(PROJECT)
|
||||
vi.mocked(api.getScriptDraft).mockResolvedValue({ revisionId: 10, sourceSha256: 'a'.repeat(64), files: [{ path: 'src/index.ts', content: 'export async function main() { return {}; }' }] })
|
||||
vi.mocked(api.compileScriptRevision).mockResolvedValue({ buildId: 20, success: true, artifactId: 30, artifactVersion: 1, diagnostics: [] })
|
||||
vi.mocked(api.testScriptContext).mockResolvedValue({ executionId: 40, status: 'OK', message: null, contextJson: '{"hello":"world"}' })
|
||||
vi.mocked(api.publishScriptBuild).mockResolvedValue(50)
|
||||
})
|
||||
|
||||
it('keeps build, context test and publication distinct, then invalidates the build on edits', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount()
|
||||
await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
|
||||
expect(screen.getByRole('button', { name: 'Test context' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Publish tested build' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Build saved revision' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Test context' })).toBeEnabled())
|
||||
expect(api.compileScriptRevision).toHaveBeenCalledWith(7, 10, 'src/index.ts', [], expect.any(AbortSignal))
|
||||
expect(api.publishScriptBuild).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'Test context' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Publish tested build' })).toBeEnabled())
|
||||
await user.click(screen.getByRole('button', { name: 'Publish tested build' }))
|
||||
await screen.findByText('Published immutable version 50.')
|
||||
expect(api.publishScriptBuild).toHaveBeenCalledWith(7, 20, '', expect.any(AbortSignal))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'TypeScript source: src/index.ts' }), { target: { value: 'export const changed = true;' } })
|
||||
expect(screen.getByRole('button', { name: 'Build saved revision' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Publish tested build' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Save draft' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('saves the observed base revision and never invokes publication automatically', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.saveScriptDraft).mockRejectedValue(new Error('Source changed; reload'))
|
||||
mount()
|
||||
const editor = await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
|
||||
fireEvent.change(editor, { target: { value: 'new source' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Save draft' }))
|
||||
await screen.findByText('Source changed; reload')
|
||||
expect(api.saveScriptDraft).toHaveBeenCalledWith(7, 10, [{ path: 'src/index.ts', content: 'new source' }], '', expect.any(AbortSignal))
|
||||
expect(api.compileScriptRevision).not.toHaveBeenCalled()
|
||||
expect(api.publishScriptBuild).not.toHaveBeenCalled()
|
||||
expect(editor).toHaveValue('new source')
|
||||
})
|
||||
|
||||
it('fails closed on missing extension and disables unavailable workers and unauthorized actions', async () => {
|
||||
discovery.installed = false
|
||||
const first = mount()
|
||||
expect(screen.getByText(/requires the scripting API extension/)).toBeVisible()
|
||||
expect(api.getScriptProject).not.toHaveBeenCalled()
|
||||
first.unmount()
|
||||
discovery.installed = true
|
||||
vi.mocked(api.getScriptProject).mockResolvedValue({ ...PROJECT, workerAvailable: false, actions: [] })
|
||||
mount()
|
||||
expect(await screen.findByRole('button', { name: 'Build saved revision' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Save draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('textbox', { name: 'TypeScript source: src/index.ts' })).toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('suggests the isolated read broker without offering legacy write helpers', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount()
|
||||
const editor = await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
|
||||
fireEvent.change(editor, { target: { value: 'qqq.qu' } })
|
||||
await user.click(editor)
|
||||
await user.keyboard('{End}{Control>} {/Control}')
|
||||
await waitFor(() => expect(screen.getByRole('option', { name: /qqq.query/ })).toBeVisible())
|
||||
expect(screen.queryByRole('option', { name: /api.insert/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps timed-out mutations uncertain after refreshing history instead of retrying them', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.compileScriptRevision).mockRejectedValue(new AxiosError('Timeout', 'ECONNABORTED'))
|
||||
mount()
|
||||
await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
|
||||
await user.click(screen.getByRole('button', { name: 'Build saved revision' }))
|
||||
await screen.findByText(/Server work may still finish/)
|
||||
expect(screen.getByRole('button', { name: 'Build saved revision' })).toBeDisabled()
|
||||
expect(screen.getByRole('combobox', { name: 'Source revision' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh server history' }))
|
||||
await waitFor(() => expect(api.getScriptProject).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByRole('button', { name: 'Build saved revision' })).toBeDisabled()
|
||||
expect(api.compileScriptRevision).toHaveBeenCalledTimes(1)
|
||||
expect(api.publishScriptBuild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('imports only an explicit immutable commit into the normal draft lifecycle', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.getScriptProject).mockResolvedValue({ ...PROJECT, importAvailable: true })
|
||||
vi.mocked(api.importScriptDraft).mockResolvedValue(10)
|
||||
mount()
|
||||
await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
|
||||
await user.click(screen.getByText('Import / sync from an exact GitHub commit'))
|
||||
await user.type(screen.getByRole('textbox', { name: 'GitHub repository' }), 'acme/scripts')
|
||||
await user.type(screen.getByRole('textbox', { name: 'Commit SHA' }), 'main')
|
||||
expect(screen.getByRole('button', { name: 'Import commit as draft' })).toBeDisabled()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Commit SHA' }), { target: { value: 'a'.repeat(40) } })
|
||||
await user.type(screen.getByRole('textbox', { name: 'Source subdirectory' }), 'scripts')
|
||||
await user.click(screen.getByRole('button', { name: 'Import commit as draft' }))
|
||||
await waitFor(() => expect(api.importScriptDraft).toHaveBeenCalledWith(7, 10, 'acme/scripts', 'a'.repeat(40), 'scripts', expect.any(AbortSignal)))
|
||||
expect(api.compileScriptRevision).not.toHaveBeenCalled()
|
||||
expect(api.publishScriptBuild).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,243 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Source-first editor for isolated TypeScript projects; every lifecycle action is explicit. */
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { isAxiosError } from 'axios'
|
||||
import {
|
||||
compileScriptRevision, getScriptApiTypes, getScriptBuild, getScriptDraft, getScriptProject, importScriptDraft,
|
||||
publishScriptBuild, saveScriptDraft, scriptCapabilitySchema, testScriptContext,
|
||||
type ScriptBuild, type ScriptContextResult, type ScriptProject, type ScriptSourceFile, type ScriptProvenance,
|
||||
} from '@/lib/api/scripting'
|
||||
import { useUiApiExtensionInstalled } from '@/lib/api/ui-api-extensions'
|
||||
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { CodeEditor } from './CodeEditor'
|
||||
import { ScriptHookPanel } from './ScriptHookPanel'
|
||||
import type { IdeDefinition } from '@/lib/scripting/typescript-session'
|
||||
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS } from './ScriptPanels'
|
||||
|
||||
const INITIAL_SOURCE = 'import type { Context, Qqq } from "@qqq/sdk";\n\nexport async function main(context: Context, qqq: Qqq) {\n return {};\n}\n'
|
||||
const LanguageEditor = lazy(() => import('./TypeScriptSourceEditor').then((module) => ({ default: module.TypeScriptSourceEditor })))
|
||||
|
||||
/** Properties for a shared isolated Script project editor. */
|
||||
export interface IsolatedScriptEditorProps {
|
||||
/** Core project identity; no caller-supplied runtime or actor. */
|
||||
scriptId: string | number
|
||||
/** Accessible panel heading. */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the optional extension and load authorized project/source views.
|
||||
* @param props - Project identity and label.
|
||||
* @returns Source editor or a fail-closed availability message.
|
||||
*/
|
||||
export function IsolatedScriptEditor({ scriptId, label }: IsolatedScriptEditorProps) {
|
||||
const installed = useUiApiExtensionInstalled('scripting')
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const project = useQuery({
|
||||
queryKey: ['scripting', scriptId, 'project'], queryFn: ({ signal }) => getScriptProject(scriptId, signal),
|
||||
enabled: installed, retry: false, meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const revisionId = selected ?? project.data?.latestRevisionId ?? null
|
||||
const draft = useQuery({
|
||||
queryKey: ['scripting', scriptId, 'revision', revisionId],
|
||||
queryFn: ({ signal }) => getScriptDraft(scriptId, revisionId!, signal),
|
||||
enabled: installed && revisionId !== null, retry: false, meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
if (!installed) return <p role="status">The isolated scripting editor requires the scripting API extension.</p>
|
||||
if (project.error || draft.error) return <p role="alert" className={ALERT_CLASS}>{getErrorMessage(project.error ?? draft.error)}</p>
|
||||
if (!project.data || (revisionId !== null && !draft.data)) return <p role="status">Loading script project…</p>
|
||||
if (project.data.executionMode !== 'ISOLATED') return <p role="alert">This editor requires an isolated script project.</p>
|
||||
|
||||
return <SourceWorkspace
|
||||
key={`${scriptId}:${revisionId ?? 'new'}`}
|
||||
scriptId={scriptId} label={label} project={project.data} revisionId={revisionId}
|
||||
initialFiles={draft.data?.files ?? [{ path: 'src/index.ts', content: INITIAL_SOURCE }]}
|
||||
provenance={draft.data?.provenance ?? null}
|
||||
onSelect={setSelected}
|
||||
onSaved={async (id) => { await project.refetch(); setSelected(id) }}
|
||||
onRefresh={async () => { await project.refetch() }}
|
||||
/>
|
||||
}
|
||||
|
||||
interface WorkspaceProps extends IsolatedScriptEditorProps {
|
||||
project: ScriptProject
|
||||
revisionId: number | null
|
||||
initialFiles: ScriptSourceFile[]
|
||||
provenance: ScriptProvenance | null
|
||||
onSelect: (id: number) => void
|
||||
onSaved: (id: number) => Promise<void>
|
||||
onRefresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep unsaved files local while all durable state lives in the shared registry.
|
||||
* @param props - Authorized snapshot and refresh callbacks.
|
||||
* @returns Explicit draft/build/test/publish controls and diagnostics.
|
||||
*/
|
||||
function SourceWorkspace({ scriptId, label, project, revisionId, initialFiles, provenance, onSelect, onSaved, onRefresh }: WorkspaceProps) {
|
||||
const [files, setFiles] = useState(initialFiles)
|
||||
const [baseRevisionId] = useState(project.latestRevisionId)
|
||||
const [active, setActive] = useState(initialFiles[0]?.path ?? '')
|
||||
const [newPath, setNewPath] = useState('')
|
||||
const [entryPoint, setEntryPoint] = useState(initialFiles[0]?.path ?? 'src/index.ts')
|
||||
const [note, setNote] = useState('')
|
||||
const [capabilityText, setCapabilityText] = useState('[]')
|
||||
const [contextText, setContextText] = useState('{}')
|
||||
const [build, setBuild] = useState<ScriptBuild | null>(null)
|
||||
const [test, setTest] = useState<ScriptContextResult | null>(null)
|
||||
const [declarations, setDeclarations] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [uncertain, setUncertain] = useState(false)
|
||||
const [repository, setRepository] = useState(provenance?.repository ?? '')
|
||||
const [commitSha, setCommitSha] = useState(provenance?.commitSha ?? '')
|
||||
const [subdirectory, setSubdirectory] = useState(provenance?.subdirectory ?? '')
|
||||
const [selection, setSelection] = useState<IdeDefinition | null>(null)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const dirty = revisionId === null || JSON.stringify(files) !== JSON.stringify(initialFiles)
|
||||
const file = files.find((candidate) => candidate.path === active) ?? files[0]
|
||||
|
||||
useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => { if (dirty) { event.preventDefault(); event.returnValue = '' } }
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
}, [dirty])
|
||||
useEffect(() => () => controller.current?.abort(), [])
|
||||
|
||||
const operation = useMutation({
|
||||
mutationFn: async (work: (signal: AbortSignal) => Promise<void>) => {
|
||||
controller.current = new AbortController()
|
||||
setNotice('')
|
||||
await work(controller.current.signal)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isAxiosError(error) && !error.response) setUncertain(true)
|
||||
},
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const busy = operation.isPending
|
||||
const disabled = busy || uncertain
|
||||
const can = (action: string) => project.actions.includes(action)
|
||||
const capabilities = () => z.array(scriptCapabilitySchema).max(20).parse(JSON.parse(capabilityText))
|
||||
const changeFiles = (next: ScriptSourceFile[]) => { setFiles(next); setBuild(null); setTest(null) }
|
||||
|
||||
return <section className="space-y-4 rounded-xl border border-border bg-card p-4" aria-label={label}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><h3 className="text-lg font-semibold">{label}</h3><p className="text-sm text-muted-foreground">Isolated TypeScript · {dirty ? 'Unsaved draft' : `Revision ${revisionId}`}</p></div>
|
||||
<label className="text-sm">Source revision <select className={INPUT_CLASS} value={revisionId ?? ''} disabled={disabled}
|
||||
onChange={(event) => {
|
||||
if (!dirty || window.confirm('Discard unsaved source changes?')) onSelect(Number(event.target.value))
|
||||
}}>
|
||||
{revisionId === null && <option value="">New draft</option>}
|
||||
{project.revisions.map((item) => <option key={item.id} value={item.id}>#{item.sequence ?? item.id} {item.message ?? ''}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
{!project.workerAvailable && <p role="status" className="text-sm">The isolated compiler/runtime is unavailable. Source can be reviewed and saved; building and testing require the configured worker.</p>}
|
||||
<p className="text-sm text-muted-foreground">Save creates a draft. Build compiles saved files. Test runs that build with the context below. Publish creates an immutable version; it does not attach a record hook.</p>
|
||||
{provenance && <p className="break-all text-sm" aria-label="Imported source provenance">Imported {provenance.repository}@{provenance.commitSha}{provenance.subdirectory ? ` / ${provenance.subdirectory}` : ''} · by {provenance.importedBy} · SHA-256 {provenance.sourceSha256}</p>}
|
||||
{project.importAvailable && <details><summary className="cursor-pointer text-sm font-medium">Import / sync from an exact GitHub commit</summary>
|
||||
<p className="my-2 text-sm text-muted-foreground">Select a directory containing only TypeScript source. Import creates a new draft; review it before building, testing and publishing. Repository access is managed by the server.</p>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="text-sm">GitHub repository<input className={INPUT_CLASS} value={repository} disabled={disabled || !can('draft')} maxLength={201} placeholder="owner/repository" onChange={(event) => setRepository(event.target.value)} /></label>
|
||||
<label className="text-sm">Commit SHA<input className={INPUT_CLASS} value={commitSha} disabled={disabled || !can('draft')} maxLength={40} onChange={(event) => setCommitSha(event.target.value)} /></label>
|
||||
<label className="text-sm">Source subdirectory<input className={INPUT_CLASS} value={subdirectory} disabled={disabled || !can('draft')} maxLength={200} placeholder="scripts" onChange={(event) => setSubdirectory(event.target.value)} /></label>
|
||||
</div>
|
||||
<button type="button" className={BUTTON_CLASS} disabled={disabled || !can('draft') || !repository || !/^[a-f0-9]{40}$/.test(commitSha) || (dirty && revisionId !== null)}
|
||||
onClick={() => operation.mutate(async (signal) => onSaved(await importScriptDraft(scriptId, baseRevisionId, repository, commitSha, subdirectory || null, signal)))}>Import commit as draft</button>
|
||||
{dirty && revisionId !== null && <p className="text-sm">Save or discard local source edits before importing.</p>}
|
||||
</details>}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="text-sm">File <select className={INPUT_CLASS} value={file?.path ?? ''} onChange={(event) => setActive(event.target.value)}>
|
||||
{files.map((item) => <option key={item.path}>{item.path}</option>)}
|
||||
</select></label>
|
||||
<label className="text-sm">New file path <input className={INPUT_CLASS} value={newPath} disabled={disabled || !can('draft')} onChange={(event) => setNewPath(event.target.value)} placeholder="src/helper.ts" /></label>
|
||||
<button type="button" className={BUTTON_CLASS} disabled={disabled || !can('draft') || !newPath || files.some((item) => item.path === newPath)}
|
||||
onClick={() => { changeFiles([...files, { path: newPath, content: '' }]); setActive(newPath); setNewPath('') }}>Add file</button>
|
||||
<button type="button" className={BUTTON_CLASS} disabled={disabled || !can('draft') || files.length < 2}
|
||||
onClick={() => { const next = files.filter((item) => item.path !== file.path); changeFiles(next); setActive(next[0].path) }}>Remove file</button>
|
||||
</div>
|
||||
<Suspense fallback={<p role="status">Loading TypeScript editor…</p>}>
|
||||
<LanguageEditor files={files} active={file.path} readOnly={disabled || !can('draft')} declarations={declarations} selection={selection}
|
||||
onNavigate={(target) => { if (files.some((item) => item.path === target.file)) { setActive(target.file); setSelection(target) } }}
|
||||
onChange={(content) => changeFiles(files.map((item) => item.path === file.path ? { ...item, content } : item))} />
|
||||
</Suspense>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm">Entry file <input className={INPUT_CLASS} value={entryPoint} disabled={disabled} onChange={(event) => { setEntryPoint(event.target.value); setBuild(null); setTest(null) }} /></label>
|
||||
<label className="text-sm">Revision / publication note <input className={INPUT_CLASS} value={note} maxLength={2000} disabled={disabled} onChange={(event) => setNote(event.target.value)} /></label>
|
||||
</div>
|
||||
<details><summary className="cursor-pointer text-sm font-medium">Read capabilities and scoped API types</summary>
|
||||
<p className="my-2 text-sm text-muted-foreground">Each declaration names operation READ, table, fields, maxRows and maxCalls. Reads also require your current permissions.</p>
|
||||
<CodeEditor id={`script-capabilities-${scriptId}`} ariaLabel="Read capability declarations" language="json" rows={6} value={capabilityText}
|
||||
readOnly={disabled} onChange={(value) => { setCapabilityText(value); setBuild(null); setTest(null); setDeclarations('') }} />
|
||||
<button type="button" className={BUTTON_CLASS} disabled={disabled} onClick={() => operation.mutate(async (signal) => setDeclarations(await getScriptApiTypes(scriptId, capabilities(), signal)))}>Load scoped API types</button>
|
||||
{declarations && <CodeEditor id={`script-types-${scriptId}`} ariaLabel="Scoped API declarations" language="typescript" rows={10} value={declarations} readOnly />}
|
||||
</details>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className={PRIMARY_BUTTON_CLASS} disabled={disabled || !can('draft') || !dirty}
|
||||
onClick={() => operation.mutate(async (signal) => onSaved(await saveScriptDraft(scriptId, baseRevisionId, files, note, signal)))}>Save draft</button>
|
||||
<button type="button" className={BUTTON_CLASS} disabled={disabled || dirty || revisionId === null || !can('check') || !project.workerAvailable}
|
||||
onClick={() => operation.mutate(async (signal) => { setBuild(await compileScriptRevision(scriptId, revisionId!, entryPoint, capabilities(), signal)); setTest(null); await onRefresh() })}>Build saved revision</button>
|
||||
<button type="button" className={BUTTON_CLASS} disabled={disabled || dirty || !build?.success || !can('test') || !project.workerAvailable}
|
||||
onClick={() => operation.mutate(async (signal) => {
|
||||
const input = JSON.parse(contextText) as unknown
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Test context must be a JSON object')
|
||||
setTest(await testScriptContext(scriptId, build!.buildId, input as Record<string, unknown>, capabilities(), signal))
|
||||
})}>Test context</button>
|
||||
<button type="button" className={BUTTON_CLASS} disabled={disabled || dirty || !build?.success || test?.status !== 'OK' || !can('publish')}
|
||||
onClick={() => operation.mutate(async (signal) => { const id = await publishScriptBuild(scriptId, build!.buildId, note, signal); setNotice(`Published immutable version ${id}.`); await onRefresh() })}>Publish tested build</button>
|
||||
{busy && <button type="button" className={BUTTON_CLASS} onClick={() => { controller.current?.abort(); setUncertain(true) }}>Stop waiting</button>}
|
||||
{uncertain && <button type="button" className={BUTTON_CLASS} disabled={busy} onClick={() => operation.mutate(async () => { await onRefresh() })}>Refresh server history</button>}
|
||||
</div>
|
||||
<ScriptHookPanel scriptId={scriptId} project={project} build={dirty ? null : build} disabled={disabled} capabilities={capabilities}
|
||||
run={(work) => operation.mutate(work)} onTested={setTest} />
|
||||
{uncertain && <p role="status">Observation stopped. Server work may still finish. Refresh and inspect history, then reopen the editor before another mutation.</p>}
|
||||
{operation.error && !uncertain && <p role="alert" className={ALERT_CLASS}>{getErrorMessage(operation.error)}</p>}
|
||||
{notice && <p role="status">{notice}</p>}
|
||||
{build && <div aria-label="Build diagnostics">
|
||||
<p className="font-medium">Build {build.buildId}: {build.success ? 'Compiled' : 'Failed'}</p>
|
||||
<ul>{build.diagnostics.map((diagnostic, index) => <li key={index}>
|
||||
<button type="button" className="text-left underline" onClick={() => {
|
||||
const source = files.find((item) => item.path === diagnostic.file)
|
||||
if (!source) return
|
||||
const lines = source.content.split('\n')
|
||||
const line = Math.max(0, Math.min(lines.length - 1, diagnostic.line - 1))
|
||||
const offset = lines.slice(0, line).reduce((sum, text) => sum + text.length + 1, 0) + Math.max(0, Math.min(lines[line].length, diagnostic.column - 1))
|
||||
setActive(source.path); setSelection({ file: source.path, from: offset, to: offset })
|
||||
}}>{diagnostic.file}:{diagnostic.line}:{diagnostic.column}</button>
|
||||
{' '}{diagnostic.code}: {diagnostic.message}
|
||||
</li>)}</ul>
|
||||
</div>}
|
||||
<details open><summary className="cursor-pointer text-sm font-medium">Context test input and result</summary>
|
||||
<CodeEditor id={`script-context-${scriptId}`} ariaLabel="Context test input" language="json" rows={6} value={contextText} readOnly={disabled}
|
||||
onChange={(value) => { setContextText(value); setTest(null) }} />
|
||||
{test && <div role="status"><p>Execution {test.executionId}: {test.status}</p><pre className="overflow-auto whitespace-pre-wrap text-sm">{test.contextJson ?? test.message}</pre></div>}
|
||||
</details>
|
||||
<details><summary className="cursor-pointer text-sm font-medium">Recent builds and publications (up to 100 each)</summary>
|
||||
<ul>{project.builds.map((item) => <li key={item.id}>Build {item.id}: {item.status} · revision {item.revisionId}
|
||||
{item.revisionId === revisionId && <button type="button" className={BUTTON_CLASS} disabled={disabled || dirty}
|
||||
onClick={() => operation.mutate(async (signal) => { setBuild(await getScriptBuild(scriptId, item.id, signal)); setTest(null) })}>Load diagnostics</button>}
|
||||
</li>)}</ul>
|
||||
<ul>{project.publications.map((item) => <li key={item.id}>Publication {item.id} · version {item.version} · {item.actor}</li>)}</ul>
|
||||
</details>
|
||||
</section>
|
||||
}
|
||||
@@ -211,7 +211,7 @@ describe('ScriptEditorDialog', () => {
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Submit' }))
|
||||
await waitFor(() => expect(testScript).toHaveBeenCalledWith(expect.objectContaining({
|
||||
scriptId: 7, inputValues: { who: 'Ada' }, files: expect.objectContaining({ 'main.js': 'unsaved();' }),
|
||||
}), { signal: expect.any(AbortSignal) }))
|
||||
})))
|
||||
expect(await within(dialog).findByText('ran')).toBeInTheDocument()
|
||||
expect(storeScriptRevision).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Hook administration keeps dry runs, publication and active bindings distinct. */
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import * as api from '@/lib/api/scripting'
|
||||
import { ScriptHookPanel } from './ScriptHookPanel'
|
||||
|
||||
vi.mock('@/lib/api/scripting', async (original) => {
|
||||
const actual = await original<typeof import('@/lib/api/scripting')>()
|
||||
return { ...actual, getScriptHooks: vi.fn(), createScriptBinding: vi.fn(), setScriptBindingEnabled: vi.fn(), testScriptHook: vi.fn() }
|
||||
})
|
||||
vi.mock('./CodeEditor', () => ({ CodeEditor: (props: { ariaLabel: string; value: string; readOnly?: boolean; onChange?: (value: string) => void }) => <textarea
|
||||
aria-label={props.ariaLabel} value={props.value} readOnly={props.readOnly} onChange={(event) => props.onChange?.(event.target.value)} /> }))
|
||||
|
||||
const PROJECT: api.ScriptProject = { scriptId: 7, executionMode: 'ISOLATED', latestRevisionId: 10, revisions: [], builds: [],
|
||||
publications: [{ id: 50, revisionId: 10, artifactId: 30, version: 2, status: null, actor: 'alice', createdAt: null }],
|
||||
actions: ['view', 'bind', 'test'], workerAvailable: true, importAvailable: false }
|
||||
const VIEW: api.ScriptHookView = { targets: [{ tableName: 'orders', label: 'Orders', phases: [{ event: 'PRE_UPDATE', patchableFields: ['status'] }, { event: 'POST_DELETE', patchableFields: [] }] }],
|
||||
bindings: [{ id: 40, name: 'Production', tableName: 'orders', event: 'PRE_UPDATE', publicationId: 2, enabled: true, grants: [], patchableFields: ['status'] }], historyLimited: true, nextBindingId: 5 }
|
||||
const RESULT: api.ScriptHookResult = { executionId: 60, status: 'OK', message: null, contextJson: null, hook: { action: 'PATCH', message: null, patch: { status: 'SIMULATED' } } }
|
||||
const tested = vi.fn()
|
||||
|
||||
/**
|
||||
* Mirror the editor's shared cancellation/error runner without executing script text.
|
||||
* @param props - Current authorized project.
|
||||
* @returns Isolated hook panel fixture.
|
||||
*/
|
||||
function Harness({ project }: { project: api.ScriptProject }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
return <><ScriptHookPanel scriptId={7} project={project} build={{ buildId: 20, success: true, artifactId: 30, artifactVersion: 1, diagnostics: [] }}
|
||||
disabled={busy} capabilities={() => []} onTested={tested} run={(work) => {
|
||||
setBusy(true)
|
||||
void work(new AbortController().signal).catch((failure: Error) => setError(failure.message)).finally(() => setBusy(false))
|
||||
}} />{error && <p role="alert">{error}</p>}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an isolated React Query cache for one project fixture.
|
||||
* @param project - Project permissions and runtime availability.
|
||||
* @returns Rendered hook controls.
|
||||
*/
|
||||
function mount(project = PROJECT) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><Harness project={project} /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('record hook controls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.getScriptHooks).mockImplementation(async (_project, _signal, cursor) => cursor === 5
|
||||
? { ...VIEW, bindings: [{ ...VIEW.bindings[0], id: 1, name: 'Old publication', publicationId: 1 }], nextBindingId: null, historyLimited: false } : VIEW)
|
||||
vi.mocked(api.testScriptHook).mockResolvedValue(RESULT)
|
||||
vi.mocked(api.createScriptBinding).mockResolvedValue(99)
|
||||
vi.mocked(api.setScriptBindingEnabled).mockResolvedValue(40)
|
||||
})
|
||||
|
||||
it('loads on demand and simulates the selected record without activating a binding', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount()
|
||||
expect(api.getScriptHooks).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByText('Record hooks: test and bindings'))
|
||||
await screen.findByRole('combobox', { name: 'Hook table' })
|
||||
await user.type(screen.getByRole('textbox', { name: 'Selected record ID' }), '42')
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Proposed hook test changes' }), { target: { value: '{"status":"PROPOSED"}' } })
|
||||
await user.click(screen.getByRole('button', { name: 'Test hook without writes' }))
|
||||
await screen.findByText('Hook dry run: OK. No record changes were applied.')
|
||||
expect(api.testScriptHook).toHaveBeenCalledWith(7, 20, { tableName: 'orders', event: 'PRE_UPDATE', recordId: '42', changes: { status: 'PROPOSED' } }, expect.any(AbortSignal))
|
||||
expect(tested).toHaveBeenCalledWith(RESULT)
|
||||
expect(api.createScriptBinding).not.toHaveBeenCalled()
|
||||
expect(api.setScriptBindingEnabled).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('textbox', { name: 'Simulated hook decision' })).toHaveValue(JSON.stringify(RESULT.hook, null, 2))
|
||||
})
|
||||
|
||||
it.each(['AFTER_INSERT', 'AFTER_UPDATE', 'AFTER_DELETE'] as const)('explains %s and dry-runs only metadata-authorized observations', async (phase) => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.getScriptHooks).mockResolvedValue({ ...VIEW, targets: [{ ...VIEW.targets[0], phases: [{ event: phase, patchableFields: [] }] }] })
|
||||
mount()
|
||||
await user.click(screen.getByText('Record hooks: test and bindings'))
|
||||
expect(await screen.findByRole('combobox', { name: 'Hook event' })).toHaveValue(phase)
|
||||
expect(screen.queryByRole('textbox', { name: 'Proposed hook test changes' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/AFTER scripts may only return ALLOW/)).toHaveTextContent(phase === 'AFTER_DELETE' ? 'never deleted record values' : 'read the currently authorized record at delivery time')
|
||||
await user.type(screen.getByRole('textbox', { name: 'Selected record ID' }), '42')
|
||||
await user.click(screen.getByRole('button', { name: 'Test hook without writes' }))
|
||||
await waitFor(() => expect(api.testScriptHook).toHaveBeenCalledWith(7, 20, { tableName: 'orders', event: phase, recordId: '42', changes: {} }, expect.any(AbortSignal)))
|
||||
expect(api.createScriptBinding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a pinned enabled binding only after its explicit action and permits disabling it', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount()
|
||||
await user.click(screen.getByText('Record hooks: test and bindings'))
|
||||
await screen.findByRole('textbox', { name: 'Binding name' })
|
||||
await user.type(screen.getByRole('textbox', { name: 'Binding name' }), 'Warehouse')
|
||||
await user.click(screen.getByRole('checkbox', { name: 'status' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Create enabled binding' }))
|
||||
await screen.findByText('Binding 99 is enabled.')
|
||||
expect(api.createScriptBinding).toHaveBeenCalledWith(7, { name: 'Warehouse', tableName: 'orders', event: 'PRE_UPDATE', publicationId: 50, capabilities: [], patchableFields: ['status'] }, expect.any(AbortSignal))
|
||||
await user.click(screen.getByRole('button', { name: 'Disable Production' }))
|
||||
await waitFor(() => expect(api.setScriptBindingEnabled).toHaveBeenCalledWith(7, 40, false, expect.any(AbortSignal)))
|
||||
})
|
||||
|
||||
it('pages retained bindings and can disable one pinned to an old publication', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount()
|
||||
await user.click(screen.getByText('Record hooks: test and bindings'))
|
||||
await user.click(await screen.findByRole('button', { name: 'Older bindings' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Disable Old publication' }))
|
||||
await waitFor(() => expect(api.setScriptBindingEnabled).toHaveBeenCalledWith(7, 1, false, expect.any(AbortSignal)))
|
||||
expect(api.getScriptHooks).toHaveBeenCalledWith(7, expect.any(AbortSignal), 5)
|
||||
await user.click(screen.getByRole('button', { name: 'First binding page' }))
|
||||
await screen.findByRole('button', { name: 'Disable Production' })
|
||||
})
|
||||
|
||||
it('returns to the newest page after creating from an older page and identifies controls', async () => {
|
||||
const user = userEvent.setup()
|
||||
const rendered = mount()
|
||||
await user.click(screen.getByText('Record hooks: test and bindings'))
|
||||
await user.click(await screen.findByRole('button', { name: 'Older bindings' }))
|
||||
await screen.findByRole('button', { name: 'Disable Old publication' })
|
||||
await user.type(screen.getByRole('textbox', { name: 'Binding name' }), 'New binding')
|
||||
await user.click(screen.getByRole('button', { name: 'Create enabled binding' }))
|
||||
await screen.findByText('Binding 99 is enabled.')
|
||||
await screen.findByRole('button', { name: 'Disable Production' })
|
||||
expect(screen.queryByRole('button', { name: 'First binding page' })).not.toBeInTheDocument()
|
||||
for (const control of rendered.container.querySelectorAll('summary, select, input, button')) {
|
||||
expect(control.getAttribute('data-qqq-id')).toMatch(/^script-hook/)
|
||||
}
|
||||
})
|
||||
|
||||
it('disables unauthorized actions and allows emergency disable when the worker is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
const rendered = mount({ ...PROJECT, actions: ['view'] })
|
||||
await user.click(screen.getByText('Record hooks: test and bindings'))
|
||||
expect(await screen.findByRole('button', { name: 'Disable Production' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Create enabled binding' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Test hook without writes' })).toBeDisabled()
|
||||
rendered.unmount()
|
||||
mount({ ...PROJECT, workerAvailable: false })
|
||||
await user.click(screen.getByText('Record hooks: test and bindings'))
|
||||
expect(await screen.findByRole('button', { name: 'Disable Production' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Test hook without writes' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows a denied action and retains the pinned binding for inspection', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(api.setScriptBindingEnabled).mockRejectedValue(new Error('Current target permission required'))
|
||||
mount()
|
||||
await user.click(screen.getByText('Record hooks: test and bindings'))
|
||||
await user.click(await screen.findByRole('button', { name: 'Disable Production' }))
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Current target permission required')
|
||||
expect(screen.getByRole('button', { name: 'Disable Production' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Source-project hook administration and read-only selected-record event tests. */
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createScriptBinding, getScriptHooks, setScriptBindingEnabled, testScriptHook,
|
||||
type ScriptBuild, type ScriptCapability, type ScriptHookEvent, type ScriptHookResult, type ScriptProject,
|
||||
} from '@/lib/api/scripting'
|
||||
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { CodeEditor } from './CodeEditor'
|
||||
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS } from './ScriptPanels'
|
||||
|
||||
interface Props {
|
||||
scriptId: string | number
|
||||
project: ScriptProject
|
||||
build: ScriptBuild | null
|
||||
disabled: boolean
|
||||
capabilities: () => ScriptCapability[]
|
||||
run: (work: (signal: AbortSignal) => Promise<void>) => void
|
||||
onTested: (result: ScriptHookResult) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep binding activation distinct from publication and tests distinct from writes.
|
||||
* @param props - Current authorized project and shared cancellable action runner.
|
||||
* @returns Explicit hook test, activation and paged retained binding controls.
|
||||
*/
|
||||
export function ScriptHookPanel({ scriptId, project, build, disabled, capabilities, run, onTested }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [tableName, setTableName] = useState('')
|
||||
const [event, setEvent] = useState<ScriptHookEvent>('PRE_INSERT')
|
||||
const [recordId, setRecordId] = useState('')
|
||||
const [changes, setChanges] = useState('{}')
|
||||
const [name, setName] = useState('')
|
||||
const [publication, setPublication] = useState<number | null>(null)
|
||||
const [patchFields, setPatchFields] = useState<string[]>([])
|
||||
const [tested, setTested] = useState<{ buildId: number; result: ScriptHookResult } | null>(null)
|
||||
const [cursor, setCursor] = useState<number | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const catalog = useQuery({
|
||||
queryKey: ['scripting', scriptId, 'hooks', cursor],
|
||||
queryFn: ({ signal }) => getScriptHooks(scriptId, signal, cursor), enabled: open,
|
||||
retry: false, meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const target = catalog.data?.targets.find((item) => item.tableName === tableName) ?? catalog.data?.targets[0]
|
||||
const phase = target?.phases.find((item) => item.event === event) ?? target?.phases[0]
|
||||
const publicationId = publication ?? project.publications[0]?.id ?? null
|
||||
const canBind = project.actions.includes('bind')
|
||||
const canTest = project.actions.includes('test')
|
||||
const result = tested?.buildId === build?.buildId ? tested?.result : null
|
||||
const canChange = phase?.event === 'PRE_INSERT' || phase?.event === 'PRE_UPDATE'
|
||||
|
||||
return <details data-qqq-id="script-hooks-panel" className="rounded-lg border border-border p-3" onToggle={(toggle) => setOpen(toggle.currentTarget.open)}>
|
||||
<summary data-qqq-id="script-hooks-toggle" className="cursor-pointer font-medium">Record hooks: test and bindings</summary>
|
||||
<p className="my-2 text-sm text-muted-foreground">Test reads the selected record and simulates this event. It never inserts, updates or deletes a record, and never applies the returned patch. Native field validation occurs during an actual write. Post events observe backend actions and do not prove commit.</p>
|
||||
{catalog.error && <p role="alert" className={ALERT_CLASS}>{getErrorMessage(catalog.error)}</p>}
|
||||
{catalog.isFetching && <p role="status">Loading authorized hook controls…</p>}
|
||||
{catalog.data && <div className="space-y-3">
|
||||
{target && phase ? <>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm">Hook table<select data-qqq-id="script-hook-table" className={INPUT_CLASS} value={target.tableName} disabled={disabled} onChange={(change) => { setTableName(change.target.value); setRecordId(''); setPatchFields([]); setTested(null) }}>
|
||||
{catalog.data.targets.map((item) => <option key={item.tableName} value={item.tableName}>{item.label ?? item.tableName}</option>)}
|
||||
</select></label>
|
||||
<label className="text-sm">Hook event<select data-qqq-id="script-hook-event" className={INPUT_CLASS} value={phase.event} disabled={disabled} onChange={(change) => { setEvent(change.target.value as ScriptHookEvent); setPatchFields([]); setChanges('{}'); setTested(null) }}>
|
||||
{target.phases.map((item) => <option key={item.event}>{item.event}</option>)}
|
||||
</select></label>
|
||||
<label className="text-sm">Selected record ID<input data-qqq-id="script-hook-record-id" className={INPUT_CLASS} value={recordId} maxLength={1024} disabled={disabled} onChange={(change) => { setRecordId(change.target.value); setTested(null) }} /></label>
|
||||
</div>
|
||||
{phase.event.startsWith('AFTER_') && <p data-qqq-id="script-hook-delivery-semantics" className="text-sm text-muted-foreground">
|
||||
{phase.event === 'AFTER_DELETE'
|
||||
? 'AFTER_DELETE runs from a committed Jobs intent and receives event metadata only, never deleted record values. This dry run reads the selected record for authorization, then sends only metadata; it does not delete or enqueue work.'
|
||||
: 'AFTER_INSERT and AFTER_UPDATE run from a committed Jobs intent and read the currently authorized record at delivery time, not its historical values. This dry run reads the selected current record; it does not write, enqueue work, or prove commit.'}
|
||||
{' '}AFTER scripts may only return ALLOW. Delivery failures cannot undo the committed write.
|
||||
</p>}
|
||||
{canChange && <CodeEditor id={`hook-changes-${scriptId}`} ariaLabel="Proposed hook test changes" language="json" rows={4}
|
||||
value={changes} readOnly={disabled} onChange={(value) => { setChanges(value); setTested(null) }} />}
|
||||
<button data-qqq-id="script-hook-test" type="button" className={BUTTON_CLASS} disabled={disabled || !build?.success || !recordId.trim() || !canTest || !project.workerAvailable}
|
||||
onClick={() => run(async (signal) => {
|
||||
const answer = await testScriptHook(scriptId, build!.buildId, { tableName: target.tableName, event: phase.event, recordId, changes: canChange ? JSON.parse(changes) as unknown : {} }, signal)
|
||||
setTested({ buildId: build!.buildId, result: answer }); onTested(answer)
|
||||
})}>Test hook without writes</button>
|
||||
{result && <div aria-label="Hook dry-run result"><p role="status">Hook dry run: {result.status}. No record changes were applied.</p>
|
||||
{result.message && <p className="text-sm">{result.message}</p>}
|
||||
{result.hook && <CodeEditor id={`hook-result-${scriptId}`} ariaLabel="Simulated hook decision" language="json" rows={5} value={JSON.stringify(result.hook, null, 2)} readOnly />}
|
||||
</div>}
|
||||
<fieldset className="space-y-2 rounded border border-border p-3" disabled={disabled || !canBind}>
|
||||
<legend className="px-1 text-sm font-medium">Create an enabled binding</legend>
|
||||
<p className="text-sm text-muted-foreground">This activates the chosen immutable publication for the table and event. Read grants use the editor’s capability declarations; the server checks them against that publication.</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm">Binding name<input data-qqq-id="script-hook-binding-name" className={INPUT_CLASS} value={name} maxLength={200} onChange={(change) => setName(change.target.value)} /></label>
|
||||
<label className="text-sm">Published version<select data-qqq-id="script-hook-publication" className={INPUT_CLASS} value={publicationId ?? ''} onChange={(change) => setPublication(Number(change.target.value))}>
|
||||
{!publicationId && <option value="">Publish a tested build first</option>}
|
||||
{project.publications.map((item) => <option key={item.id} value={item.id}>Version {item.version ?? item.id} · publication {item.id}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
{phase.patchableFields.length > 0 ? <div className="flex flex-wrap gap-3" aria-label="Allowed binding patch fields">
|
||||
{phase.patchableFields.map((field) => <label className="text-sm" key={field}><input data-qqq-id={`script-hook-patch-${encodeURIComponent(field)}`} type="checkbox" checked={patchFields.includes(field)} onChange={(change) => setPatchFields(change.target.checked ? [...patchFields, field] : patchFields.filter((selected) => selected !== field))} /> {field}</label>)}
|
||||
</div> : <p className="text-sm">This phase does not support patches.</p>}
|
||||
<button data-qqq-id="script-hook-create-binding" type="button" className={BUTTON_CLASS} disabled={disabled || !canBind || !publicationId || !name.trim() || !project.workerAvailable}
|
||||
onClick={() => run(async (signal) => {
|
||||
const id = await createScriptBinding(scriptId, { name, tableName: target.tableName, event: phase.event, publicationId: publicationId!, capabilities: capabilities(), patchableFields: patchFields.filter((field) => phase.patchableFields.includes(field)) }, signal)
|
||||
setNotice(`Binding ${id} is enabled.`); setCursor(null)
|
||||
await queryClient.invalidateQueries({ queryKey: ['scripting', scriptId, 'hooks'] })
|
||||
})}>Create enabled binding</button>
|
||||
</fieldset>
|
||||
</> : <p role="status">No host-installed hook phases are available under your current permissions.</p>}
|
||||
<div className="overflow-x-auto"><table className="w-full text-left text-sm" aria-label="Retained hook bindings">
|
||||
<thead><tr><th>Name</th><th>Table / event</th><th>Publication</th><th>State</th><th>Action</th></tr></thead>
|
||||
<tbody>{catalog.data.bindings.map((item) => <tr key={item.id}>
|
||||
<td>{item.name}</td><td>{item.tableName} / {item.event}</td><td>{item.publicationId}</td><td>{item.enabled ? 'Enabled' : 'Disabled'}</td>
|
||||
<td><button data-qqq-id={`script-hook-binding-state-${encodeURIComponent(String(item.id))}`} type="button" className={BUTTON_CLASS} disabled={disabled || !canBind || (!item.enabled && !project.workerAvailable)}
|
||||
onClick={() => run(async (signal) => { await setScriptBindingEnabled(scriptId, item.id, !item.enabled, signal); setNotice(`Binding ${item.id} ${item.enabled ? 'disabled' : 'enabled'}.`); await catalog.refetch() })}>{item.enabled ? 'Disable' : 'Enable'} {item.name}</button></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
{catalog.data.bindings.length === 0 && <p className="text-sm">No visible bindings on this page.</p>}
|
||||
<div className="flex gap-2">
|
||||
{cursor !== null && <button data-qqq-id="script-hook-bindings-first" type="button" className={BUTTON_CLASS} disabled={disabled} onClick={() => setCursor(null)}>First binding page</button>}
|
||||
{catalog.data.nextBindingId !== null && <button data-qqq-id="script-hook-bindings-older" type="button" className={BUTTON_CLASS} disabled={disabled} onClick={() => setCursor(catalog.data!.nextBindingId)}>Older bindings</button>}
|
||||
<button data-qqq-id="script-hook-bindings-refresh" type="button" className={BUTTON_CLASS} disabled={disabled} onClick={() => { void catalog.refetch() }}>Refresh bindings</button>
|
||||
</div>
|
||||
{catalog.data.historyLimited && <p className="text-sm">This view is bounded. Use older binding pages to manage retained bindings from earlier publications.</p>}
|
||||
{notice && <p role="status">{notice}</p>}
|
||||
</div>}
|
||||
</details>
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { testScript } from '@/lib/api/developer'
|
||||
import { ScriptTest } from './ScriptPanels'
|
||||
|
||||
vi.mock('@/lib/api/developer', () => ({ testScript: vi.fn() }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(testScript).mockReset()
|
||||
vi.mocked(testScript).mockImplementation((_request, options) => new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
|
||||
}))
|
||||
})
|
||||
|
||||
function mount() {
|
||||
return render(<QueryClientProvider client={new QueryClient()}>
|
||||
<ScriptTest idKey="test" scriptId={1} files={{ 'main.ts': 'return 1' }} inputFields={[]} outputFields={[]} />
|
||||
</QueryClientProvider>)
|
||||
}
|
||||
|
||||
it('aborts observation when the test panel unmounts', async () => {
|
||||
const { unmount } = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
await waitFor(() => expect(testScript).toHaveBeenCalledTimes(1))
|
||||
const signal = vi.mocked(testScript).mock.calls[0][1]?.signal
|
||||
expect(signal?.aborted).toBe(false)
|
||||
await act(async () => unmount())
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('stops waiting with an honest accessible message and permits another test', async () => {
|
||||
mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
const stop = await screen.findByRole('button', { name: 'Stop waiting' })
|
||||
fireEvent.click(stop)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('server test may still be running')
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled()
|
||||
vi.mocked(testScript).mockResolvedValueOnce({ outputObject: {}, logLines: [] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
await waitFor(() => expect(testScript).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull())
|
||||
})
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
@@ -112,14 +112,9 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
|
||||
}) {
|
||||
const [inputValues, setInputValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(inputFields.map((field) => [field.name, text(field.defaultValue)])))
|
||||
const activeTest = useRef<AbortController | null>(null)
|
||||
useEffect(() => () => activeTest.current?.abort(new DOMException('Stopped waiting because the test panel closed. The server test may still be running.', 'AbortError')), [])
|
||||
const run = useMutation({
|
||||
mutationFn: (controller: AbortController) => testScript({ scriptId, files, inputValues, apiName, apiVersion }, { signal: controller.signal }),
|
||||
mutationFn: () => testScript({ scriptId, files, inputValues, apiName, apiVersion }),
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
onSettled: (_data, _error, controller) => {
|
||||
if (activeTest.current === controller) activeTest.current = null
|
||||
},
|
||||
})
|
||||
const result = run.data
|
||||
const outputs = result?.outputObject ?? {}
|
||||
@@ -135,10 +130,7 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (activeTest.current) return
|
||||
const controller = new AbortController()
|
||||
activeTest.current = controller
|
||||
run.mutate(controller)
|
||||
run.mutate()
|
||||
}}
|
||||
>
|
||||
<h4 className="text-base font-semibold text-foreground">Test Input</h4>
|
||||
@@ -162,11 +154,6 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
|
||||
)
|
||||
})}
|
||||
<div className="flex justify-end">
|
||||
{run.isPending && <button type="button" className={BUTTON_CLASS}
|
||||
data-qqq-id={`button-stop-waiting-script-${idKey}`}
|
||||
onClick={() => activeTest.current?.abort(new DOMException('Stopped waiting. The server test may still be running.', 'AbortError'))}>
|
||||
Stop waiting
|
||||
</button>}
|
||||
<button type="submit" className={PRIMARY_BUTTON_CLASS} disabled={run.isPending} data-qqq-id={`button-test-script-${idKey}`}>
|
||||
{run.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Submit
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Isolated and unknown modes never mount legacy script actions. */
|
||||
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ScriptViewer } from './ScriptViewer'
|
||||
|
||||
vi.mock('./IsolatedScriptEditor', () => ({ IsolatedScriptEditor: ({ scriptId }: { scriptId: number }) => <div>Isolated project {scriptId}</div> }))
|
||||
|
||||
/**
|
||||
* Render without a QueryClient; any legacy viewer mount would try to use it.
|
||||
* @param mode - Stored server-controlled execution mode.
|
||||
*/
|
||||
function mount(mode: string) {
|
||||
render(<ScriptViewer idKey="example" label="Script" scriptId={7} script={{ tableName: 'script', values: { id: 7, executionMode: mode } }}
|
||||
canEdit canTest logs={{ queryKey: () => [], load: async () => [] }} onChanged={async () => undefined} />)
|
||||
}
|
||||
|
||||
describe('script execution mode routing', () => {
|
||||
it('routes isolated source directly to the isolated editor', () => {
|
||||
mount('ISOLATED')
|
||||
expect(screen.getByText('Isolated project 7')).toBeVisible()
|
||||
expect(screen.queryByText('Edit and Activate')).not.toBeInTheDocument()
|
||||
})
|
||||
it('fails closed on unknown execution modes', () => {
|
||||
mount('FUTURE_MODE')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('not supported')
|
||||
})
|
||||
})
|
||||
@@ -44,7 +44,6 @@ import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { CodeBlock } from './HighlightedCode'
|
||||
import { ScriptEditorDialog } from './ScriptEditorDialog'
|
||||
import { IsolatedScriptEditor } from './IsolatedScriptEditor'
|
||||
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, ScriptDocs, ScriptTest, type ScriptTestFields } from './ScriptPanels'
|
||||
import {
|
||||
FILE_MODE_MULTI_PRE_DEFINED,
|
||||
@@ -114,22 +113,7 @@ export interface ScriptViewerProps {
|
||||
* @param props - {@link ScriptViewerProps}
|
||||
* @returns The viewer.
|
||||
*/
|
||||
export function ScriptViewer(props: ScriptViewerProps) {
|
||||
if (props.script.values.executionMode === 'ISOLATED') {
|
||||
return <IsolatedScriptEditor scriptId={props.scriptId} label={props.label} />
|
||||
}
|
||||
if (props.script.values.executionMode && props.script.values.executionMode !== 'LEGACY') {
|
||||
return <p role="alert">This script execution mode is not supported by this editor.</p>
|
||||
}
|
||||
return <LegacyScriptViewer {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing trusted-project viewer, never mounted for isolated source.
|
||||
* @param props - Legacy viewer properties.
|
||||
* @returns The existing trusted workflow.
|
||||
*/
|
||||
function LegacyScriptViewer({
|
||||
export function ScriptViewer({
|
||||
idKey,
|
||||
label,
|
||||
scriptId,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @file Typing while the cold language-service worker is still answering. Uses the REAL editor, the REAL
|
||||
* worker client (finite queue, stale rejection, deadlines) and the REAL CodeMirror linter; only the browser
|
||||
* Worker itself is controlled by the test.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { TypeScriptSourceEditor } from './TypeScriptSourceEditor'
|
||||
import { IDE_VERSION } from '@/lib/scripting/typescript-session'
|
||||
|
||||
interface Posted { id: number; operation: string; files: Array<{ path: string; content: string }> }
|
||||
|
||||
/** A Worker whose answers are released by the test, exactly as a cold worker releases them late. */
|
||||
class FakeWorker {
|
||||
static instances: FakeWorker[] = []
|
||||
static auto = false
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: ((event: ErrorEvent) => void) | null = null
|
||||
posted: Posted[] = []
|
||||
answered = new Set<number>()
|
||||
terminated = false
|
||||
constructor() { FakeWorker.instances.push(this) }
|
||||
postMessage(request: Posted) {
|
||||
this.posted.push(request)
|
||||
if (FakeWorker.auto) queueMicrotask(() => this.answer(request))
|
||||
}
|
||||
terminate() { this.terminated = true }
|
||||
answer(request: Posted) {
|
||||
if (this.terminated || this.answered.has(request.id)) return
|
||||
this.answered.add(request.id)
|
||||
const content = request.files[0].content
|
||||
const at = content.indexOf('"x"')
|
||||
const diagnostics = at < 0 ? [] : [{ from: at, to: at + 3, code: 2322, severity: 'error', message: "Type 'string' is not assignable to type 'number'." }]
|
||||
this.onmessage?.({ data: { protocol: 'qqq-typescript-ide/1', version: IDE_VERSION, id: request.id, result: { diagnostics } } } as MessageEvent)
|
||||
}
|
||||
releaseHeld() { for (const request of this.posted) this.answer(request) }
|
||||
}
|
||||
|
||||
const originalRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects')
|
||||
const originalBounds = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
|
||||
beforeEach(() => {
|
||||
FakeWorker.instances = []
|
||||
FakeWorker.auto = false
|
||||
vi.stubGlobal('Worker', FakeWorker)
|
||||
Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: () => [] })
|
||||
Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: () => new DOMRect() })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
if (originalRects) Object.defineProperty(Range.prototype, 'getClientRects', originalRects)
|
||||
else Reflect.deleteProperty(Range.prototype, 'getClientRects')
|
||||
if (originalBounds) Object.defineProperty(Range.prototype, 'getBoundingClientRect', originalBounds)
|
||||
else Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
|
||||
})
|
||||
|
||||
const FINAL = 'export const n: number = "x"'
|
||||
const keystrokes = Array.from({ length: FINAL.length }, (_, i) => FINAL.slice(0, i + 1))
|
||||
|
||||
/** The parent workspace feeds each onChange back as a NEW files array, exactly like the real isolated editor. */
|
||||
function Workspace({ initial }: { initial: string }) {
|
||||
const [files, setFiles] = React.useState([{ path: 'src/main.ts', content: initial }])
|
||||
return <TypeScriptSourceEditor files={files} active="src/main.ts" readOnly={false} declarations="" onNavigate={vi.fn()}
|
||||
onChange={(content) => setFiles([{ path: 'src/main.ts', content }])} />
|
||||
}
|
||||
|
||||
/** Real edits on the real CodeMirror view, one keystroke at a time (the user's own typing, not a prop replacement). */
|
||||
function mount(initial: string) {
|
||||
const view = render(<Workspace initial={initial} />)
|
||||
const cm = EditorView.findFromDOM(view.container.querySelector('.cm-content') as HTMLElement)!
|
||||
const typeAll = (text: string) => { for (const ch of text) act(() => { cm.dispatch({ changes: { from: cm.state.doc.length, insert: ch }, userEvent: 'input.type' }) }) }
|
||||
return { ...view, typeAll }
|
||||
}
|
||||
|
||||
describe('typing while the cold worker is still answering', () => {
|
||||
it('analyses the final buffer once the held requests drain, and reports its error', async () => {
|
||||
const editor = mount('')
|
||||
editor.typeAll(FINAL)
|
||||
const worker = FakeWorker.instances[0]
|
||||
// the cold worker finally answers everything it was holding (all of it describes older buffers)
|
||||
FakeWorker.auto = true
|
||||
worker.releaseHeld()
|
||||
// the product must now analyse the CURRENT buffer by itself; no further keystroke is made
|
||||
await waitFor(() => expect(worker.posted.some((request) => request.files[0].content === FINAL)).toBe(true), { timeout: 4000 })
|
||||
await waitFor(() => expect(editor.container.querySelector('.cm-lintRange-error')).not.toBeNull(), { timeout: 4000 })
|
||||
})
|
||||
|
||||
it('never exceeds the finite queue and does not leave a queue-full message once the current buffer is analysed', async () => {
|
||||
const editor = mount('')
|
||||
editor.typeAll(FINAL)
|
||||
const worker = FakeWorker.instances[0]
|
||||
expect(worker.posted.filter((request) => !worker.answered.has(request.id)).length).toBeLessThanOrEqual(4)
|
||||
FakeWorker.auto = true
|
||||
worker.releaseHeld()
|
||||
await waitFor(() => expect(worker.posted.some((request) => request.files[0].content === FINAL)).toBe(true), { timeout: 4000 })
|
||||
await waitFor(() => expect(editor.container.textContent ?? '').not.toMatch(/queue is full/i), { timeout: 4000 })
|
||||
})
|
||||
|
||||
it('re-admits the current buffer after a queue-full refusal caused by something other than the editor typing', async () => {
|
||||
const props = { active: 'src/main.ts', readOnly: false, onChange: vi.fn(), onNavigate: vi.fn(), files: [{ path: 'src/main.ts', content: FINAL }] }
|
||||
const view = render(<TypeScriptSourceEditor {...props} declarations="" />)
|
||||
const worker = FakeWorker.instances[0]
|
||||
// six external declaration refreshes while the cold worker holds its answers: the 5th and 6th are refused by the finite queue
|
||||
for (let i = 0; i < 6; i++) view.rerender(<TypeScriptSourceEditor {...props} declarations={'declare const sdk' + i + ': number'} />)
|
||||
expect(worker.posted.filter((request) => !worker.answered.has(request.id)).length).toBeLessThanOrEqual(4)
|
||||
FakeWorker.auto = true
|
||||
const before = worker.posted.length
|
||||
worker.releaseHeld()
|
||||
await waitFor(() => expect(worker.posted.length).toBeGreaterThan(before), { timeout: 4000 })
|
||||
await waitFor(() => expect(view.container.querySelector('.cm-lintRange-error')).not.toBeNull(), { timeout: 4000 })
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/** @file Real CodeMirror integration tests with a deterministic advisory worker transport. */
|
||||
|
||||
import React from 'react'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TypeScriptSourceEditor } from './TypeScriptSourceEditor'
|
||||
|
||||
const transport = vi.hoisted(() => ({ request: vi.fn(), dispose: vi.fn() }))
|
||||
const originalRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects')
|
||||
const originalBounds = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
|
||||
vi.mock('@/lib/scripting/typescript-worker-client', () => ({
|
||||
TypeScriptWorkerClient: class { request = transport.request; dispose = transport.dispose },
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
transport.request.mockImplementation(async (operation: string) => {
|
||||
if (operation === 'format') return { edits: [{ from: 7, to: 7, insert: ' ' }, { from: 8, to: 8, insert: ' ' }] }
|
||||
if (operation === 'definition') return { definitions: [{ file: 'src/helper.ts', from: 0, to: 3 }] }
|
||||
return { diagnostics: [] }
|
||||
})
|
||||
Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: () => [] })
|
||||
Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: () => new DOMRect() })
|
||||
})
|
||||
afterEach(() => {
|
||||
if (originalRects) Object.defineProperty(Range.prototype, 'getClientRects', originalRects)
|
||||
else Reflect.deleteProperty(Range.prototype, 'getClientRects')
|
||||
if (originalBounds) Object.defineProperty(Range.prototype, 'getBoundingClientRect', originalBounds)
|
||||
else Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('CodeMirror isolated TypeScript source editor', () => {
|
||||
it('applies formatting as a normal source edit and dispatches cross-file definition navigation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const onNavigate = vi.fn()
|
||||
const files = [{ path: 'src/main.ts', content: 'const x=1;' }, { path: 'src/helper.ts', content: 'export const name = "helper";' }]
|
||||
const mounted = render(<TypeScriptSourceEditor files={files} active="src/main.ts" readOnly={false} declarations=""
|
||||
onChange={onChange} onNavigate={onNavigate} />)
|
||||
expect(screen.getByRole('textbox', { name: 'TypeScript source: src/main.ts' })).toHaveAttribute('contenteditable', 'true')
|
||||
await user.click(screen.getByRole('button', { name: 'Format source' }))
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith('const x = 1;'))
|
||||
await user.click(screen.getByRole('button', { name: 'Go to definition (F12)' }))
|
||||
await waitFor(() => expect(onNavigate).toHaveBeenCalledWith({ file: 'src/helper.ts', from: 0, to: 3 }))
|
||||
mounted.unmount()
|
||||
expect(transport.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps read-only content noneditable and refuses formatting', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TypeScriptSourceEditor files={[{ path: 'src/main.ts', content: 'const x=1;' }]} active="src/main.ts" readOnly declarations=""
|
||||
onChange={vi.fn()} onNavigate={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Format source' })).toBeDisabled()
|
||||
expect(screen.getByRole('textbox', { name: 'TypeScript source: src/main.ts' })).toHaveAttribute('contenteditable', 'false')
|
||||
await user.click(screen.getByRole('button', { name: 'Stop editor analysis' }))
|
||||
expect(transport.dispose).toHaveBeenCalled()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('analysis stopped')
|
||||
})
|
||||
})
|
||||
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user