Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97dab74f5
|
||
|
|
c8f79847f3
|
||
|
|
4d28309be7
|
||
|
|
13abaa795d
|
||
|
|
2f556afadf
|
||
|
|
dce92cfba1
|
||
|
|
8d372142f8
|
||
|
|
0f0ebc524f
|
||
|
|
dad2a79824
|
||
|
|
de5c1bd066
|
No files matched your search
@@ -1,12 +1,12 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
|
||||
@@ -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@eeb76914ad49fcdb2197529481de55d1e35a008a
|
||||
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@eeb76914ad49fcdb2197529481de55d1e35a008a
|
||||
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@eeb76914ad49fcdb2197529481de55d1e35a008a
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-collaboration/
|
||||
/.next-presence-live/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
profile: frontend-next
|
||||
orb_version: 0.7.12
|
||||
orb_version: '0.8'
|
||||
default_branch: main
|
||||
@@ -0,0 +1,138 @@
|
||||
# Next UI 1.0.0-RC.37 — published collaboration candidate
|
||||
|
||||
This candidate is published. Artifacts are
|
||||
`com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.37` and
|
||||
`ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.37`. RC.36 remains immutable.
|
||||
Publication does not deploy Kof22 or authorize final 1.0.
|
||||
|
||||
## Source and changes
|
||||
|
||||
Prepared from collaboration source `dce92cfba1f02f7969c19b59d4442f5488b3821e`.
|
||||
The RC preparation changes the package and Maven versions together and retains
|
||||
main's managed orb 0.8 publication configuration. This source
|
||||
includes the previously tested collaboration source
|
||||
`8d372142f8af0c41a75a353edc6d150c33ccf613` plus a My Work browser regression test.
|
||||
|
||||
- Presence opens server-issued leases and renews them through
|
||||
`/presence/leases` and `/presence/leases/renew`. Record/table viewers and
|
||||
editors receive updates through the existing SSE/HTTP transport.
|
||||
- Authentication retains the Auth0 display profile while using canonical
|
||||
backend presence identity. Departure, expired leases and reconnect behavior
|
||||
remain covered by the collaboration test suite.
|
||||
- Marking a mention read refreshes My Work; the additional browser test checks
|
||||
that behavior.
|
||||
- Changes since RC.36 also include assistant conversation navigation, unread
|
||||
state and attachment recovery, startup branding, shared HTML sanitization,
|
||||
dependency fixes and centralized CI. This is not a presence-only diff from
|
||||
RC.36.
|
||||
- Development dependencies are aligned on Storybook 10.6.2 and Vitest 4.1.12
|
||||
(including `@vitest/mocker`) for
|
||||
[GHSA-f2ph-cmg5-wxc8](https://github.com/storybookjs/storybook/security/advisories/GHSA-f2ph-cmg5-wxc8)
|
||||
and [GHSA-xp26-3454-mjgp](https://github.com/vitest-dev/vitest/security/advisories/GHSA-xp26-3454-mjgp).
|
||||
These advisories affect development-server tooling; they do not describe a
|
||||
Storybook server in the static dashboard package. Integration is tracked in
|
||||
[QQQ #1327](https://github.com/QRun-IO/qqq/issues/1327).
|
||||
|
||||
## Backend pairing
|
||||
|
||||
Use a backend implementing the issued-lease collaboration API. The verified
|
||||
earlier pair is QQQ `4.1.0-collab-33464d37e-SNAPSHOT` with collaboration
|
||||
`0.1.0-collab-8f348da-SNAPSHOT` and Redis `0.1.0-redis-7fe5492-SNAPSHOT`.
|
||||
The selectable-field follow-up pair is QQQ
|
||||
`4.1.0-virtual-field-values-1196-cc3981bb3-SNAPSHOT` with collaboration
|
||||
`0.1.0-field-values-1196-ecff347-SNAPSHOT`; that pair's independent API probe
|
||||
does not establish packaged frontend acceptance.
|
||||
|
||||
Configure authenticated scope, Redis namespaces and lifecycle, SSE delivery and
|
||||
connection affinity in the host. Presence is advisory; stale-save protection
|
||||
still requires backend record versions. Kof22 PR338's RC.36 frontend must be
|
||||
updated only after this artifact is published and independently verified.
|
||||
|
||||
## Qualification and release gates
|
||||
|
||||
- [x] Local typecheck and all 3,664 unit tests pass. Lint passes with six
|
||||
warnings and no errors; license headers pass.
|
||||
- [x] Canonical Circle caller and immutable GitHub worker bridge validate locally.
|
||||
- [x] Patched toolchain passes TypeScript, all 3,664 unit tests and Storybook
|
||||
static build (bundle-size warnings remain).
|
||||
- [x] Exact RC source passes hosted CI and full browser acceptance.
|
||||
- [x] Local static export builds and packages under `next-dashboard/`; development
|
||||
mock worker is excluded.
|
||||
- [x] Fresh immutable RC tag publishes matching Maven and image versions.
|
||||
- [x] Public JAR/POM checksums and image digest are recorded.
|
||||
- [x] Browser acceptance runs against the downloaded frontend JAR and the
|
||||
selected published backend pair.
|
||||
- [ ] Kof22 integration and isolated staging qualification are complete before
|
||||
production rollout is proposed.
|
||||
|
||||
Earlier source acceptance run
|
||||
[39683690943](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/39683690943)
|
||||
is supporting evidence, not qualification of this RC package. The versioned tag
|
||||
triggers Maven and container publication; the inspected workflows contain no
|
||||
host deployment step.
|
||||
|
||||
Local Java 21 Maven packaging passed on October 26. ZIP inspection confirmed
|
||||
the home and dynamic record fallback pages, both issued-lease endpoint strings
|
||||
in the packaged JavaScript, and exclusion of `mockServiceWorker.js`. This is
|
||||
package-content evidence, not a substitute for browser protocol acceptance.
|
||||
|
||||
## Published artifact evidence
|
||||
|
||||
Signed tag `v1.0.0-RC.37` points to
|
||||
`13abaa795d71f0a2213409fb910003920b82f3c3`. Source acceptance
|
||||
[40333860200](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/40333860200)
|
||||
passed all 18 real-backend browser shards and the aggregate gate. One shard
|
||||
required a targeted retry after a 40-minute system-package download timeout;
|
||||
its initial attempt never reached browser tests. CodeQL, unit/coverage,
|
||||
collaboration browser profiles and both image architectures passed.
|
||||
|
||||
Circle publisher 758 and node build 759 passed. The tag's separate acceptance
|
||||
run [40342829221](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/40342829221)
|
||||
published its images successfully, but its tablet shard and one targeted retry
|
||||
both timed out after 40 minutes downloading Ubuntu browser dependencies, before
|
||||
browser tests started. The aggregate gate therefore did not pass. Candidate CI
|
||||
now pins verified orb commit `eeb76914ad49fcdb2197529481de55d1e35a008a`, whose
|
||||
browser installation steps use bounded APT retries/timeouts and the official
|
||||
HTTPS mirror fallbacks. This changes candidate CI only; the signed RC tag and
|
||||
published artifacts remain unchanged. Updated-worker qualification
|
||||
[40357830356](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/40357830356)
|
||||
passed on candidate commit `c8f79847f3174425f02538c3c1b345eff86fda53`: all 18
|
||||
real-backend browser shards and the aggregate gate succeeded, with no failed
|
||||
jobs. This qualifies the corrected candidate workflow; it does not change the
|
||||
failed historical tag run or replace the earlier source acceptance evidence.
|
||||
|
||||
| Public artifact | SHA-256 / OCI digest |
|
||||
| --- | --- |
|
||||
| Maven POM | `4761d2fd07f60da226fb179837b053a30a5a198e8238189d3912fcb3a69849f9` |
|
||||
| Maven JAR | `c9fa2eb2381a47c8104f82f4e940d2bfa8ba67e2341298dde0de4e21ad3ff1ab` |
|
||||
| Multi-platform image index | `sha256:0b0fa9906eed60df1d23f28390888708587361a168b20fef92f49e6090723822` |
|
||||
| Linux AMD64 image | `sha256:a6e5ee0947b5e12cbf821741a2a72fdac7c705d1276f59bb9497807cdd020f75` |
|
||||
| Linux ARM64 image | `sha256:91999495b5958a2b1d67a43fd768c274690fea3eb3e79eaaab0e5924c8335eb4` |
|
||||
|
||||
The POM and JAR were downloaded directly from Maven Central; both match the
|
||||
repository's SHA-512 checksums. Coordinate and JAR resource checks passed.
|
||||
Repository checksums establish transfer integrity, not signature identity.
|
||||
The immutable AMD64 image served `/app/person` with HTTP 200 in a disposable
|
||||
container; ARM64 has hosted build verification, not a local runtime claim.
|
||||
|
||||
The downloaded public JAR passed Chromium acceptance with six mock identities
|
||||
across two independent JVMs using the published selectable-field backend pair
|
||||
above: each browser saw the other five users; all four viewers saw exactly two
|
||||
editors; editor field focus, retained drafts, table projection and departure
|
||||
passed. Redis connection/subscription metrics, persisted framework UP health
|
||||
results and standard health-record navigation also passed. There were no browser
|
||||
page errors, and all test-owned processes and Redis resources were cleaned up.
|
||||
The hosts use independently seeded memory records and mock authentication;
|
||||
these checks do not prove production identity-provider or shared-database save
|
||||
behavior. Kof22 staging adoption remains outstanding.
|
||||
|
||||
## Limits and rollback
|
||||
|
||||
This branch does not include separate frontend PR194/195 or the independent
|
||||
backend attachment, My Work and Redis-recovery follow-ups. Those remain tracked
|
||||
in the QQQ project.
|
||||
|
||||
If packaged acceptance fails, withhold adoption and fix the candidate. After
|
||||
deployment, restore a previously qualified frontend/backend pair together;
|
||||
RC.36 must not be substituted against a backend requiring issued leases.
|
||||
No host data migration, production deployment or GA approval is implied.
|
||||
@@ -1,5 +1,8 @@
|
||||
# Next UI — unreleased
|
||||
|
||||
[RC37 collaboration candidate](1.0.0-RC.37.md) records the published issued-lease
|
||||
frontend, artifact receipts, backend pairing and outstanding host qualification.
|
||||
|
||||
[RC36 preparation](1.0.0-RC.36.md) collects every change merged since [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35). RC35 is published and immutable. Changes merged after the RC36 preparation commit are listed below. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -20,6 +20,8 @@ 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
|
||||
|
||||
+11
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.36",
|
||||
"version": "1.0.0-RC.37",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
@@ -76,9 +76,9 @@
|
||||
"@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.0",
|
||||
"@storybook/nextjs-vite": "10.6.0",
|
||||
"@storybook/react": "10.6.0",
|
||||
"@storybook/addon-docs": "10.6.2",
|
||||
"@storybook/nextjs-vite": "10.6.2",
|
||||
"@storybook/react": "10.6.2",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -89,8 +89,8 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/ui": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"@vitest/ui": "^4.1.12",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "16.3.6",
|
||||
@@ -101,10 +101,11 @@
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.5.28",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "10.6.0",
|
||||
"storybook": "10.6.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "7.3.7",
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
@@ -116,7 +117,8 @@
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"@humanfs/node": ">=0.16.8",
|
||||
"@babel/core": ">=7.29.6 <8",
|
||||
"source-map-js": ">=1.2.2"
|
||||
"source-map-js": ">=1.2.2",
|
||||
"follow-redirects@<1.16.1": "1.16.1"
|
||||
},
|
||||
"ignoredOptionalDependencies": [
|
||||
"electron"
|
||||
|
||||
@@ -34,7 +34,7 @@ export default defineConfig({
|
||||
webServer: {
|
||||
command: `pnpm dev --port ${port}`,
|
||||
url: baseURL,
|
||||
env: { NEXT_PUBLIC_MOCK_API: 'true' },
|
||||
env: { NEXT_PUBLIC_MOCK_API: 'true', QQQ_NEXT_DIST_DIR: '.next-collaboration' },
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
||||
Generated
+147
-151
@@ -9,6 +9,7 @@ overrides:
|
||||
'@humanfs/node': '>=0.16.8'
|
||||
'@babel/core': '>=7.29.6 <8'
|
||||
source-map-js: '>=1.2.2'
|
||||
follow-redirects@<1.16.1: 1.16.1
|
||||
|
||||
importers:
|
||||
|
||||
@@ -151,14 +152,14 @@ importers:
|
||||
specifier: 1.64.0-alpha-2026-10-01
|
||||
version: 1.64.0-alpha-2026-10-01
|
||||
'@storybook/addon-docs':
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
|
||||
specifier: 10.6.2
|
||||
version: 10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
|
||||
'@storybook/nextjs-vite':
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
specifier: 10.6.2
|
||||
version: 10.6.2(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/react':
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
specifier: 10.6.2
|
||||
version: 10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
@@ -188,13 +189,13 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
version: 5.1.4(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(vitest@4.1.11)
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12(vitest@4.1.12)
|
||||
'@vitest/ui':
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(vitest@4.1.11)
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12(vitest@4.1.12)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.27
|
||||
version: 10.4.27(postcss@8.5.28)
|
||||
@@ -226,17 +227,20 @@ importers:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
storybook:
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
specifier: 10.6.2
|
||||
version: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
tailwindcss:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: 7.3.7
|
||||
version: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vitest:
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12(@types/node@22.19.11)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.12)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1850,19 +1854,19 @@ packages:
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@storybook/addon-docs@10.6.0':
|
||||
resolution: {integrity: sha512-Fk3rUQWeGyCfL170OkmRp6Ruyv7MHPb/xrKa6YNk7FlGmlVLi5tKzh2T/0J13vWEy8wiu2MV3IBu8iRsaUR+Cw==}
|
||||
'@storybook/addon-docs@10.6.2':
|
||||
resolution: {integrity: sha512-f7Rv/rzX1sk4lUDTeoIRkXWcWvNwLv7vBnfpFGb3f88MliB+M4ZekGKNiKtrmi44uLFt52gzwwPzj2eGDu0DQQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.6.0
|
||||
storybook: ^10.6.2
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@storybook/builder-vite@10.6.0':
|
||||
resolution: {integrity: sha512-7zt8tYB6dnSO8fbjUz91MHexGDpf9HrLlA3FAmlVb+2ZL3eWUs499L3I6BDUymcWu7oW5WCBJlB8ERj8ZLLzig==}
|
||||
'@storybook/builder-vite@10.6.2':
|
||||
resolution: {integrity: sha512-SzeQEGjvybdhP2coF4aIX6JX/7rZTrQCpRU3vAEO/R98Vr3xM8jdg7a3nvt91TeF/P8iWqljLIpaDjTdk2r/sQ==}
|
||||
peerDependencies:
|
||||
storybook: ^10.6.0
|
||||
storybook: ^10.6.2
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
'@storybook/global@5.0.0':
|
||||
@@ -1873,15 +1877,15 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@storybook/nextjs-vite@10.6.0':
|
||||
resolution: {integrity: sha512-TTXZsOUAXyezvZt+ICuIpYjzOkUXfEidUCbd/nnzczHaoxLWvVqwTzVHt9To7zsXWSfZFOxHQV9F4Uuzh74aKw==}
|
||||
'@storybook/nextjs-vite@10.6.2':
|
||||
resolution: {integrity: sha512-19H8L6FG5b8IKLeVkGMKTBMm1vag38QYMs/DZKn1tfwyUDOhr1XfZcZ+3PjlixzjgprIfgCz1JCRS7LxUFLyjA==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
next: ^14.1.0 || ^15.0.0 || ^16.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.6.0
|
||||
storybook: ^10.6.2
|
||||
typescript: '*'
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
@@ -1892,40 +1896,40 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@storybook/react-dom-shim@10.6.0':
|
||||
resolution: {integrity: sha512-ZE0l4xL6d00rKFeJhRDuh0t3r+/2ffhx47lIyaSWrTD5kuM3nDjKDxCIWwX4E3gLV+oNX8E1prXRMuEWJUJwJg==}
|
||||
'@storybook/react-dom-shim@10.6.2':
|
||||
resolution: {integrity: sha512-k0262Vqxjk3wHeg3g/QO/ENDrkC7kkbuwsFSXOdYr7Sl888tsF1DAS7uQ/gr+s/BRz1FLYRX7wBY3smEUV4MJg==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.6.0
|
||||
storybook: ^10.6.2
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@storybook/react-vite@10.6.0':
|
||||
resolution: {integrity: sha512-nc1dqnmjGOcDJvPCkAn4Hmu1gi7dNHHoibNmlBp0d1adosejELZxuNjHBkPl/KxZjbzP3jljQHnHB/guxd8e2g==}
|
||||
'@storybook/react-vite@10.6.2':
|
||||
resolution: {integrity: sha512-RfYSW9eefxRXk7IXKBTBkBYAspHKaxNVBR7mlXQAt+Z2bJZzj+S21mv6kBcpFIfAIT9wSIq8xMg0RbeR7CneVQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.6.0
|
||||
storybook: ^10.6.2
|
||||
typescript: '>= 4.9.x'
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@storybook/react@10.6.0':
|
||||
resolution: {integrity: sha512-0xfYYPRHcr5ta1hmXJCSezH4JGuUHJ+xVF9f1j+Y/MWEMOQE4wnaKWKsk/HLrDjZkT5hKfvmogt9b0yduP6zEQ==}
|
||||
'@storybook/react@10.6.2':
|
||||
resolution: {integrity: sha512-kNWUU6Q8TrLmEqAzYmNYhC9A3i3kbjFxqIcLL3eu+o4gDu0afp1Of6Sy6jIF2qPGH05A3r6FWMzD03y4Mm5xRw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
storybook: ^10.6.0
|
||||
storybook: ^10.6.2
|
||||
typescript: '>= 4.9.x'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
@@ -2399,11 +2403,11 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/coverage-v8@4.1.11':
|
||||
resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==}
|
||||
'@vitest/coverage-v8@4.1.12':
|
||||
resolution: {integrity: sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.11
|
||||
vitest: 4.1.11
|
||||
'@vitest/browser': 4.1.12
|
||||
vitest: 4.1.12
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
@@ -2411,11 +2415,11 @@ packages:
|
||||
'@vitest/expect@3.2.4':
|
||||
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
|
||||
|
||||
'@vitest/expect@4.1.11':
|
||||
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
|
||||
'@vitest/expect@4.1.12':
|
||||
resolution: {integrity: sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==}
|
||||
|
||||
'@vitest/mocker@4.1.11':
|
||||
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
|
||||
'@vitest/mocker@4.1.12':
|
||||
resolution: {integrity: sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -2428,31 +2432,31 @@ packages:
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
|
||||
|
||||
'@vitest/pretty-format@4.1.11':
|
||||
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
|
||||
'@vitest/pretty-format@4.1.12':
|
||||
resolution: {integrity: sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==}
|
||||
|
||||
'@vitest/runner@4.1.11':
|
||||
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
|
||||
'@vitest/runner@4.1.12':
|
||||
resolution: {integrity: sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==}
|
||||
|
||||
'@vitest/snapshot@4.1.11':
|
||||
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
|
||||
'@vitest/snapshot@4.1.12':
|
||||
resolution: {integrity: sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==}
|
||||
|
||||
'@vitest/spy@3.2.4':
|
||||
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
|
||||
|
||||
'@vitest/spy@4.1.11':
|
||||
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
|
||||
'@vitest/spy@4.1.12':
|
||||
resolution: {integrity: sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==}
|
||||
|
||||
'@vitest/ui@4.1.11':
|
||||
resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==}
|
||||
'@vitest/ui@4.1.12':
|
||||
resolution: {integrity: sha512-eYUTHtsp52Fu6YGUw9T9173bba7YUr9j/Xj9CdhUaOb8AppLKcknHUaiZQeqJ8MiJ8C2VyKiZuUGSKdvPIKQKA==}
|
||||
peerDependencies:
|
||||
vitest: 4.1.11
|
||||
vitest: 4.1.12
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||
|
||||
'@vitest/utils@4.1.11':
|
||||
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
|
||||
'@vitest/utils@4.1.12':
|
||||
resolution: {integrity: sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==}
|
||||
|
||||
'@webcontainer/env@1.1.1':
|
||||
resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==}
|
||||
@@ -2467,11 +2471,6 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.18.0:
|
||||
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.19.0:
|
||||
resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -3275,8 +3274,8 @@ packages:
|
||||
flatted@3.4.4:
|
||||
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
follow-redirects@1.16.1:
|
||||
resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
@@ -4732,8 +4731,8 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
storybook@10.6.0:
|
||||
resolution: {integrity: sha512-H48X6AURAFTpfN2zEux6vNesee5Oq8Gxj1IanYmblPZ218sfKSeyyerF1NC7GD7BrruoC8L4/cU0zII4y+d6zw==}
|
||||
storybook@10.6.2:
|
||||
resolution: {integrity: sha512-qHDINMiapxFUxYb+xC3j8u7Y+85X3u+/PlytE/wmnyg+bU0agCW3jUmWP4vIh2MioO2jTK/2B3Lcw6IpRFzQBA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -5078,11 +5077,11 @@ packages:
|
||||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
vite-plugin-storybook-nextjs@10.6.0:
|
||||
resolution: {integrity: sha512-BhwBZO3KEsO9IhGWOIuHSyQuedRHZ/ZfcV11kldPDH7gpOg5EP5cZ58hsSNjhtf45o806TfpPkQHFaQeYcvYlw==}
|
||||
vite-plugin-storybook-nextjs@10.6.2:
|
||||
resolution: {integrity: sha512-ie7ffznavYmcbP5tk5PADc2g6TicGXsIGcajLMqkFxe08v7HjZ6WslxqXLMWOS/OOC/nJapxEHDdNomWl+P3pA==}
|
||||
peerDependencies:
|
||||
next: ^14.1.0 || ^15.0.0 || ^16.0.0
|
||||
storybook: ^10.6.0
|
||||
storybook: ^10.6.2
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
vite-tsconfig-paths@5.1.4:
|
||||
@@ -5093,8 +5092,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
vite@7.3.6:
|
||||
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
|
||||
vite@7.3.7:
|
||||
resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -5133,20 +5132,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.11:
|
||||
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
|
||||
vitest@4.1.12:
|
||||
resolution: {integrity: sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.11
|
||||
'@vitest/browser-preview': 4.1.11
|
||||
'@vitest/browser-webdriverio': 4.1.11
|
||||
'@vitest/coverage-istanbul': 4.1.11
|
||||
'@vitest/coverage-v8': 4.1.11
|
||||
'@vitest/ui': 4.1.11
|
||||
'@vitest/browser-playwright': 4.1.12
|
||||
'@vitest/browser-preview': 4.1.12
|
||||
'@vitest/browser-webdriverio': ^4.1.11
|
||||
'@vitest/coverage-istanbul': 4.1.12
|
||||
'@vitest/coverage-v8': 4.1.12
|
||||
'@vitest/ui': 4.1.12
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -5926,11 +5925,11 @@ snapshots:
|
||||
'@types/yargs': 17.0.35
|
||||
chalk: 4.1.2
|
||||
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
glob: 13.0.6
|
||||
react-docgen-typescript: 2.4.0(typescript@5.9.3)
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -6723,14 +6722,14 @@ snapshots:
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@storybook/addon-docs@10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))':
|
||||
'@storybook/addon-docs@10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@storybook/icons': 2.1.0(react@19.2.4)
|
||||
'@storybook/react-dom-shim': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
|
||||
'@storybook/react-dom-shim': 10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
ts-dedent: 2.3.0
|
||||
unplugin: 2.3.11
|
||||
optionalDependencies:
|
||||
@@ -6738,11 +6737,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react-dom'
|
||||
|
||||
'@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@storybook/builder-vite@10.6.2(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
ts-dedent: 2.3.0
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
|
||||
'@storybook/global@5.0.0': {}
|
||||
|
||||
@@ -6750,18 +6749,18 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@storybook/nextjs-vite@10.6.2(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/react': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
'@storybook/react-vite': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/builder-vite': 10.6.2(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/react': 10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
'@storybook/react-vite': 10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
next: 16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4)
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite-plugin-storybook-nextjs: 10.6.2(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
@@ -6772,30 +6771,30 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@storybook/react-dom-shim@10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))':
|
||||
'@storybook/react-dom-shim@10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@storybook/react-vite@10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@storybook/react-vite@10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@rollup/pluginutils': 5.4.0(rollup@4.64.6)
|
||||
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/react': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
'@storybook/builder-vite': 10.6.2(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/react': 10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
empathic: 2.1.0
|
||||
magic-string: 1.4.2
|
||||
react: 19.2.4
|
||||
react-docgen: 8.0.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
resolve: 1.22.12
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
tsconfig-paths: 4.2.0
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -6804,15 +6803,15 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@storybook/react@10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)':
|
||||
'@storybook/react@10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
'@storybook/react-dom-shim': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
|
||||
'@storybook/react-dom-shim': 10.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
|
||||
react: 19.2.4
|
||||
react-docgen: 8.0.4
|
||||
react-docgen-typescript: 2.4.0(typescript@5.9.3)
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
@@ -7252,7 +7251,7 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-react@5.1.4(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@vitejs/plugin-react@5.1.4(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7)
|
||||
@@ -7260,14 +7259,14 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.3
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@4.1.11(vitest@4.1.11)':
|
||||
'@vitest/coverage-v8@4.1.12(vitest@4.1.12)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.11
|
||||
'@vitest/utils': 4.1.12
|
||||
ast-v8-to-istanbul: 1.0.7
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
@@ -7276,7 +7275,7 @@ snapshots:
|
||||
obug: 2.2.1
|
||||
std-env: 4.2.0
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
vitest: 4.1.12(@types/node@22.19.11)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.12)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
|
||||
'@vitest/expect@3.2.4':
|
||||
dependencies:
|
||||
@@ -7286,41 +7285,41 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/expect@4.1.11':
|
||||
'@vitest/expect@4.1.12':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
'@vitest/spy': 4.1.12
|
||||
'@vitest/utils': 4.1.12
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/mocker@4.1.11(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@vitest/mocker@4.1.12(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/spy': 4.1.12
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3)
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/pretty-format@4.1.11':
|
||||
'@vitest/pretty-format@4.1.12':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/runner@4.1.11':
|
||||
'@vitest/runner@4.1.12':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.11
|
||||
'@vitest/utils': 4.1.12
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.11':
|
||||
'@vitest/snapshot@4.1.12':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
'@vitest/pretty-format': 4.1.12
|
||||
'@vitest/utils': 4.1.12
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
@@ -7328,18 +7327,18 @@ snapshots:
|
||||
dependencies:
|
||||
tinyspy: 4.0.6
|
||||
|
||||
'@vitest/spy@4.1.11': {}
|
||||
'@vitest/spy@4.1.12': {}
|
||||
|
||||
'@vitest/ui@4.1.11(vitest@4.1.11)':
|
||||
'@vitest/ui@4.1.12(vitest@4.1.12)':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.11
|
||||
'@vitest/utils': 4.1.12
|
||||
fflate: 0.8.3
|
||||
flatted: 3.4.4
|
||||
pathe: 2.0.3
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
vitest: 4.1.12(@types/node@22.19.11)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.12)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -7347,9 +7346,9 @@ snapshots:
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/utils@4.1.11':
|
||||
'@vitest/utils@4.1.12':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/pretty-format': 4.1.12
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
@@ -7361,10 +7360,7 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
acorn@8.18.0: {}
|
||||
|
||||
acorn@8.19.0:
|
||||
optional: true
|
||||
acorn@8.19.0: {}
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
@@ -7526,7 +7522,7 @@ snapshots:
|
||||
|
||||
axios@1.20.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
follow-redirects: 1.16.1
|
||||
form-data: 4.0.6
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
@@ -8328,7 +8324,7 @@ snapshots:
|
||||
|
||||
flatted@3.4.4: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
follow-redirects@1.16.1: {}
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
@@ -10193,7 +10189,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4):
|
||||
storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4):
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
'@storybook/icons': 2.1.0(react@19.2.4)
|
||||
@@ -10530,7 +10526,7 @@ snapshots:
|
||||
unplugin@2.3.11:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
acorn: 8.18.0
|
||||
acorn: 8.19.0
|
||||
picomatch: 4.0.7
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
@@ -10619,7 +10615,7 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-plugin-storybook-nextjs@10.6.0(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
vite-plugin-storybook-nextjs@10.6.2(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.64.6)(storybook@10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
'@next/env': 16.4.0
|
||||
'@rollup/pluginutils': 5.4.0(rollup@4.64.6)
|
||||
@@ -10629,27 +10625,27 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
probe-image-size: 7.4.0
|
||||
semver: 7.8.5
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
storybook: 10.6.2(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
ts-dedent: 2.3.0
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1):
|
||||
vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1):
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
@@ -10665,15 +10661,15 @@ snapshots:
|
||||
terser: 5.51.2
|
||||
yaml: 2.9.1
|
||||
|
||||
vitest@4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
vitest@4.1.12(@types/node@22.19.11)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.12)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.11
|
||||
'@vitest/mocker': 4.1.11(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/runner': 4.1.11
|
||||
'@vitest/snapshot': 4.1.11
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
'@vitest/expect': 4.1.12
|
||||
'@vitest/mocker': 4.1.12(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@vitest/pretty-format': 4.1.12
|
||||
'@vitest/runner': 4.1.12
|
||||
'@vitest/snapshot': 4.1.12
|
||||
'@vitest/spy': 4.1.12
|
||||
'@vitest/utils': 4.1.12
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
magic-string: 0.30.21
|
||||
@@ -10685,12 +10681,12 @@ snapshots:
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.1
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.11
|
||||
'@vitest/coverage-v8': 4.1.11(vitest@4.1.11)
|
||||
'@vitest/ui': 4.1.11(vitest@4.1.11)
|
||||
'@vitest/coverage-v8': 4.1.12(vitest@4.1.12)
|
||||
'@vitest/ui': 4.1.12(vitest@4.1.12)
|
||||
jsdom: 26.1.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.36</revision>
|
||||
<revision>1.0.0-RC.37</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
|
||||
@@ -180,10 +180,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Sync user ID to QContext
|
||||
useEffect(() => {
|
||||
if (user?.email) {
|
||||
setUserId(user.email)
|
||||
}
|
||||
}, [user?.email, setUserId])
|
||||
setUserId(user?.id ?? user?.email)
|
||||
}, [user?.id, user?.email, setUserId])
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
|
||||
@@ -167,6 +167,7 @@ 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"
|
||||
|
||||
@@ -1,309 +1,318 @@
|
||||
/*
|
||||
* 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, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as api from '@/lib/api/collaboration'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import type { RealtimeEvent } from '@/lib/realtime/client'
|
||||
import { ShareDialog } from './ShareDialog'
|
||||
import { RequestAccess } from './RequestAccess'
|
||||
import { TasksTab } from './TasksTab'
|
||||
import { CollaborationEdit } from './CollaborationEdit'
|
||||
import { MyWork } from './MyWork'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
let receive: ((event: RealtimeEvent) => void) | undefined
|
||||
vi.mock('@/lib/hooks/use-realtime-channel', () => ({
|
||||
useRealtimeChannel: (_channel: string, callback?: typeof receive) => {
|
||||
receive = callback
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/hooks/use-collaboration-presence', () => ({
|
||||
useCollaborationPresence: () => ({ viewers: [] }),
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', async () => {
|
||||
const { createContext } = await import('react')
|
||||
return { useQContext: () => ({ userId: 'me' }), QContext: createContext({ userId: 'me' }) }
|
||||
})
|
||||
vi.mock('@/lib/hooks/use-collaboration-core', () => ({
|
||||
useCollaborationAvailable: () => ({ data: true }),
|
||||
}))
|
||||
vi.mock('@/lib/hooks/use-metadata', () => ({
|
||||
useMetaData: () => ({ data: qInstance }),
|
||||
useTableMetaData: () => ({ data: qInstance.tables.person }),
|
||||
}))
|
||||
vi.mock('@/lib/api/tables', () => ({
|
||||
getRecord: vi.fn(),
|
||||
queryRecords: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
searchPeople: vi.fn(),
|
||||
mentionCandidates: vi.fn(),
|
||||
getRecordLink: vi.fn(),
|
||||
previewShares: vi.fn(),
|
||||
shareRecord: vi.fn(),
|
||||
requestAccess: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
getMyWork: vi.fn(),
|
||||
listMyTasks: vi.fn(),
|
||||
}))
|
||||
const record = { table: 'person', pk: '1' }
|
||||
function mount(node: React.ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>)
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.searchPeople).mockResolvedValue({
|
||||
items: [
|
||||
{ userId: 'sam', displayName: 'Sam Parker', email: null },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
|
||||
{ userId: 'unknown', displayName: 'Taylor', email: null },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.previewShares).mockImplementation(async (_record, ids) => ({
|
||||
recipients: ids.map((id) => ({
|
||||
userId: id,
|
||||
displayName: id === 'sam' ? 'Sam Parker' : id,
|
||||
canRead: id === 'unknown' ? null : id === 'alex',
|
||||
})),
|
||||
}))
|
||||
vi.mocked(api.mentionCandidates).mockResolvedValue({
|
||||
items: [
|
||||
{ person: { userId: 'sam', displayName: 'Sam Parker' }, canRead: 'false' },
|
||||
{ person: { userId: 'alex', displayName: 'Alex Rivera' }, canRead: 'true' },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.getRecordLink).mockResolvedValue({ url: 'https://example.test/app/person/1' })
|
||||
vi.mocked(api.shareRecord).mockResolvedValue({
|
||||
items: [
|
||||
{ id: 1, toUserId: 'sam', recipientCouldRead: false },
|
||||
{ id: 2, toUserId: 'alex', recipientCouldRead: true },
|
||||
],
|
||||
})
|
||||
vi.mocked(api.listTasks).mockResolvedValue({
|
||||
items: [{ id: 1, title: 'Review order', status: 'OPEN' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.listMyTasks).mockResolvedValue({ items: [], nextCursor: null })
|
||||
vi.mocked(api.createTask).mockResolvedValue({ id: 2 })
|
||||
vi.mocked(api.patchTask).mockResolvedValue({ id: 1 })
|
||||
vi.mocked(getRecord).mockResolvedValue({
|
||||
tableName: 'person',
|
||||
values: { id: 1, firstName: 'Their value', modifyDate: 'v2' },
|
||||
})
|
||||
})
|
||||
it('previews inaccessible and unknown recipients, then reports notified and offered access', async () => {
|
||||
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
|
||||
expect(await screen.findByText("can't open this record")).toBeVisible()
|
||||
expect(screen.getByText('unknown')).toBeVisible()
|
||||
fireEvent.click(screen.getByLabelText(/Sam Parker/))
|
||||
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
expect(await screen.findByText('Sam Parker: Offered access')).toBeVisible()
|
||||
expect(screen.getByText('alex: Notified')).toBeVisible()
|
||||
})
|
||||
it('leaves the signed-in user out of share recipients even when the service returns them', async () => {
|
||||
vi.mocked(api.searchPeople).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ userId: 'ME', displayName: 'Me Myself', email: null },
|
||||
{ userId: 'x9', displayName: 'Mail Match', email: 'Me' },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
|
||||
expect(await screen.findByLabelText(/Alex Rivera/)).toBeVisible()
|
||||
expect(screen.queryByLabelText(/Me Myself/)).toBeNull()
|
||||
expect(screen.queryByLabelText(/Mail Match/)).toBeNull()
|
||||
})
|
||||
it('uses identical non-disclosing access copy even when the server reports missing record', async () => {
|
||||
vi.mocked(api.requestAccess).mockRejectedValue(
|
||||
new Error('Record does not exist: sensitive detail')
|
||||
)
|
||||
mount(<RequestAccess tableName="person" recordId="missing" shareId={12} />)
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), {
|
||||
target: { value: 'Please review' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request access' }))
|
||||
expect(await screen.findByRole('alert')).not.toHaveTextContent('sensitive detail')
|
||||
expect(document.body).not.toHaveTextContent('does not exist')
|
||||
expect(api.requestAccess).toHaveBeenCalledWith({
|
||||
tableName: 'person',
|
||||
recordId: 'missing',
|
||||
shareId: 12,
|
||||
message: 'Please review',
|
||||
})
|
||||
})
|
||||
it('shows a stream warning, compares theirs and mine, and uses the same dialog on 409', async () => {
|
||||
const props = {
|
||||
table: qInstance.tables.person,
|
||||
pk: '1',
|
||||
available: true,
|
||||
realtime: true,
|
||||
error: null,
|
||||
getValues: () => ({ firstName: 'My value' }),
|
||||
onViewers: vi.fn(),
|
||||
onOverwrite: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
pending: false,
|
||||
}
|
||||
mount(<CollaborationEdit {...props} />)
|
||||
act(() =>
|
||||
receive?.({
|
||||
event: 'record.changed',
|
||||
data: { table: 'person', pk: '1', changedBy: 'sam', fields: ['firstName'] },
|
||||
})
|
||||
)
|
||||
expect(await screen.findByText(/Sam Parker saved changes/)).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review changes' }))
|
||||
expect(await screen.findByText('Their value')).toBeVisible()
|
||||
expect(screen.getByText('My value')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Keep mine (overwrite)' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use theirs' }))
|
||||
expect(props.onReload).toHaveBeenCalled()
|
||||
})
|
||||
it('opens 409 review automatically and overwrites only with the refreshed version', async () => {
|
||||
const overwrite = vi.fn()
|
||||
const error = Object.assign(new Error('Conflict'), {
|
||||
status: 409,
|
||||
response: {
|
||||
status: 409,
|
||||
data: { currentVersion: 'v2', changedFields: ['firstName'], modifiedByUserId: 'sam' },
|
||||
},
|
||||
})
|
||||
mount(
|
||||
<CollaborationEdit
|
||||
table={qInstance.tables.person}
|
||||
pk="1"
|
||||
available
|
||||
realtime
|
||||
error={error}
|
||||
getValues={() => ({ firstName: 'Mine' })}
|
||||
onViewers={vi.fn()}
|
||||
onOverwrite={overwrite}
|
||||
onReload={vi.fn()}
|
||||
pending={false}
|
||||
/>
|
||||
)
|
||||
expect(await screen.findByText('Their value')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep mine (overwrite)' }))
|
||||
expect(overwrite).toHaveBeenCalledWith('v2')
|
||||
})
|
||||
it('refuses unreadable assignees, creates and toggles a task', async () => {
|
||||
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Complete Review order' }))
|
||||
await waitFor(() => expect(api.patchTask).toHaveBeenCalledWith(1, { status: 'DONE' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create task' }))
|
||||
await waitFor(() => expect(screen.getByLabelText(/Sam Parker/)).toBeDisabled())
|
||||
await waitFor(() => expect(screen.getByLabelText(/Alex Rivera/)).toBeEnabled())
|
||||
expect(api.previewShares).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), {
|
||||
target: { value: 'New task' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save task' }))
|
||||
await waitFor(() =>
|
||||
expect(api.createTask).toHaveBeenCalledWith(
|
||||
record,
|
||||
expect.objectContaining({ title: 'New task', assigneeUserId: 'alex' })
|
||||
)
|
||||
)
|
||||
})
|
||||
it('names task people by display name and falls back to the user id', async () => {
|
||||
vi.mocked(api.listTasks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Named',
|
||||
status: 'DONE',
|
||||
assigneeUserId: 'alex',
|
||||
assigneeDisplayName: 'Alex Rivera',
|
||||
createdByUserId: 'morgan',
|
||||
createdByDisplayName: 'Morgan Lee',
|
||||
completedByUserId: 'sam',
|
||||
completedByDisplayName: 'Sam Parker',
|
||||
},
|
||||
{ id: 2, title: 'Unnamed', status: 'OPEN', assigneeUserId: 'u7', createdByUserId: 'u8' },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
|
||||
expect(await screen.findByText('Assigned to u7')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Show done' }))
|
||||
expect(await screen.findByText('Assigned to Alex Rivera')).toBeVisible()
|
||||
expect(screen.getByText('Created by Morgan Lee · Completed by Sam Parker')).toBeVisible()
|
||||
expect(screen.getByText('Assigned to u7')).toBeVisible()
|
||||
expect(screen.getByText('Created by u8')).toBeVisible()
|
||||
})
|
||||
it('renders My work with overdue task, assigned table and unread mentions links', async () => {
|
||||
vi.mocked(api.getMyWork).mockResolvedValue({
|
||||
openTaskCount: 1,
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Review order',
|
||||
status: 'OPEN',
|
||||
tableName: 'person',
|
||||
recordId: '1',
|
||||
dueAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
assignedRecordsByTable: { person: 1 },
|
||||
unreadMentionCount: 3,
|
||||
})
|
||||
vi.mocked(api.listMyTasks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Review order',
|
||||
status: 'OPEN',
|
||||
tableName: 'person',
|
||||
recordId: '1',
|
||||
dueAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<MyWork />)
|
||||
expect(await screen.findByRole('link', { name: 'Review order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
expect(await screen.findByRole('link', { name: 'Alice' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
expect(screen.getByText(/Overdue:/)).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: '3 unread mentions' })).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: /People \(1\)|Person \(1\)/ })).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('CurrentUser')
|
||||
)
|
||||
})
|
||||
/*
|
||||
* 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, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as api from '@/lib/api/collaboration'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import type { RealtimeEvent } from '@/lib/realtime/client'
|
||||
import { ShareDialog } from './ShareDialog'
|
||||
import { RequestAccess } from './RequestAccess'
|
||||
import { TasksTab } from './TasksTab'
|
||||
import { CollaborationEdit } from './CollaborationEdit'
|
||||
import { MyWork } from './MyWork'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
let receive: ((event: RealtimeEvent) => void) | undefined
|
||||
vi.mock('@/lib/hooks/use-realtime-channel', () => ({
|
||||
useRealtimeChannel: (_channel: string, callback?: typeof receive) => {
|
||||
receive = callback
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/hooks/use-collaboration-presence', () => ({
|
||||
useCollaborationPresence: () => ({ viewers: [] }),
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', async () => {
|
||||
const { createContext } = await import('react')
|
||||
return { useQContext: () => ({ userId: 'me' }), QContext: createContext({ userId: 'me' }) }
|
||||
})
|
||||
vi.mock('@/lib/hooks/use-collaboration-core', () => ({
|
||||
useCollaborationAvailable: () => ({ data: true }),
|
||||
}))
|
||||
vi.mock('@/lib/hooks/use-metadata', () => ({
|
||||
useMetaData: () => ({ data: qInstance }),
|
||||
useTableMetaData: () => ({ data: qInstance.tables.person }),
|
||||
}))
|
||||
vi.mock('@/lib/api/tables', () => ({
|
||||
getRecord: vi.fn(),
|
||||
queryRecords: vi.fn().mockResolvedValue({
|
||||
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
searchPeople: vi.fn(),
|
||||
mentionCandidates: vi.fn(),
|
||||
getRecordLink: vi.fn(),
|
||||
previewShares: vi.fn(),
|
||||
shareRecord: vi.fn(),
|
||||
requestAccess: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
getMyWork: vi.fn(),
|
||||
markMentionsRead: vi.fn().mockResolvedValue({ success: true }),
|
||||
listMyTasks: vi.fn(),
|
||||
}))
|
||||
const record = { table: 'person', pk: '1' }
|
||||
function mount(node: React.ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>)
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.searchPeople).mockResolvedValue({
|
||||
items: [
|
||||
{ userId: 'sam', displayName: 'Sam Parker', email: null },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
|
||||
{ userId: 'unknown', displayName: 'Taylor', email: null },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.previewShares).mockImplementation(async (_record, ids) => ({
|
||||
recipients: ids.map((id) => ({
|
||||
userId: id,
|
||||
displayName: id === 'sam' ? 'Sam Parker' : id,
|
||||
canRead: id === 'unknown' ? null : id === 'alex',
|
||||
})),
|
||||
}))
|
||||
vi.mocked(api.mentionCandidates).mockResolvedValue({
|
||||
items: [
|
||||
{ person: { userId: 'sam', displayName: 'Sam Parker' }, canRead: 'false' },
|
||||
{ person: { userId: 'alex', displayName: 'Alex Rivera' }, canRead: 'true' },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.getRecordLink).mockResolvedValue({ url: 'https://example.test/app/person/1' })
|
||||
vi.mocked(api.shareRecord).mockResolvedValue({
|
||||
items: [
|
||||
{ id: 1, toUserId: 'sam', recipientCouldRead: false },
|
||||
{ id: 2, toUserId: 'alex', recipientCouldRead: true },
|
||||
],
|
||||
})
|
||||
vi.mocked(api.listTasks).mockResolvedValue({
|
||||
items: [{ id: 1, title: 'Review order', status: 'OPEN' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.listMyTasks).mockResolvedValue({ items: [], nextCursor: null })
|
||||
vi.mocked(api.createTask).mockResolvedValue({ id: 2 })
|
||||
vi.mocked(api.patchTask).mockResolvedValue({ id: 1 })
|
||||
vi.mocked(getRecord).mockResolvedValue({
|
||||
tableName: 'person',
|
||||
values: { id: 1, firstName: 'Their value', modifyDate: 'v2' },
|
||||
})
|
||||
})
|
||||
it('previews inaccessible and unknown recipients, then reports notified and offered access', async () => {
|
||||
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
|
||||
expect(await screen.findByText("can't open this record")).toBeVisible()
|
||||
expect(screen.getByText('unknown')).toBeVisible()
|
||||
fireEvent.click(screen.getByLabelText(/Sam Parker/))
|
||||
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
expect(await screen.findByText('Sam Parker: Offered access')).toBeVisible()
|
||||
expect(screen.getByText('alex: Notified')).toBeVisible()
|
||||
})
|
||||
it('leaves the signed-in user out of share recipients even when the service returns them', async () => {
|
||||
vi.mocked(api.searchPeople).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ userId: 'ME', displayName: 'Me Myself', email: null },
|
||||
{ userId: 'x9', displayName: 'Mail Match', email: 'Me' },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
|
||||
expect(await screen.findByLabelText(/Alex Rivera/)).toBeVisible()
|
||||
expect(screen.queryByLabelText(/Me Myself/)).toBeNull()
|
||||
expect(screen.queryByLabelText(/Mail Match/)).toBeNull()
|
||||
})
|
||||
it('uses identical non-disclosing access copy even when the server reports missing record', async () => {
|
||||
vi.mocked(api.requestAccess).mockRejectedValue(
|
||||
new Error('Record does not exist: sensitive detail')
|
||||
)
|
||||
mount(<RequestAccess tableName="person" recordId="missing" shareId={12} />)
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), {
|
||||
target: { value: 'Please review' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request access' }))
|
||||
expect(await screen.findByRole('alert')).not.toHaveTextContent('sensitive detail')
|
||||
expect(document.body).not.toHaveTextContent('does not exist')
|
||||
expect(api.requestAccess).toHaveBeenCalledWith({
|
||||
tableName: 'person',
|
||||
recordId: 'missing',
|
||||
shareId: 12,
|
||||
message: 'Please review',
|
||||
})
|
||||
})
|
||||
it('shows a stream warning, compares theirs and mine, and uses the same dialog on 409', async () => {
|
||||
const props = {
|
||||
table: qInstance.tables.person,
|
||||
pk: '1',
|
||||
available: true,
|
||||
realtime: true,
|
||||
error: null,
|
||||
getValues: () => ({ firstName: 'My value' }),
|
||||
onViewers: vi.fn(),
|
||||
onOverwrite: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
pending: false,
|
||||
}
|
||||
mount(<CollaborationEdit {...props} />)
|
||||
act(() =>
|
||||
receive?.({
|
||||
event: 'record.changed',
|
||||
data: { table: 'person', pk: '1', changedBy: 'sam', fields: ['firstName'] },
|
||||
})
|
||||
)
|
||||
expect(await screen.findByText(/Sam Parker saved changes/)).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review changes' }))
|
||||
expect(await screen.findByText('Their value')).toBeVisible()
|
||||
expect(screen.getByText('My value')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Keep mine (overwrite)' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use theirs' }))
|
||||
expect(props.onReload).toHaveBeenCalled()
|
||||
})
|
||||
it('opens 409 review automatically and overwrites only with the refreshed version', async () => {
|
||||
const overwrite = vi.fn()
|
||||
const error = Object.assign(new Error('Conflict'), {
|
||||
status: 409,
|
||||
response: {
|
||||
status: 409,
|
||||
data: { currentVersion: 'v2', changedFields: ['firstName'], modifiedByUserId: 'sam' },
|
||||
},
|
||||
})
|
||||
mount(
|
||||
<CollaborationEdit
|
||||
table={qInstance.tables.person}
|
||||
pk="1"
|
||||
available
|
||||
realtime
|
||||
error={error}
|
||||
getValues={() => ({ firstName: 'Mine' })}
|
||||
onViewers={vi.fn()}
|
||||
onOverwrite={overwrite}
|
||||
onReload={vi.fn()}
|
||||
pending={false}
|
||||
/>
|
||||
)
|
||||
expect(await screen.findByText('Their value')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep mine (overwrite)' }))
|
||||
expect(overwrite).toHaveBeenCalledWith('v2')
|
||||
})
|
||||
it('refuses unreadable assignees, creates and toggles a task', async () => {
|
||||
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Complete Review order' }))
|
||||
await waitFor(() => expect(api.patchTask).toHaveBeenCalledWith(1, { status: 'DONE' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create task' }))
|
||||
await waitFor(() => expect(screen.getByLabelText(/Sam Parker/)).toBeDisabled())
|
||||
await waitFor(() => expect(screen.getByLabelText(/Alex Rivera/)).toBeEnabled())
|
||||
expect(api.previewShares).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), {
|
||||
target: { value: 'New task' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save task' }))
|
||||
await waitFor(() =>
|
||||
expect(api.createTask).toHaveBeenCalledWith(
|
||||
record,
|
||||
expect.objectContaining({ title: 'New task', assigneeUserId: 'alex' })
|
||||
)
|
||||
)
|
||||
})
|
||||
it('names task people by display name and falls back to the user id', async () => {
|
||||
vi.mocked(api.listTasks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Named',
|
||||
status: 'DONE',
|
||||
assigneeUserId: 'alex',
|
||||
assigneeDisplayName: 'Alex Rivera',
|
||||
createdByUserId: 'morgan',
|
||||
createdByDisplayName: 'Morgan Lee',
|
||||
completedByUserId: 'sam',
|
||||
completedByDisplayName: 'Sam Parker',
|
||||
},
|
||||
{ id: 2, title: 'Unnamed', status: 'OPEN', assigneeUserId: 'u7', createdByUserId: 'u8' },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
|
||||
expect(await screen.findByText('Assigned to u7')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Show done' }))
|
||||
expect(await screen.findByText('Assigned to Alex Rivera')).toBeVisible()
|
||||
expect(screen.getByText('Created by Morgan Lee · Completed by Sam Parker')).toBeVisible()
|
||||
expect(screen.getByText('Assigned to u7')).toBeVisible()
|
||||
expect(screen.getByText('Created by u8')).toBeVisible()
|
||||
})
|
||||
it('renders My work with overdue task, assigned table and unread mentions links', async () => {
|
||||
vi.mocked(api.getMyWork).mockResolvedValue({
|
||||
openTaskCount: 1,
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Review order',
|
||||
status: 'OPEN',
|
||||
tableName: 'person',
|
||||
recordId: '1',
|
||||
dueAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
assignedRecordsByTable: { person: 1 },
|
||||
unreadMentionCount: 3,
|
||||
})
|
||||
vi.mocked(api.listMyTasks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Review order',
|
||||
status: 'OPEN',
|
||||
tableName: 'person',
|
||||
recordId: '1',
|
||||
dueAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<MyWork />)
|
||||
expect(await screen.findByRole('link', { name: 'Review order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
expect(await screen.findByRole('link', { name: 'Alice' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
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')
|
||||
)
|
||||
})
|
||||
@@ -17,11 +17,11 @@
|
||||
/** @file Personal tasks, assigned record links and unread mentions. */
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } 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 } from '@/lib/api/collaboration'
|
||||
import { getMyWork, listMyTasks, markMentionsRead } from '@/lib/api/collaboration'
|
||||
import { queryRecords } from '@/lib/api/tables'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { collaborationButton } from './CollaborationDialog'
|
||||
@@ -78,6 +78,11 @@ 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({
|
||||
@@ -158,6 +163,17 @@ 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"
|
||||
|
||||
@@ -24,6 +24,8 @@ 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'
|
||||
@@ -66,7 +68,23 @@ export function RecordCollaborationHeader({
|
||||
enabled: available && !!features,
|
||||
})
|
||||
const assignment = useMutation({
|
||||
mutationFn: () => putAssignment(target, { [role]: selected[0] ?? null }),
|
||||
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 })
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setAssign(false)
|
||||
void cache.invalidateQueries({ queryKey: queryKeys.collaborationRecord(table.name, pk) })
|
||||
@@ -89,6 +107,7 @@ 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 && (
|
||||
@@ -151,11 +170,11 @@ export function RecordCollaborationHeader({
|
||||
<PeoplePicker record={target} selected={selected} onChange={setSelected} requireRead />
|
||||
<button
|
||||
className={collaborationButton}
|
||||
disabled={assignment.isPending || !selected.length}
|
||||
disabled={assignment.isPending}
|
||||
onClick={() => assignment.mutate()}
|
||||
data-qqq-id="assignment-save"
|
||||
>
|
||||
Assign
|
||||
{selected.length ? 'Assign' : 'Clear assignment'}
|
||||
</button>
|
||||
{assignment.isError && <p role="alert">{assignment.error.message}</p>}
|
||||
</CollaborationDialog>
|
||||
@@ -176,7 +195,11 @@ 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>{pluralize(presence.viewers.length, 'person', 'people')} viewing</span>
|
||||
<span role={presence.unavailable ? 'status' : undefined}>
|
||||
{presence.unavailable
|
||||
? 'Presence temporarily unavailable'
|
||||
: `${pluralize(presence.viewers.length, 'person', 'people')} viewing`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
vi.mock('@/lib/api/tables', () => ({ getRecord: vi.fn(() => new Promise(() => {})), queryRecords: vi.fn(() => new Promise(() => {})) }))
|
||||
@@ -87,6 +87,8 @@ describe('WidgetRenderer', () => {
|
||||
<WidgetRenderer widgetMetaData={{ name: 'untyped', label: 'Untyped', hasPermission: true }} data={{ type: 'chart', title: 'Persons', chartData: { labels: ['Jan'], datasets: [{ label: 'People', data: [17] }] } }} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
// Await the real lazy chunk before checking content under a busy full-suite worker pool.
|
||||
await act(async () => { await vi.dynamicImportSettled() })
|
||||
expect(await screen.findByText('Persons', { selector: '[data-qqq-id="chart-title-untyped"]' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -46,14 +46,16 @@ it('searches the directory with a bounded page and normalizes each person', asyn
|
||||
expect(result.items[1].userId).toBeNull()
|
||||
})
|
||||
|
||||
it('sends the last presence lease as a JSON beacon, and tolerates browsers without one', () => {
|
||||
const sendBeacon = vi.fn()
|
||||
Object.defineProperty(navigator, 'sendBeacon', { value: sendBeacon, configurable: true })
|
||||
beaconPresence({ table: 'person', pk: '1', mode: 'VIEWING' } as never)
|
||||
expect(sendBeacon).toHaveBeenCalledWith('/qqq/v1/qbit/collaboration/presence', expect.any(Blob))
|
||||
expect((sendBeacon.mock.calls[0][1] as Blob).type).toBe('application/json')
|
||||
Object.defineProperty(navigator, 'sendBeacon', { value: undefined, configurable: true })
|
||||
expect(() => beaconPresence({ table: 'person', pk: '1', mode: 'VIEWING' } as never)).not.toThrow()
|
||||
it('sends an ordered DELETE that survives page teardown and tolerates network failure', async () => {
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response())
|
||||
beaconPresence({ connectionId: 'tab', table: 'person', pk: '1', mode: 'VIEWING', sequence: 4 })
|
||||
expect(fetch).toHaveBeenCalledWith('/qqq/v1/qbit/collaboration/presence', expect.objectContaining({
|
||||
method: 'DELETE', credentials: 'include', keepalive: true,
|
||||
body: JSON.stringify({ connectionId: 'tab', table: 'person', pk: '1', sequence: 4 }),
|
||||
}))
|
||||
fetch.mockRejectedValue(new Error('offline'))
|
||||
expect(() => beaconPresence({ connectionId: 'tab', table: 'person', mode: 'VIEWING', sequence: 5 })).not.toThrow()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('reads open personal tasks and rejects a task without an identifier', async () => {
|
||||
|
||||
@@ -649,28 +649,47 @@ export async function listTimeline(record: CollaborationRecord, cursor?: string,
|
||||
* @returns Current merged viewers.
|
||||
*/
|
||||
export const renewPresence = (input: PresenceRequest) =>
|
||||
apiClient.post<PresenceSnapshot>(`${BASE}/presence`, input)
|
||||
apiClient.post<PresenceSnapshot>(`${BASE}/presence/leases/renew`, input)
|
||||
/** Reserves an invisible lease for activation by a visible surface.
|
||||
* @param target - Authorized table or record.
|
||||
* @returns Server-issued connection handle.
|
||||
*/
|
||||
export const openPresenceLease = (target: { table: string; pk?: string }) =>
|
||||
apiClient.post<{ connectionId: string }>(`${BASE}/presence/leases`, target)
|
||||
/** Leaves a target.
|
||||
* @param input - Lease identity.
|
||||
* @returns Acknowledgement.
|
||||
*/
|
||||
export const leavePresence = (input: PresenceTarget) =>
|
||||
apiClient.delete(`${BASE}/presence`, { data: input })
|
||||
|
||||
/** Acknowledges only the caller's currently visible unread mentions.
|
||||
* @returns Mutation acknowledgement.
|
||||
*/
|
||||
export const markMentionsRead = () => apiClient.post(`${BASE}/my-work/mentions/read`, {})
|
||||
|
||||
/** Reads current viewers.
|
||||
* @param target - Target identity.
|
||||
* @returns Snapshot.
|
||||
*/
|
||||
export const getPresence = (target: { table: string; pk?: string }) =>
|
||||
apiClient.get<PresenceSnapshot>(`${BASE}/presence`, { params: target })
|
||||
/** Sends the last heartbeat on pagehide. Beacon only supports POST; DELETE remains the
|
||||
* unmount cleanup, and the server's 45-second TTL handles a closed document.
|
||||
/** Sends a best-effort departure that survives page teardown; expiry covers network loss.
|
||||
* @param input - Last presence lease.
|
||||
*/
|
||||
export function beaconPresence(input: PresenceRequest) {
|
||||
navigator.sendBeacon?.(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL || '/qqq/v1'}${BASE}/presence`,
|
||||
new Blob([JSON.stringify(input)], { type: 'application/json' })
|
||||
)
|
||||
void fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL || '/qqq/v1'}${BASE}/presence`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json', 'X-QQQ-Client': 'next-ui' },
|
||||
body: JSON.stringify({
|
||||
connectionId: input.connectionId,
|
||||
table: input.table,
|
||||
pk: input.pk,
|
||||
sequence: input.sequence,
|
||||
}),
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
/** Reads directory candidates.
|
||||
* @param q - Search text.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('@/lib/api/ui-api-version', () => ({ checkUiApiCompatibility: vi.fn(async () => ({ compatible: true, info: { apiVersion: '1.1.0', features: [], extensions: [], preSemver: false } })) }))
|
||||
vi.mock('@/lib/api/auth', () => ({
|
||||
getAuthenticationMetaData: vi.fn(async () => ({ name: 'auth0', type: 'AUTH_0' })),
|
||||
manageSession: vi.fn(), resumeSession: vi.fn(), logout: vi.fn(), clearAuthMetadataCache: vi.fn(),
|
||||
}))
|
||||
|
||||
import { manageSession, resumeSession } from '@/lib/api/auth'
|
||||
import { AuthProvider } from './auth-provider'
|
||||
import { useAuth } from './use-auth'
|
||||
import { getStoredUser, setSessionHint, storeUser } from './auth-storage'
|
||||
import * as oidc from './oidc'
|
||||
|
||||
let auth: ReturnType<typeof useAuth> | null = null
|
||||
|
||||
/** Exposes the real provider state for callback and reload assertions. */
|
||||
function Probe() {
|
||||
auth = useAuth()
|
||||
if (auth.isLoading) return <p>loading</p>
|
||||
return <p>{auth.isAuthenticated ? `${auth.user?.name} <${auth.user?.email}>` : 'signed out'}</p>
|
||||
}
|
||||
|
||||
describe('Auth0 identity with a server-issued user ID', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.mocked(manageSession).mockReset()
|
||||
vi.mocked(resumeSession).mockReset()
|
||||
auth = null
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('keeps the provider display identity and server ID after sign-in', async () => {
|
||||
const payload = btoa(JSON.stringify({ name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' }))
|
||||
vi.spyOn(oidc, 'exchangeAuth0Code').mockResolvedValue({ access_token: 'test-access', id_token: `h.${payload}.s` })
|
||||
vi.mocked(manageSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|dana' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
await screen.findByText('signed out')
|
||||
sessionStorage.setItem(oidc.PKCE_STORAGE.state, 'expected-state')
|
||||
await act(async () => { await auth?.handleOAuthCallback('code', 'expected-state', 'verifier') })
|
||||
expect(screen.getByText('Dana Owner (OIDC) <dana@qrun.example>')).toBeInTheDocument()
|
||||
expect(getStoredUser()).toEqual({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
|
||||
})
|
||||
|
||||
it('retains the display identity on reload when the server supplies the same ID', async () => {
|
||||
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
|
||||
setSessionHint(true)
|
||||
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|dana' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
expect(await screen.findByText('Dana Owner (OIDC) <dana@qrun.example>')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers server display fields and does not reuse a different user profile', async () => {
|
||||
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
|
||||
setSessionHint(true)
|
||||
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|eli', name: 'Eli' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
expect(await screen.findByText('Eli <undefined>')).toBeInTheDocument()
|
||||
expect(auth?.user).toEqual({ id: 'oidc|eli', name: 'Eli', email: undefined })
|
||||
})
|
||||
|
||||
it('does not reuse a cached profile when an ID-only session changes user', async () => {
|
||||
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
|
||||
setSessionHint(true)
|
||||
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|eli' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
await screen.findByText('undefined <undefined>')
|
||||
expect(auth?.user?.id).toBe('oidc|eli')
|
||||
})
|
||||
})
|
||||
@@ -185,11 +185,17 @@ function signInErrorMessage(error: unknown): string {
|
||||
* The identity to display for a session.
|
||||
*
|
||||
* @param response - The manageSession response.
|
||||
* @param fallback - Identity to use when the session carries none.
|
||||
* @param fallback - Display identity when the session carries no display fields.
|
||||
* @returns The user.
|
||||
*/
|
||||
function sessionUser(response: SessionResponse, fallback: AuthUser | null): AuthUser {
|
||||
return userFromSessionValues(response.values) ?? fallback ?? {}
|
||||
const session = userFromSessionValues(response.values)
|
||||
if (!session) return fallback ?? {}
|
||||
if (session.name || session.email) return session
|
||||
// An ID-only backend identity must retain its authoritative ID without dropping
|
||||
// the provider's display profile. Never reuse a profile for a different user ID.
|
||||
if (session.id && fallback?.id && session.id !== fallback.id) return session
|
||||
return { ...session, name: fallback?.name, email: fallback?.email }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,16 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('auth-storage', () => {
|
||||
it('preserves the canonical identity independently of the display email', () => {
|
||||
const user = userFromSessionValues({
|
||||
user: { id: 'alice', name: 'Alice', email: 'alice@example.test' },
|
||||
})
|
||||
expect(user).toEqual({ id: 'alice', name: 'Alice', email: 'alice@example.test' })
|
||||
storeUser(user!)
|
||||
expect(getStoredUser()?.id).toBe('alice')
|
||||
expect(userFromSessionValues({ user: { id: 'service-user' } })?.id).toBe('service-user')
|
||||
})
|
||||
|
||||
it.each([null, 'true', 'false'])('preserves appearance preference %s on logout', (preference) => {
|
||||
if (preference !== null) localStorage.setItem('qqq-dark-mode', preference)
|
||||
clearUserClientData()
|
||||
@@ -50,14 +60,22 @@ describe('auth-storage', () => {
|
||||
})
|
||||
|
||||
it('reads the displayed user from manageSession values (QRun-IO/qqq#332)', () => {
|
||||
expect(userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
|
||||
expect(
|
||||
userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })
|
||||
).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
|
||||
expect(userFromSessionValues({ user: { name: '', email: 7 } })).toBeNull()
|
||||
expect(userFromSessionValues(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a TABLE_BASED username as the identity line (QRun-IO/qqq#700)', () => {
|
||||
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess.table' })
|
||||
expect(userFromSessionValues({ user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
|
||||
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual(
|
||||
{ name: 'Tess Table', email: 'tess.table' }
|
||||
)
|
||||
expect(
|
||||
userFromSessionValues({
|
||||
user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' },
|
||||
})
|
||||
).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
|
||||
})
|
||||
|
||||
it('persists and validates the stored user', () => {
|
||||
@@ -70,7 +88,10 @@ describe('auth-storage', () => {
|
||||
})
|
||||
|
||||
it('keeps the full session values for analytics identity until sign-out (QRun-IO/qqq#730)', () => {
|
||||
const values = { user: { name: 'Alice (sample)', email: 'sample:alice' }, analyticsValues: { user_id: 'u-1' } }
|
||||
const values = {
|
||||
user: { name: 'Alice (sample)', email: 'sample:alice' },
|
||||
analyticsValues: { user_id: 'u-1' },
|
||||
}
|
||||
storeSessionValues(values)
|
||||
expect(getStoredSessionValues()).toEqual(values)
|
||||
storeSessionValues(undefined)
|
||||
@@ -109,15 +130,19 @@ describe('auth-storage', () => {
|
||||
expect(localStorage.getItem('accessToken')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears every table\'s remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)', () => {
|
||||
it("clears every table's remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)", () => {
|
||||
for (const table of ['person', 'pet']) {
|
||||
writeStoredQueryView(table, {
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
queryFilter: {
|
||||
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
|
||||
},
|
||||
viewIdentity: 'empty',
|
||||
})
|
||||
writeCurrentSavedViewId(table, 7)
|
||||
expect(readStoredQueryView(table)).toMatchObject({
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
queryFilter: {
|
||||
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
|
||||
},
|
||||
})
|
||||
expect(readCurrentSavedViewId(table)).toBe(7)
|
||||
}
|
||||
@@ -136,20 +161,25 @@ describe('auth-storage', () => {
|
||||
expect(readStoredQueryView(table)).toBeNull()
|
||||
expect(readCurrentSavedViewId(table)).toBeNull()
|
||||
}
|
||||
for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value)
|
||||
for (const [key, value] of Object.entries(preferences))
|
||||
expect(localStorage.getItem(key)).toBe(value)
|
||||
})
|
||||
|
||||
it('keeps remembered query state for the same identity and clears it before another identity claims the browser (QRun-IO/qqq#1006)', () => {
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.test' })
|
||||
writeStoredQueryView('person', {
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
queryFilter: {
|
||||
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
|
||||
},
|
||||
viewIdentity: 'savedView:7',
|
||||
})
|
||||
writeCurrentSavedViewId('person', 7)
|
||||
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.test' })
|
||||
expect(readStoredQueryView('person')).toMatchObject({
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
queryFilter: {
|
||||
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
|
||||
},
|
||||
viewIdentity: 'savedView:7',
|
||||
})
|
||||
expect(readCurrentSavedViewId('person')).toBe(7)
|
||||
@@ -160,9 +190,12 @@ describe('auth-storage', () => {
|
||||
expect(localStorage.getItem('qqq.clientDataOwner')).toBe('bob@example.test')
|
||||
})
|
||||
|
||||
it('clears the previous user\'s data when someone else signs in in this browser (QRun-IO/qqq#696)', () => {
|
||||
it("clears the previous user's data when someone else signs in in this browser (QRun-IO/qqq#696)", () => {
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.com' })
|
||||
localStorage.setItem('qqq-recent-records', JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }]))
|
||||
localStorage.setItem(
|
||||
'qqq-recent-records',
|
||||
JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }])
|
||||
)
|
||||
storeUser({ name: 'Alice', email: 'alice@example.com' })
|
||||
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.com' })
|
||||
|
||||
@@ -31,6 +31,7 @@ const SESSION_HINT_KEY = 'qqq.sessionHint'
|
||||
|
||||
/** The displayed identity of the signed-in user. */
|
||||
export interface StoredUser {
|
||||
id?: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
@@ -102,16 +103,24 @@ export function setSessionHint(present: boolean): void {
|
||||
* @param values - The session values for the frontend.
|
||||
* @returns The user when it has a name or email.
|
||||
*/
|
||||
export function userFromSessionValues(values: Record<string, unknown> | undefined): StoredUser | null {
|
||||
export function userFromSessionValues(
|
||||
values: Record<string, unknown> | undefined
|
||||
): StoredUser | null {
|
||||
const user = values?.user
|
||||
if (!user || typeof user !== 'object') return null
|
||||
const { name, email, username } = user as Record<string, unknown>
|
||||
const identity = typeof email === 'string' && email ? email : typeof username === 'string' && username ? username : undefined
|
||||
const { id, name, email, username } = user as Record<string, unknown>
|
||||
const identity =
|
||||
typeof email === 'string' && email
|
||||
? email
|
||||
: typeof username === 'string' && username
|
||||
? username
|
||||
: undefined
|
||||
const result: StoredUser = {
|
||||
...(typeof id === 'string' && id ? { id } : {}),
|
||||
name: typeof name === 'string' && name ? name : undefined,
|
||||
email: identity,
|
||||
}
|
||||
return result.name || result.email ? result : null
|
||||
return result.id || result.name || result.email ? result : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +171,9 @@ export function storeSessionValues(values: Record<string, unknown> | undefined):
|
||||
export function getStoredSessionValues(): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(storage('local')?.getItem(SESSION_VALUES_KEY) ?? 'null') as unknown
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -179,7 +190,9 @@ export function recordReauthAttempt(now = Date.now()): boolean {
|
||||
const session = storage('session')
|
||||
let attempts: number[] = []
|
||||
try {
|
||||
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter((time) => now - time < 60_000)
|
||||
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter(
|
||||
(time) => now - time < 60_000
|
||||
)
|
||||
} catch {
|
||||
attempts = []
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('@/lib/context/q-context', async () => {
|
||||
})
|
||||
vi.mock('./use-realtime-channel', () => ({ useRealtimeChannel: vi.fn() }))
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
openPresenceLease: vi.fn(),
|
||||
renewPresence: vi.fn(),
|
||||
leavePresence: vi.fn(),
|
||||
getPresence: vi.fn(),
|
||||
@@ -33,6 +34,10 @@ vi.mock('@/lib/api/collaboration', () => ({
|
||||
}))
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
let nextLease = 0
|
||||
vi.mocked(api.openPresenceLease).mockImplementation(async () => ({
|
||||
connectionId: `lease:${++nextLease}`,
|
||||
}))
|
||||
vi.mocked(api.getPresence).mockResolvedValue({ table: 'person', viewers: [] })
|
||||
vi.mocked(api.renewPresence).mockResolvedValue({ table: 'person', viewers: [] })
|
||||
vi.mocked(api.leavePresence).mockResolvedValue({})
|
||||
@@ -50,12 +55,15 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
|
||||
wrapper,
|
||||
initialProps: { field: 'firstName' },
|
||||
})
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.renewPresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'EDITING', fieldName: 'firstName' })
|
||||
)
|
||||
await act(() => vi.advanceTimersByTimeAsync(20000))
|
||||
expect(api.renewPresence).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.renewPresence).mock.calls.map(([input]) => input.sequence)).toEqual([1, 2])
|
||||
hook.rerender({ field: 'lastName' })
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
|
||||
expect(api.renewPresence).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ fieldName: 'lastName' })
|
||||
)
|
||||
@@ -64,9 +72,9 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
|
||||
expect.objectContaining({ fieldName: 'lastName' })
|
||||
)
|
||||
hook.unmount()
|
||||
expect(api.leavePresence).toHaveBeenCalledTimes(1)
|
||||
expect(api.leavePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ table: 'person', pk: '1' })
|
||||
expect(api.leavePresence).not.toHaveBeenCalled()
|
||||
expect(api.beaconPresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ table: 'person', pk: '1', sequence: 4 })
|
||||
)
|
||||
})
|
||||
it('merges tabs and excludes my own tabs, preferring editors', () => {
|
||||
@@ -81,3 +89,155 @@ it('merges tabs and excludes my own tabs, preferring editors', () => {
|
||||
)
|
||||
).toEqual([{ userId: 'sam', displayName: 'Sam', mode: 'EDITING', fieldName: 'name' }])
|
||||
})
|
||||
|
||||
it('reports failed renewals even when reads succeed, and recovers on a successful renewal', async () => {
|
||||
vi.mocked(api.renewPresence).mockRejectedValueOnce(new Error('Unavailable'))
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(hook.result.current.unavailable).toBe(true)
|
||||
expect(hook.result.current.viewers).toEqual([])
|
||||
await act(() => vi.advanceTimersByTimeAsync(20000))
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('ignores a failed renewal from the previous record', async () => {
|
||||
let rejectOld: (reason: Error) => void = () => undefined
|
||||
vi.mocked(api.renewPresence).mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectOld = reject
|
||||
})
|
||||
)
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(({ pk }) => useCollaborationPresence('person', pk), {
|
||||
wrapper,
|
||||
initialProps: { pk: '1' },
|
||||
})
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
hook.rerender({ pk: '2' })
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
await act(async () => {
|
||||
rejectOld(new Error('Old request failed'))
|
||||
})
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('releases a late reservation without activating a closed surface', async () => {
|
||||
let resolveOpen!: (value: { connectionId: string }) => void
|
||||
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOpen = resolve
|
||||
})
|
||||
)
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
hook.unmount()
|
||||
await act(async () => {
|
||||
resolveOpen({ connectionId: 'lease:late' })
|
||||
})
|
||||
expect(api.renewPresence).not.toHaveBeenCalled()
|
||||
expect(api.leavePresence).toHaveBeenCalledWith({
|
||||
table: 'person',
|
||||
pk: '1',
|
||||
connectionId: 'lease:late',
|
||||
sequence: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejoins once with a new handle after an explicit lease expiry', async () => {
|
||||
vi.mocked(api.renewPresence).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
|
||||
})
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
vi.mocked(api.renewPresence).mock.calls.map(([input]) => [input.connectionId, input.sequence])
|
||||
).toEqual([
|
||||
['lease:1', 1],
|
||||
['lease:2', 1],
|
||||
])
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('opens a fresh lease on pageshow instead of reviving the departed handle', async () => {
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
act(() => window.dispatchEvent(new Event('pagehide')))
|
||||
act(() => window.dispatchEvent(new Event('pageshow')))
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
|
||||
expect(api.renewPresence).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionId: 'lease:2', sequence: 1 })
|
||||
)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('bounds immediate rejoin attempts when replacement leases also expire', async () => {
|
||||
vi.mocked(api.renewPresence).mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
|
||||
})
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
|
||||
expect(hook.result.current.unavailable).toBe(true)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('discards an old pending open after hide and show while preserving the new handle', async () => {
|
||||
let resolveOld!: (value: { connectionId: string }) => void
|
||||
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve
|
||||
})
|
||||
)
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
act(() => window.dispatchEvent(new Event('pagehide')))
|
||||
act(() => window.dispatchEvent(new Event('pageshow')))
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
await act(async () => {
|
||||
resolveOld({ connectionId: 'lease:old' })
|
||||
})
|
||||
expect(api.renewPresence).toHaveBeenCalledTimes(1)
|
||||
expect(api.renewPresence).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionId: 'lease:1', sequence: 1 })
|
||||
)
|
||||
expect(api.leavePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'lease:old', sequence: 1 })
|
||||
)
|
||||
hook.unmount()
|
||||
expect(api.leavePresence).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionId: 'lease:1', sequence: 2 })
|
||||
)
|
||||
})
|
||||
@@ -16,10 +16,17 @@
|
||||
|
||||
/** @file Ephemeral presence leases with focus changes, polling fallback and realtime refresh. */
|
||||
'use client'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isAxiosError } from 'axios'
|
||||
import type { PresenceSnapshot, PresenceViewer } from '@/types/collaboration'
|
||||
import { renewPresence, leavePresence, getPresence, beaconPresence } from '@/lib/api/collaboration'
|
||||
import {
|
||||
openPresenceLease,
|
||||
renewPresence,
|
||||
leavePresence,
|
||||
getPresence,
|
||||
beaconPresence,
|
||||
} from '@/lib/api/collaboration'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { QContext } from '@/lib/context/q-context'
|
||||
import { useRealtimeChannel } from './use-realtime-channel'
|
||||
@@ -51,8 +58,11 @@ export function useCollaborationPresence(
|
||||
fieldName?: string,
|
||||
enabled = true
|
||||
) {
|
||||
const [connectionId] = useState(() => crypto.randomUUID())
|
||||
const focus = useRef({ editing, fieldName })
|
||||
focus.current = { editing, fieldName }
|
||||
const refresh = useRef<() => void>(() => undefined)
|
||||
const [ended, setEnded] = useState(false)
|
||||
const [renewalFailed, setRenewalFailed] = useState(false)
|
||||
const userId = useContext(QContext)?.userId
|
||||
const cache = useQueryClient()
|
||||
const key = queryKeys.collaborationResource(table, pk ?? '', 'presence')
|
||||
@@ -65,7 +75,14 @@ export function useCollaborationPresence(
|
||||
useRealtimeChannel(
|
||||
pk ? `record:${table}:${pk}` : `table:${table}`,
|
||||
({ event, data }) => {
|
||||
if (event === 'session-ended') setEnded(true)
|
||||
if (
|
||||
event === 'session-ended' ||
|
||||
(event === 'channel-revoked' &&
|
||||
data.channel === (pk ? `record:${table}:${pk}` : `table:${table}`))
|
||||
) {
|
||||
setEnded(true)
|
||||
cache.setQueryData(key, { table, pk, viewers: [] })
|
||||
}
|
||||
if (
|
||||
event === 'presence.changed' &&
|
||||
data.table === table &&
|
||||
@@ -78,34 +95,112 @@ export function useCollaborationPresence(
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!enabled || ended) return
|
||||
const input = {
|
||||
connectionId,
|
||||
table,
|
||||
pk,
|
||||
mode: editing ? ('EDITING' as const) : ('VIEWING' as const),
|
||||
fieldName,
|
||||
}
|
||||
const heartbeat = () => {
|
||||
void renewPresence(input)
|
||||
.then((snapshot) =>
|
||||
let active = true
|
||||
let visible = document.visibilityState !== 'hidden'
|
||||
let connectionId: string | undefined
|
||||
let sequence = 0
|
||||
let generation = 0
|
||||
let opening = false
|
||||
const heartbeat = async (rejoining = false) => {
|
||||
if (!active || !visible || opening) return
|
||||
const requestGeneration = generation
|
||||
let requestSequence = sequence
|
||||
try {
|
||||
if (!connectionId) {
|
||||
opening = true
|
||||
const lease = await openPresenceLease({ table, pk })
|
||||
if (!active || !visible || generation !== requestGeneration) {
|
||||
void leavePresence({ table, pk, connectionId: lease.connectionId, sequence: 1 }).catch(
|
||||
() => undefined
|
||||
)
|
||||
return
|
||||
}
|
||||
opening = false
|
||||
connectionId = lease.connectionId
|
||||
sequence = 0
|
||||
}
|
||||
requestSequence = ++sequence
|
||||
const snapshot = await renewPresence({
|
||||
table,
|
||||
pk,
|
||||
connectionId,
|
||||
sequence: requestSequence,
|
||||
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
|
||||
fieldName: focus.current.fieldName,
|
||||
})
|
||||
if (active && visible && generation === requestGeneration && sequence === requestSequence) {
|
||||
setRenewalFailed(false)
|
||||
cache.setQueryData(queryKeys.collaborationResource(table, pk ?? '', 'presence'), snapshot)
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!active || !visible || generation !== requestGeneration || sequence !== requestSequence)
|
||||
return
|
||||
opening = false
|
||||
setRenewalFailed(true)
|
||||
if (
|
||||
isAxiosError(error) &&
|
||||
error.response?.status === 409 &&
|
||||
error.response.data?.code === 'PRESENCE_LEASE_EXPIRED'
|
||||
) {
|
||||
connectionId = undefined
|
||||
generation++
|
||||
if (!rejoining) void heartbeat(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
heartbeat()
|
||||
const timer = setInterval(heartbeat, 20000)
|
||||
const hide = () => beaconPresence(input)
|
||||
refresh.current = () => {
|
||||
void heartbeat()
|
||||
}
|
||||
void heartbeat()
|
||||
const timer = setInterval(() => {
|
||||
void heartbeat()
|
||||
}, 20000)
|
||||
const release = (beacon: boolean) => {
|
||||
generation++
|
||||
opening = false
|
||||
const previous = connectionId
|
||||
connectionId = undefined
|
||||
if (!previous) return
|
||||
const input = { table, pk, connectionId: previous, sequence: ++sequence }
|
||||
if (beacon)
|
||||
beaconPresence({
|
||||
...input,
|
||||
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
|
||||
fieldName: focus.current.fieldName,
|
||||
})
|
||||
else void leavePresence(input).catch(() => undefined)
|
||||
}
|
||||
const hide = () => {
|
||||
visible = false
|
||||
release(true)
|
||||
}
|
||||
const show = () => {
|
||||
visible = document.visibilityState !== 'hidden'
|
||||
void heartbeat()
|
||||
}
|
||||
const visibility = () => (document.visibilityState === 'hidden' ? hide() : show())
|
||||
window.addEventListener('pagehide', hide)
|
||||
window.addEventListener('pageshow', show)
|
||||
document.addEventListener('visibilitychange', visibility)
|
||||
return () => {
|
||||
active = false
|
||||
release(false)
|
||||
refresh.current = () => undefined
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('pagehide', hide)
|
||||
window.removeEventListener('pageshow', show)
|
||||
document.removeEventListener('visibilitychange', visibility)
|
||||
}
|
||||
}, [connectionId, table, pk, editing, fieldName, enabled, ended, cache])
|
||||
}, [table, pk, enabled, ended, cache])
|
||||
useEffect(() => {
|
||||
if (!enabled || ended) return
|
||||
return () => {
|
||||
void leavePresence({ connectionId, table, pk }).catch(() => undefined)
|
||||
}
|
||||
}, [connectionId, table, pk, enabled, ended])
|
||||
return { ...query, viewers: otherViewers(query.data?.viewers ?? [], userId) }
|
||||
refresh.current()
|
||||
}, [editing, fieldName])
|
||||
return {
|
||||
...query,
|
||||
unavailable: enabled && !ended && (query.isError || renewalFailed),
|
||||
viewers:
|
||||
enabled && !ended && !query.isError && !renewalFailed
|
||||
? otherViewers(query.data?.viewers ?? [], userId)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,23 @@ it('invalidates records, panel counts, tasks, notifications and resync queries',
|
||||
invalidateRealtime(cache, { event: 'resync', data: {} })
|
||||
expect(invalidate).toHaveBeenLastCalledWith({ queryKey: queryKeys.all() })
|
||||
})
|
||||
it('presence hints invalidate only their record or table room without trusting viewer payloads', () => {
|
||||
const cache = new QueryClient()
|
||||
const invalidate = vi.spyOn(cache, 'invalidateQueries')
|
||||
const recordKey = queryKeys.collaborationResource('person', '1', 'presence')
|
||||
cache.setQueryData(recordKey, { viewers: [] })
|
||||
invalidateRealtime(cache, {
|
||||
event: 'presence.invalidated',
|
||||
data: { table: 'person', pk: 1, viewers: [{ userId: 'untrusted' }] },
|
||||
})
|
||||
expect(invalidate).toHaveBeenCalledExactlyOnceWith({ queryKey: recordKey })
|
||||
expect(cache.getQueryData(recordKey)).toEqual({ viewers: [] })
|
||||
invalidate.mockClear()
|
||||
invalidateRealtime(cache, { event: 'presence.invalidated', data: { table: 'person' } })
|
||||
expect(invalidate).toHaveBeenCalledExactlyOnceWith({
|
||||
queryKey: queryKeys.collaborationResource('person', '', 'presence'),
|
||||
})
|
||||
})
|
||||
class Bus {
|
||||
static peers = new Set<Bus>()
|
||||
onmessage?: (event: MessageEvent) => void
|
||||
|
||||
@@ -41,6 +41,11 @@ export function invalidateRealtime(client: QueryClient, { event, data }: Realtim
|
||||
}
|
||||
if (event === 'task.changed') invalidate(queryKeys.collaborationMyWork())
|
||||
if (typeof data.table !== 'string') return
|
||||
if (event === 'presence.invalidated') {
|
||||
// Hints contain no viewer state; reread through the authenticated presence API.
|
||||
invalidate(queryKeys.collaborationResource(data.table, String(data.pk ?? ''), 'presence'))
|
||||
return
|
||||
}
|
||||
if (event === 'record.changed' || event === 'record.deleted' || event === 'table.changed')
|
||||
invalidate(queryKeys.tableRecords(data.table))
|
||||
if (data.pk === undefined || data.pk === null) return
|
||||
|
||||
@@ -601,6 +601,8 @@ const viewers = [
|
||||
{ userId: 'sam', displayName: 'Sam Parker', mode: 'EDITING', fieldName: 'firstName' },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', mode: 'VIEWING' },
|
||||
]
|
||||
// Demo peers are fixed fixtures; only the current browser's issued handles are tracked.
|
||||
const presenceLeases = new Map<string, { table: string; pk?: string }>()
|
||||
const taskStore: import('@/types/collaboration').CollaborationTaskV1[] = [
|
||||
{
|
||||
id: 8001,
|
||||
@@ -671,11 +673,28 @@ collaborationHandlers.push(
|
||||
const params = new URL(request.url).searchParams
|
||||
return HttpResponse.json({ table: params.get('table'), pk: params.get('pk'), viewers })
|
||||
}),
|
||||
http.post(`${BASE}/presence`, async ({ request }) => {
|
||||
const input = (await request.json()) as Record<string, unknown>
|
||||
http.post(`${BASE}/presence/leases`, async ({ request }) => {
|
||||
const target = (await request.json()) as { table: string; pk?: string }
|
||||
const connectionId = `lease:${crypto.randomUUID()}`
|
||||
presenceLeases.set(connectionId, target)
|
||||
return HttpResponse.json({ connectionId })
|
||||
}),
|
||||
http.post(`${BASE}/presence/leases/renew`, async ({ request }) => {
|
||||
const input = (await request.json()) as {
|
||||
table: string
|
||||
pk?: string
|
||||
connectionId: string
|
||||
}
|
||||
const target = presenceLeases.get(input.connectionId)
|
||||
if (!target || target.table !== input.table || target.pk !== input.pk)
|
||||
return HttpResponse.json({ code: 'PRESENCE_LEASE_EXPIRED' }, { status: 409 })
|
||||
return HttpResponse.json({ table: input.table, pk: input.pk, viewers })
|
||||
}),
|
||||
http.delete(`${BASE}/presence`, () => HttpResponse.json({ success: true })),
|
||||
http.delete(`${BASE}/presence`, async ({ request }) => {
|
||||
const input = (await request.json()) as { connectionId: string }
|
||||
presenceLeases.delete(input.connectionId)
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
http.get(`${BASE}/people`, ({ request }) => {
|
||||
const q = new URL(request.url).searchParams.get('q')?.toLowerCase() ?? ''
|
||||
return HttpResponse.json({
|
||||
@@ -770,7 +789,11 @@ collaborationHandlers.push(
|
||||
completedByDisplayName: nameOf('me'),
|
||||
})
|
||||
if (input.status === 'OPEN')
|
||||
Object.assign(task, { completedAt: null, completedByUserId: null, completedByDisplayName: null })
|
||||
Object.assign(task, {
|
||||
completedAt: null,
|
||||
completedByUserId: null,
|
||||
completedByDisplayName: null,
|
||||
})
|
||||
publishCollaborationEvent('task.changed', {
|
||||
table: task.tableName,
|
||||
pk: task.recordId,
|
||||
|
||||
@@ -597,6 +597,7 @@ export interface PresenceViewer {
|
||||
}
|
||||
/** Presence target and client lease identity. */
|
||||
export interface PresenceTarget {
|
||||
sequence?: number
|
||||
connectionId: string
|
||||
table: string
|
||||
pk?: string
|
||||
|
||||
@@ -123,6 +123,10 @@ test.describe('command palette and search', () => {
|
||||
await expect(pageLinks.filter({ hasText: /^Pet/ })).toHaveText([/^Pet(?! )/, /^Pet Species/, /^Pet Note/])
|
||||
for (const text of await pageLinks.allTextContents()) expect(text).toMatch(/pet/i)
|
||||
}
|
||||
// Help results load above Pages and can move the link during a pointer gesture.
|
||||
// Wait for the actual result, including after reload, before testing navigation.
|
||||
await expect(page.locator('[data-qqq-id="help-search-results"] [data-qqq-id="help-result-table:petNote;slot:help"]')).toBeVisible()
|
||||
await expect(page.locator('[data-qqq-id="help-search-results"]').getByRole('status')).toHaveCount(0)
|
||||
await page.getByRole('region', { name: 'Pages' }).getByRole('link', { name: /^Pet Note/ }).click()
|
||||
await expect(page).toHaveURL(/\/app\/petNote\/?$/)
|
||||
expect(searchStatuses.length).toBeGreaterThan(0)
|
||||
@@ -136,10 +140,12 @@ test.describe('command palette and search', () => {
|
||||
await page.locator('[data-qqq-id="button-search"]').click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Command palette' })
|
||||
await dialog.getByRole('combobox').fill('clone')
|
||||
const clone = dialog.getByRole('option', { name: /Clone People/ })
|
||||
// Help arrives asynchronously and can share the process label; it is not a local page match.
|
||||
await expect(dialog.locator('[data-qqq-id="help-result-process:clonePeople;slot:help"]')).toBeVisible()
|
||||
const clone = dialog.locator('[data-qqq-id="command-item-clonePeople"]')
|
||||
await expect(clone).toHaveCount(1)
|
||||
await expect(clone).toContainText('People App')
|
||||
await expect(dialog.getByRole('option')).toHaveCount(2)
|
||||
await expect(dialog.locator('[data-qqq-id^="command-item-"]')).toHaveCount(1)
|
||||
await expect(dialog.locator('[data-qqq-id="command-search-all"]')).toContainText('Search everything for “clone”')
|
||||
await expectTouchTargets(dialog)
|
||||
await expect(clone).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
/** @file Exercises the real variant lifecycle with only process and filesystem boundaries replaced. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import path from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -39,7 +40,7 @@ vi.mock('node:child_process', () => {
|
||||
// Existing stale export and build files cannot determine the browser's selected server.
|
||||
vi.mock('node:fs', async (original) => {
|
||||
const fs = {
|
||||
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith('next-dashboard/index.html') ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
|
||||
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith(path.join('next-dashboard', 'index.html')) ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
|
||||
readdirSync: () => ['Fixture.java'],
|
||||
readFileSync: (file: string) => file.endsWith('routes-manifest.json')
|
||||
? JSON.stringify({ rewrites: { beforeFiles: [], afterFiles: ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession', 'apis.json', 'api', 'qa'].map((prefix) => ({ source: `/${prefix}/:path*`, destination: `http://127.0.0.1:${state.wrongTarget ? '18765' : '18775'}/${prefix}/:path*` })), fallback: [] } })
|
||||
|
||||
@@ -19,9 +19,25 @@ import path from 'node:path'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function openRecord(page: Page) {
|
||||
const opened = page.waitForResponse(
|
||||
(r) => r.url().endsWith('/presence/leases') && r.request().method() === 'POST'
|
||||
)
|
||||
const renewed = page.waitForResponse(
|
||||
(r) => r.url().endsWith('/presence/leases/renew') && r.request().method() === 'POST'
|
||||
)
|
||||
await page.goto('/app/person/1')
|
||||
expect((await opened).status()).toBe(200)
|
||||
const renewal = await renewed
|
||||
expect(renewal.status()).toBe(200)
|
||||
expect(renewal.request().postDataJSON()).toMatchObject({
|
||||
table: 'person',
|
||||
pk: '1',
|
||||
mode: 'VIEWING',
|
||||
connectionId: expect.stringMatching(/^lease:/),
|
||||
})
|
||||
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toHaveText('Alice Johnson')
|
||||
await expect(page.locator('[data-qqq-id="presence-sam"]')).toBeVisible()
|
||||
await expect(page.getByText('Presence temporarily unavailable', { exact: true })).toHaveCount(0)
|
||||
}
|
||||
async function concurrentEdit(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
|
||||
@@ -49,10 +49,12 @@ it('keeps the variant build, static assets and entrypoint after the main build d
|
||||
expect(readFileSync(path.join(staged, 'public/icon.svg'), 'utf8')).toBe('<svg/>')
|
||||
expect(readFileSync(path.join(staged, 'qqq-server.mjs'), 'utf8')).toBe('container entrypoint')
|
||||
expect(readFileSync(path.join(staged, 'branding-assets.mjs'), 'utf8')).toBe('declared branding aliases')
|
||||
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe('.pnpm/example')
|
||||
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe(path.join('.pnpm', 'example'))
|
||||
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
|
||||
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
|
||||
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
|
||||
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an old shared-backend rewrite even with a new valid variant marker', () => {
|
||||
|
||||
+5
-1
@@ -37,7 +37,11 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
".next/dev/types/**/*.ts",
|
||||
".next-collaboration/types/**/*.ts",
|
||||
".next-collaboration/dev/types/**/*.ts",
|
||||
".next-presence-live/types/**/*.ts",
|
||||
".next-presence-live/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in new issue
Block a user